"""Slice 3 API surface — the §10 PR flow's endpoints.

Owns every `prs/<pr_number>/...` route from §17, plus the branch-scoped
`pr-draft` and `open-pr` endpoints that compose the §10.2 modal. Read
paths fetch branch and main bodies live from Gitea; write paths funnel
through `bot.py` so the §1 chokepoint and the §6.5 trailer hold.

Visibility and the §11.3 universal-public rule fall out structurally:
opening a PR is the moment a private branch goes public, and the
confirmation lives on the §10.1 affordance rather than as a side-effect
of toggling visibility. The frontend confirms; the server is the
authority that flips the branch visibility row at open time.

Permission gates per §6.1 (app admin/owner), §6.3 (per-RFC owners and
arbiters), and §6.5 (every PR write carries an On-behalf-of trailer).
"""
from __future__ import annotations

import json
import logging
from typing import Any

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod, rfc_links
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
from .providers import BaseProvider

log = logging.getLogger(__name__)

RFC_FILE_PATH = "RFC.md"


# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------


class OpenPRBody(BaseModel):
    title: str = Field(min_length=1, max_length=240)
    description: str = Field(max_length=8000)
    # Roadmap #26: optional "What will you be using this change for?" —
    # the concrete ground-truth use case sibling to the required
    # "why is this change needed" (the `description`). Optional, generous
    # cap matching the description bound.
    proposed_use_case: str | None = Field(default=None, max_length=8000)


class PRDescriptionBody(BaseModel):
    title: str = Field(min_length=1, max_length=240)
    description: str = Field(max_length=8000)


class PRSeenBody(BaseModel):
    last_seen_commit_sha: str | None = None
    last_seen_message_id: int | None = None


class PRReviewBody(BaseModel):
    text: str = Field(min_length=1, max_length=20_000)
    anchor_payload: dict = Field(default_factory=dict)
    quote: str | None = Field(default=None, max_length=2000)


def make_router(
    config: Config,
    gitea: Gitea,
    bot: Bot,
    providers: dict[str, BaseProvider],
) -> APIRouter:
    router = APIRouter()

    # -------------------------------------------------------------------
    # §10.2: AI-drafted PR title and description.
    # Returned ahead of submit so the modal renders with prefilled values
    # the contributor can edit. The contributor's gesture is what
    # produces the open-pr call; the draft is just a starting point.
    # Per §6.6 the model used is the RFC's resolved default; an empty
    # resolved list falls back to the deterministic stub.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
    async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_active_rfc(slug, viewer)
        owner, repo = _owner_repo(rfc)
        path = _file_path_for(rfc)
        if not _branch_has_commits_ahead(slug, branch):
            raise HTTPException(409, "Branch has no commits ahead of main")
        main_fetched = await gitea.read_file(owner, repo, path, ref="main")
        branch_fetched = await gitea.read_file(owner, repo, path, ref=branch)
        if not branch_fetched:
            raise HTTPException(404, f"Branch {path} not found")
        chat_messages = _branch_chat_excerpt(slug, branch)
        rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
        title, description = _draft_with_provider(
            slug=slug,
            providers=providers,
            default_model=rfc_default_model,
            rfc_title=rfc["title"],
            main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]),
            branch_body=_extract_body(rfc, branch_fetched[0]),
            chat_messages=chat_messages,
        )
        _ = viewer  # silence unused
        return {"title": title, "description": description}

    # -------------------------------------------------------------------
    # §10.1: open a PR. The §11.3 universal-public flip is server-side —
    # the frontend confirms before calling; this endpoint flips the
    # branch's read_public unconditionally.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
    async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        # v0.16.0 (item #12): opening a PR is the canonical PR-shaped
        # write — the gate fires here even though the branch-cutting
        # entry points also gate, since a user with prior branch access
        # who's since had their per-RFC role revoked shouldn't be able
        # to ship the PR. The branch-creation gate is the kickoff
        # refusal; this one is the post-work refusal.
        if not auth.can_contribute_to_rfc(viewer, slug):
            raise HTTPException(
                403,
                "This RFC's owner has not invited you to contribute PRs",
            )
        rfc = _require_active_rfc(slug, viewer)
        if branch == "main":
            raise HTTPException(409, "PRs open from non-main branches")
        owner, repo = _owner_repo(rfc)

        # §10.1: branch must have commits ahead of main.
        if not _branch_has_commits_ahead(slug, branch):
            raise HTTPException(409, "Branch has no commits ahead of main")

        # §10.9: at most one open PR per branch.
        existing = db.conn().execute(
            """
            SELECT pr_number FROM cached_prs
             WHERE rfc_slug = ? AND head_branch = ? AND state = 'open'
            """,
            (slug, branch),
        ).fetchone()
        if existing:
            raise HTTPException(409, "This branch already has an open PR")

        # §11.3: opening a PR makes the branch publicly readable.
        db.conn().execute(
            """
            INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
            VALUES (?, ?, 1, 'just-me')
            ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
            """,
            (slug, branch),
        )

        # §10.9: when the branch is a resolution branch, the open carries
        # a `Supersedes:` trailer naming the original PR so the cache
        # closes it on the resolution PR's merge.
        supersedes = _resolution_origin(slug, branch)

        try:
            pr = await bot.open_branch_pr(
                viewer.as_actor(),
                owner=owner,
                repo=repo,
                head_branch=branch,
                title=body.title.strip(),
                description=body.description.strip(),
                slug=slug,
                supersedes_pr_number=supersedes,
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")

        await _refresh_after_pr_write(rfc)

        # Roadmap #26: persist the optional use case to the canonical,
        # reconcile-proof side table keyed by the PR number. Blank/omitted
        # writes no row (absence == "left blank"). The mirror onto
        # cached_prs keeps the cache column in parity.
        use_case = (body.proposed_use_case or "").strip()
        if use_case:
            db.conn().execute(
                """
                INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
                VALUES ('pr', ?, ?, ?)
                ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
                """,
                (slug, pr["number"], use_case),
            )
            db.conn().execute(
                "UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
                (use_case, slug, pr["number"]),
            )

        return {"pr_number": pr["number"], "slug": slug, "branch": branch}

    # -------------------------------------------------------------------
    # §10.3: the PR review page data.
    # -------------------------------------------------------------------

    @router.get("/api/rfcs/{slug}/prs/{pr_number}")
    async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
        viewer = auth.current_user(request)
        rfc = _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        owner, repo = _owner_repo(rfc)
        path = _file_path_for(rfc)
        head_branch = pr_row["head_branch"]

        # Roadmap #28 Part 1: build the RFC auto-link index once for this
        # PR view (read-time enrichment against the live accepted-RFC
        # corpus; see rfc_links.py). exclude_slug suppresses self-links to
        # this RFC inside its own PR.
        link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)

        # §11.3: PRs are always public; no visibility check.
        main_fetched = await gitea.read_file(owner, repo, path, ref="main")
        main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
        merge_sha = pr_row["merge_commit_sha"] if "merge_commit_sha" in pr_row.keys() else None
        branch_ref = merge_sha if pr_row["state"] == "merged" and merge_sha else head_branch
        branch_fetched = await gitea.read_file(owner, repo, path, ref=branch_ref) if branch_ref else None
        if branch_fetched is None:
            # Fall back to head_branch if the merge commit is gone.
            branch_fetched = await gitea.read_file(owner, repo, path, ref=head_branch) or ("", "")
        branch_body = _extract_body(rfc, branch_fetched[0])

        # Threads + messages — the branch chat is the PR's conversation
        # surface per §10.4. Both `chat`/`flag` and `review` kinds
        # surface here; the frontend renders them inline with visual
        # distinction.
        thread_rows = db.conn().execute(
            """
            SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
                   created_by, created_at, resolved_at, resolved_by
              FROM threads
             WHERE rfc_slug = ? AND branch_name = ?
             ORDER BY id
            """,
            (slug, head_branch),
        ).fetchall()
        threads = [_serialize_thread(r) for r in thread_rows]
        thread_ids = [t["id"] for t in threads]

        messages_by_thread: dict[int, list[dict]] = {tid: [] for tid in thread_ids}
        if thread_ids:
            placeholders = ",".join("?" * len(thread_ids))
            msg_rows = db.conn().execute(
                f"""
                SELECT m.id, m.thread_id, m.role, m.author_user_id,
                       u.gitea_login AS author_login, u.display_name AS author_display,
                       m.model_id, m.text, m.quote, m.created_at
                  FROM thread_messages m
             LEFT JOIN users u ON u.id = m.author_user_id
                 WHERE m.thread_id IN ({placeholders})
                 ORDER BY m.id
                """,
                tuple(thread_ids),
            ).fetchall()
            for r in msg_rows:
                messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))

        # Roadmap #28 Part 1: enrich every comment with RFC auto-link
        # segments (read-time; see rfc_links.py). The description is
        # enriched alongside it in the return dict below.
        for _msgs in messages_by_thread.values():
            for _m in _msgs:
                _m["text_segments"] = link_index.segment(_m["text"])

        # Per-user seen cursor per §10.3. Anonymous viewers get no
        # cursor — they always see "everything new" but cannot advance
        # the cursor (no row to write to).
        seen = None
        if viewer is not None:
            seen_row = db.conn().execute(
                """
                SELECT last_seen_commit_sha, last_seen_message_id, seen_at
                  FROM pr_seen
                 WHERE user_id = ? AND rfc_slug = ? AND pr_number = ?
                """,
                (viewer.user_id, slug, pr_number),
            ).fetchone()
            if seen_row:
                seen = {
                    "last_seen_commit_sha": seen_row["last_seen_commit_sha"],
                    "last_seen_message_id": seen_row["last_seen_message_id"],
                    "seen_at": seen_row["seen_at"],
                }

        # Live Gitea pull for mergeability per §10.5 / §10.9.
        mergeable = None
        conflict_files: list[str] = []
        if pr_row["state"] == "open":
            try:
                live = await gitea.get_pull(owner, repo, pr_number)
            except GiteaError:
                live = None
            if live is not None:
                mergeable = bool(live.get("mergeable"))
                if not mergeable:
                    conflict_files = [path]

        # Aggregate counts the header strip surfaces per §10.3.
        open_review = sum(1 for t in threads if t["thread_kind"] == "review" and t["state"] == "open")
        open_chat = sum(1 for t in threads if t["thread_kind"] == "chat" and t["state"] == "open" and t["anchor_kind"] != "whole-doc")
        open_flags = sum(1 for t in threads if t["thread_kind"] == "flag" and t["state"] == "open")

        # §10.9: surface the supersession relationship in both
        # directions. `superseded_by` carries the resolution PR that
        # closed this one (set by the cache on the resolution merge).
        # `supersedes` is parsed from this PR's body trailer so it
        # surfaces immediately on open — without waiting for the
        # original to close.
        superseded_by = pr_row["superseded_by_pr_number"]
        from .cache import _parse_supersedes
        supersedes = _parse_supersedes(pr_row["description"] or "")
        if supersedes is None:
            row = db.conn().execute(
                """
                SELECT original_pr_number FROM pr_resolution_branches
                 WHERE rfc_slug = ? AND resolution_branch = ?
                """,
                (slug, head_branch),
            ).fetchone()
            if row:
                supersedes = row["original_pr_number"]

        capabilities = _pr_capabilities(rfc, pr_row, viewer)
        return {
            "slug": slug,
            "rfc_title": rfc["title"],
            "rfc_id": rfc["rfc_id"],
            "pr_number": pr_number,
            "title": pr_row["title"],
            "description": pr_row["description"],
            "description_segments": link_index.segment(pr_row["description"]),
            "proposed_use_case": _pr_use_case(pr_number),
            "state": pr_row["state"],
            "opened_by": pr_row["opened_by"],
            "opened_at": pr_row["opened_at"],
            "merged_at": pr_row["merged_at"],
            "closed_at": pr_row["closed_at"],
            "merge_commit_sha": pr_row["merge_commit_sha"],
            "head_branch": head_branch,
            "head_sha": pr_row["head_sha"],
            "base_branch": pr_row["base_branch"],
            "superseded_by_pr_number": superseded_by,
            "supersedes_pr_number": supersedes,
            "main_body": main_body or "",
            "branch_body": branch_body or "",
            "threads": threads,
            "messages_by_thread": messages_by_thread,
            "seen": seen,
            "mergeable": mergeable,
            "conflict_files": conflict_files,
            "counts": {
                "open_review_threads": open_review,
                "open_chat_threads": open_chat,
                "open_flags": open_flags,
            },
            "capabilities": capabilities,
        }

    # -------------------------------------------------------------------
    # §10.3: advance the per-user seen cursor.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
    async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        _require_active_rfc(slug, viewer)
        _require_pr(slug, pr_number)
        # Take the max of stored and incoming for both cursors so a
        # stale tab firing a seen-cursor advance after a fresher tab
        # cannot roll the cursor back.
        existing = db.conn().execute(
            """
            SELECT last_seen_commit_sha, last_seen_message_id
              FROM pr_seen
             WHERE user_id = ? AND rfc_slug = ? AND pr_number = ?
            """,
            (viewer.user_id, slug, pr_number),
        ).fetchone()
        new_sha = body.last_seen_commit_sha or (existing["last_seen_commit_sha"] if existing else None)
        existing_msg = existing["last_seen_message_id"] if existing else None
        new_msg = body.last_seen_message_id
        if existing_msg is not None and new_msg is not None:
            new_msg = max(existing_msg, new_msg)
        elif new_msg is None:
            new_msg = existing_msg
        db.conn().execute(
            """
            INSERT INTO pr_seen
                (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
            VALUES (?, ?, ?, ?, ?, datetime('now'))
            ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
                last_seen_commit_sha = excluded.last_seen_commit_sha,
                last_seen_message_id = excluded.last_seen_message_id,
                seen_at = excluded.seen_at
            """,
            (viewer.user_id, slug, pr_number, new_sha, new_msg),
        )
        # §15.7 reconciler: a scope cursor advance marks unread
        # notifications scoped to this PR read.
        from . import notify
        reconciled = notify.reconcile_seen_advance(
            user_id=viewer.user_id, rfc_slug=slug, pr_number=pr_number,
        )
        return {"ok": True, "reconciled": reconciled}

    # -------------------------------------------------------------------
    # §10.4: post a review-kind thread anchored to a diff range.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
    async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        head_branch = pr_row["head_branch"]
        cur = db.conn().execute(
            """
            INSERT INTO threads (rfc_slug, branch_name, anchor_kind, anchor_payload,
                                 thread_kind, label, created_by)
            VALUES (?, ?, 'range', ?, 'review', NULL, ?)
            """,
            (slug, head_branch, json.dumps(body.anchor_payload), viewer.user_id),
        )
        thread_id = cur.lastrowid
        message_id = chat_layer.append_user_message(
            thread_id=thread_id,
            author_user_id=viewer.user_id,
            text=body.text,
            quote=body.quote,
        )
        return {"thread_id": thread_id, "message_id": message_id}

    # -------------------------------------------------------------------
    # §10.5: merge.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
    async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        if not _can_merge(rfc, viewer):
            raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
        if pr_row["state"] != "open":
            raise HTTPException(409, f"PR is {pr_row['state']}, not open")
        owner, repo = _owner_repo(rfc)
        try:
            await bot.merge_branch_pr(
                viewer.as_actor(),
                owner=owner,
                repo=repo,
                pr_number=pr_number,
                head_branch=pr_row["head_branch"],
                slug=slug,
            )
        except GiteaError as e:
            # 409 from Gitea typically means a conflict — surface as
            # the §10.9 conflict-replay signal rather than a generic 502.
            if e.status == 409:
                raise HTTPException(409, "Merge conflict with main — use Start resolution branch")
            raise HTTPException(502, f"Gitea: {e.detail}")
        await _refresh_after_pr_write(rfc)
        return {"ok": True, "pr_number": pr_number}

    # -------------------------------------------------------------------
    # §10.8: withdraw.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
    async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        if not _can_withdraw(rfc, pr_row, viewer):
            raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
        if pr_row["state"] != "open":
            raise HTTPException(409, f"PR is {pr_row['state']}, not open")
        owner, repo = _owner_repo(rfc)
        try:
            await bot.withdraw_branch_pr(
                viewer.as_actor(),
                owner=owner,
                repo=repo,
                pr_number=pr_number,
                head_branch=pr_row["head_branch"],
                slug=slug,
                reason="withdraw",
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        await _refresh_after_pr_write(rfc)
        return {"ok": True, "pr_number": pr_number}

    # -------------------------------------------------------------------
    # §10.2: post-open title/description edits.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/description")
    async def edit_pr_description(
        slug: str, pr_number: int, body: PRDescriptionBody, request: Request
    ) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        if not _can_edit_pr_text(rfc, pr_row, viewer):
            raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
        # Per §10.2: title and description stay editable. For now we
        # mutate the cache directly; the underlying Gitea PR could be
        # updated too via the issues endpoint, but the cache is the
        # source of truth for the surface so this is the relevant write.
        db.conn().execute(
            """
            UPDATE cached_prs
               SET title = ?, description = ?
             WHERE rfc_slug = ? AND pr_number = ?
            """,
            (body.title.strip(), body.description.strip(), slug, pr_number),
        )
        return {"ok": True}

    # -------------------------------------------------------------------
    # §10.9: cut a resolution branch and replay.
    # -------------------------------------------------------------------

    @router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
    async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
        viewer = auth.require_contributor(request)
        rfc = _require_active_rfc(slug, viewer)
        pr_row = _require_pr(slug, pr_number)
        if pr_row["state"] != "open":
            raise HTTPException(409, f"PR is {pr_row['state']}, not open")
        owner, repo = _owner_repo(rfc)
        original_branch = pr_row["head_branch"]

        # Confirm there is in fact a conflict — refusing this on a
        # mergeable PR keeps the surface honest.
        try:
            live = await gitea.get_pull(owner, repo, pr_number)
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        if live is None:
            raise HTTPException(404, "PR not found on Gitea")
        if live.get("mergeable") is True:
            raise HTTPException(409, "PR is mergeable; no resolution branch needed")

        resolution_branch = _resolution_branch_name(original_branch)
        try:
            await bot.cut_resolution_branch(
                viewer.as_actor(),
                owner=owner,
                repo=repo,
                original_branch=original_branch,
                resolution_branch=resolution_branch,
                slug=slug,
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")

        # Record the parentage so subsequent open-pr on the resolution
        # branch knows which original PR to supersede.
        db.conn().execute(
            """
            INSERT INTO pr_resolution_branches
                (rfc_slug, original_pr_number, original_branch, resolution_branch)
            VALUES (?, ?, ?, ?)
            """,
            (slug, pr_number, original_branch, resolution_branch),
        )

        # Default the resolution branch's visibility to public — it
        # exists to land in a PR, and §11.3 will flip it anyway.
        db.conn().execute(
            """
            INSERT OR IGNORE INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
            VALUES (?, ?, 1, 'just-me')
            """,
            (slug, resolution_branch),
        )

        # Replay the original branch's accepted AI changes onto the
        # resolution branch, one commit at a time. Per §10.9: the AI
        # participant handles unambiguous conflicts; the rest surface
        # to the contributor. For Slice 3 we apply changes whose
        # `original` text still locates in the resolution branch's
        # current RFC.md (the "unambiguous" case) and surface the
        # rest as stale-pending changes on the resolution branch's
        # chat, ready for the contributor to re-anchor.
        unambiguous, ambiguous = await _replay_changes(
            gitea=gitea,
            bot=bot,
            actor=viewer.as_actor(),
            owner=owner,
            repo=repo,
            slug=slug,
            file_path=_file_path_for(rfc),
            is_super_draft=_is_meta_resident(rfc),
            original_branch=original_branch,
            resolution_branch=resolution_branch,
        )

        # Seed the resolution branch's chat with a system-author
        # message linking back to the original branch's chat per §10.9.
        original_thread = db.conn().execute(
            """
            SELECT id FROM threads
             WHERE rfc_slug = ? AND branch_name = ?
               AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
             ORDER BY id LIMIT 1
            """,
            (slug, original_branch),
        ).fetchone()
        # Materialize the resolution branch's whole-doc chat thread.
        cur = db.conn().execute(
            """
            INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
            VALUES (?, ?, 'whole-doc', 'chat', NULL, ?)
            """,
            (slug, resolution_branch, viewer.user_id),
        )
        new_thread_id = cur.lastrowid
        link = (
            f"Forked from this conversation → /rfc/{slug}?branch={original_branch} "
            f"(thread id {original_thread['id'] if original_thread else 'n/a'}). "
            f"Replayed {len(unambiguous)} change(s) cleanly; {len(ambiguous)} require manual re-anchoring."
        )
        chat_layer.append_system_message(thread_id=new_thread_id, text=link)

        # Surface ambiguous changes as fresh pending stale rows on the
        # resolution branch so the change panel offers the re-anchoring
        # affordance immediately.
        for ch in ambiguous:
            db.conn().execute(
                """
                INSERT INTO changes
                    (rfc_slug, branch_name, thread_id, kind, state,
                     original, proposed, reason, stale_since)
                VALUES (?, ?, ?, 'ai', 'pending', ?, ?, ?, datetime('now'))
                """,
                (slug, resolution_branch, new_thread_id, ch["original"], ch["proposed"], ch["reason"]),
            )

        await _refresh_after_pr_write(rfc)
        return {
            "ok": True,
            "resolution_branch": resolution_branch,
            "replayed_clean": len(unambiguous),
            "replayed_ambiguous": len(ambiguous),
        }

    # ------------------------------------------------------------------
    # Helpers (closures over config/gitea/etc.)
    # ------------------------------------------------------------------

    def _require_rfc(slug: str, viewer):
        row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
        if row is None:
            raise HTTPException(404, "RFC not found")
        # §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
        # public" yields to a gated project: non-members get 404.
        auth.require_project_readable(viewer, row["project_id"])
        return row

    def _require_active_rfc(slug: str, viewer):
        """Used by the §10 PR-flow read and write paths. Per §17's routing-
        collapse rule, a super-draft RFC also routes here — its body-edit
        PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
        surface is identical. Under the meta-only topology (§1) an active
        RFC is meta-resident too (repo is null) — that is normal, not an
        error, so there is no per-RFC-repo precondition."""
        row = _require_rfc(slug, viewer)
        if row["state"] not in ("active", "super-draft"):
            raise HTTPException(409, f"RFC is {row['state']}")
        return row

    def _is_meta_resident(rfc) -> bool:
        """Meta-only topology (§1): an entry lives in the meta repo's
        `rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
        a legacy per-RFC `repo:` — which nothing does after the RFC-0001
        fold-back (§13.6). Drives the body/path/repo dispatch below."""
        return not rfc["repo"]

    def _owner_repo(rfc) -> tuple[str, str]:
        # §22/G-15: a meta-resident entry's repo is its COLLECTION's project
        # content_repo, not the deployment default (entry_location falls back to
        # the default for a legacy/unknown collection).
        if _is_meta_resident(rfc):
            org, repo, _ = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
            return org, repo
        owner, repo = rfc["repo"].split("/", 1)
        return owner, repo

    def _file_path_for(rfc) -> str:
        # §22/G-15: the collection's `<subfolder>/rfcs/<slug>.md`.
        if _is_meta_resident(rfc):
            _, _, path = projects_mod.entry_location(config, rfc["collection_id"], rfc["slug"])
            return path
        return RFC_FILE_PATH

    def _extract_body(rfc, file_contents: str) -> str:
        """For meta-resident entries the file on disk is the full
        frontmatter+body envelope; the editable body is entry.body."""
        if not _is_meta_resident(rfc):
            return file_contents
        try:
            entry = entry_mod.parse(file_contents)
        except Exception:
            return file_contents
        return entry.body

    def _require_pr(slug: str, pr_number: int):
        # Dispatch by RFC state: super-draft body-edit PRs live on the
        # meta repo as pr_kind='meta_body_edit'; active RFC PRs live on
        # the per-RFC repo as 'rfc_branch'. The API surface and the §10
        # treatment are identical.
        # Slice 5: §13.1 claim PRs (pr_kind='meta_claim') are also
        # exposed through this surface — the merge path is the only
        # affordance an admin needs, and the §10 review machinery
        # gracefully degrades for frontmatter-only PRs.
        # Slice 8: §9.5 metadata-pane PRs (`meta_metadata`) land here
        # too per the §19.2 "in-app merge for metadata PRs" candidate.
        # The diff-rendered review surface degrades gracefully — there
        # is no body diff worth reviewing — but the merge gesture lands
        # in-app rather than forcing the Gitea round-trip.
        row = db.conn().execute(
            """
            SELECT * FROM cached_prs
             WHERE rfc_slug = ? AND pr_number = ?
               AND pr_kind IN ('rfc_branch', 'meta_body_edit', 'meta_claim', 'meta_metadata')
            """,
            (slug, pr_number),
        ).fetchone()
        if not row:
            raise HTTPException(404, "PR not found")
        return row

    def _branch_has_commits_ahead(slug: str, branch: str) -> bool:
        """Cheap heuristic: the cache records main + branch head shas,
        which mismatch when the branch has any commit not on main. The
        meta-repo branch refresh (cache.refresh_meta_branches) synthesizes
        a per-slug 'main' row for super-drafts so this works uniformly."""
        row = db.conn().execute(
            """
            SELECT b.head_sha AS branch_sha,
                   (SELECT head_sha FROM cached_branches
                     WHERE rfc_slug = ? AND branch_name = 'main') AS main_sha
              FROM cached_branches b
             WHERE b.rfc_slug = ? AND b.branch_name = ?
            """,
            (slug, slug, branch),
        ).fetchone()
        if not row or not row["branch_sha"]:
            return False
        return row["branch_sha"] != row["main_sha"]

    def _resolution_origin(slug: str, branch: str) -> int | None:
        row = db.conn().execute(
            """
            SELECT original_pr_number FROM pr_resolution_branches
             WHERE rfc_slug = ? AND resolution_branch = ?
            """,
            (slug, branch),
        ).fetchone()
        return row["original_pr_number"] if row else None

    async def _refresh_after_pr_write(rfc) -> None:
        if _is_meta_resident(rfc):
            await cache.refresh_meta_repo(config, gitea)
            await cache.refresh_meta_branches(config, gitea)
            await cache.refresh_meta_pulls(config, gitea)
        else:
            await cache.refresh_rfc_repo(config, gitea, rfc["slug"])

    return router


# ---------------------------------------------------------------------------
# Capability helpers (module-level, since they don't need closure)
# ---------------------------------------------------------------------------


def _can_merge(rfc, viewer) -> bool:
    """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
    if viewer is None:
        return False
    if auth.is_collection_superuser(viewer, rfc["collection_id"]):
        return True
    owners = json.loads(rfc["owners_json"] or "[]")
    arbiters = json.loads(rfc["arbiters_json"] or "[]")
    return viewer.gitea_login in owners or viewer.gitea_login in arbiters


def _can_withdraw(rfc, pr_row, viewer) -> bool:
    """§10.8: the contributor OR any arbiter / RFC owner / app admin/owner."""
    if viewer is None:
        return False
    if _can_merge(rfc, viewer):
        return True
    return pr_row["opened_by"] == viewer.gitea_login


def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
    """Per §10.2 last paragraph: title/description editable by the
    contributor or any RFC arbiter (which collapses to the same set as
    withdraw)."""
    return _can_withdraw(rfc, pr_row, viewer)


def _pr_use_case(pr_number: int) -> str | None:
    """Roadmap #26: the optional propose-PR use case from the canonical
    side table, or None when the change was opened without one ("left
    blank")."""
    row = db.conn().execute(
        "SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
        (pr_number,),
    ).fetchone()
    return row["use_case"] if row else None


def _pr_capabilities(rfc, pr_row, viewer) -> dict:
    return {
        "can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
        "can_withdraw": _can_withdraw(rfc, pr_row, viewer) and pr_row["state"] == "open",
        "can_edit_text": _can_edit_pr_text(rfc, pr_row, viewer) and pr_row["state"] == "open",
        "can_post_review": viewer is not None and pr_row["state"] == "open",
        "can_resolve_conflict": viewer is not None and pr_row["state"] == "open",
        "is_anonymous": viewer is None,
    }


# ---------------------------------------------------------------------------
# AI-drafted title and description (§10.2)
# ---------------------------------------------------------------------------


def _branch_chat_excerpt(slug: str, branch: str, limit: int = 40) -> list[dict]:
    rows = db.conn().execute(
        """
        SELECT m.role, m.text
          FROM thread_messages m
          JOIN threads t ON t.id = m.thread_id
         WHERE t.rfc_slug = ? AND t.branch_name = ?
           AND t.thread_kind IN ('chat', 'review')
           AND m.role IN ('user', 'assistant')
         ORDER BY m.id DESC
         LIMIT ?
        """,
        (slug, branch, limit),
    ).fetchall()
    items = [{"role": r["role"], "text": r["text"]} for r in reversed(rows)]
    return items


def _draft_with_provider(
    *,
    slug: str,
    providers: dict[str, BaseProvider],
    default_model: str,
    rfc_title: str,
    main_body: str,
    branch_body: str,
    chat_messages: list[dict],
) -> tuple[str, str]:
    """Per §10.2: AI-drafted title (spec voice) and description (2–4
    sentences pulling from chat).

    When no provider is configured — or per §6.6 the RFC's resolved
    list is empty (operator universe empty, frontmatter opt-out, or
    intersection empty) — we fall back to a deterministic stub. The
    surface still works; the contributor edits the text. Per §6.7,
    when a consenting funder is in effect, the provider instance is
    built from funder credentials; if the funder cannot serve the
    default model, we also fall back to the stub.
    """
    if not providers or not default_model:
        return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
    provider = funder.provider_for_rfc(slug, default_model, providers)
    if provider is None:
        return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
    system = (
        "You are summarizing a contributor's proposed change to an RFC for an arbiter audience. "
        "Output exactly two sections in this order: 'TITLE: <one line, structural, spec-voice>' "
        "then 'DESCRIPTION: <two to four sentences pulling what was argued and what shifted>'. "
        "No prelude, no closing."
    )
    chat_dump = "\n".join(f"- {m['role']}: {m['text'][:600]}" for m in chat_messages[-20:])
    user_msg = (
        f"RFC: {rfc_title}\n\n"
        f"--- main RFC.md ---\n{main_body[:6000]}\n\n"
        f"--- branch RFC.md ---\n{branch_body[:6000]}\n\n"
        f"--- recent branch chat ---\n{chat_dump or '(empty)'}\n"
    )
    try:
        text = provider.send(system, [{"role": "user", "content": user_msg}])
    except Exception as exc:
        log.warning("pr-draft provider failed: %s", exc)
        return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
    title, description = _split_title_description(text)
    if not title:
        title, _desc = _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
    return title, description


def _split_title_description(text: str) -> tuple[str, str]:
    """Parse the `TITLE: ... DESCRIPTION: ...` shape the prompt asks for.

    Tolerant of variations the model might emit — leading/trailing
    whitespace, the model adding markdown emphasis around the labels —
    and falls back to the whole text as description if the title
    label isn't present."""
    title = ""
    description = text.strip()
    lower = text.lower()
    title_idx = lower.find("title:")
    desc_idx = lower.find("description:")
    if title_idx >= 0:
        end = desc_idx if desc_idx > title_idx else len(text)
        title_line = text[title_idx + len("title:") : end].strip()
        title = title_line.split("\n", 1)[0].strip().strip("*_`")
    if desc_idx >= 0:
        description = text[desc_idx + len("description:") :].strip().strip("*_`")
    return title[:240], description[:8000]


def _stub_draft(*, rfc_title: str, main_body: str, branch_body: str) -> tuple[str, str]:
    delta = abs(len(branch_body) - len(main_body))
    title = f"Edits to {rfc_title}"
    description = (
        f"Proposed revisions to {rfc_title}. The branch's RFC.md differs from main "
        f"by {delta} characters. Arbiters: please review the diff inline and the "
        f"branch chat for the argument."
    )
    return title, description


# ---------------------------------------------------------------------------
# §10.9 replay
# ---------------------------------------------------------------------------


async def _replay_changes(
    *,
    gitea: Gitea,
    bot: Bot,
    actor,
    owner: str,
    repo: str,
    slug: str,
    file_path: str,
    is_super_draft: bool,
    original_branch: str,
    resolution_branch: str,
) -> tuple[list[dict], list[dict]]:
    """Walk the original branch's accepted AI-kind changes in creation
    order and try to apply each to the resolution branch.

    Returns (unambiguous_changes_applied, ambiguous_changes_skipped).
    For super-draft body edits the file is rfcs/<slug>.md and the body
    lives inside the frontmatter envelope — extract the body for the
    `original`-text match and re-wrap before committing.
    """
    rows = db.conn().execute(
        """
        SELECT id, kind, original, proposed, reason
          FROM changes
         WHERE rfc_slug = ? AND branch_name = ? AND state = 'accepted' AND kind = 'ai'
         ORDER BY id
        """,
        (slug, original_branch),
    ).fetchall()
    unambiguous: list[dict] = []
    ambiguous: list[dict] = []
    for r in rows:
        fetched = await gitea.read_file(owner, repo, file_path, ref=resolution_branch)
        if fetched is None:
            ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
            continue
        current_content, current_sha = fetched
        current_body = _extract_body_for_replay(is_super_draft, current_content)
        original_text = r["original"] or ""
        if not original_text or current_body.count(original_text) != 1:
            ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
            continue
        new_body = current_body.replace(original_text, r["proposed"], 1)
        new_content = _wrap_body_for_replay(is_super_draft, current_content, new_body)
        try:
            await bot.commit_replay_change(
                actor,
                owner=owner,
                repo=repo,
                branch=resolution_branch,
                file_path=file_path,
                new_content=new_content,
                prior_sha=current_sha,
                original_change_id=r["id"],
                original=r["original"] or "",
                proposed=r["proposed"] or "",
                reason=r["reason"] or "",
                slug=slug,
            )
            unambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
        except GiteaError as e:
            log.warning("replay change %d failed: %s", r["id"], e)
            ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
    return unambiguous, ambiguous


def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
    # §22.4a SLICE-4: a meta-resident entry may be legacy (frontmatter+body) or
    # migrated (body-only). strip_frontmatter handles both without raising.
    if not is_super_draft:
        return content
    return metadata_mod.strip_frontmatter(content)


def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
    # §22.4a SLICE-4: identity for body-only (migrated) files — never re-grow
    # frontmatter; preserve a legacy file's frontmatter until its next metadata
    # edit migrates it.
    nb = new_body if new_body.endswith("\n") else new_body + "\n"
    if not is_super_draft:
        return nb
    if entry_mod.FRONTMATTER_RE.match(prior_content):
        entry = entry_mod.parse(prior_content)
        entry.body = nb
        return entry_mod.serialize(entry)
    return nb


def _resolution_branch_name(original_branch: str) -> str:
    """Per §10.9: a fresh branch name derived from the original.

    Slice 3 picks `<original>-resolved-<hex>`. Exact format is an
    implementation detail per §8.14's voice — kept short, ref-safe,
    and traceable to the parent."""
    import secrets

    suffix = secrets.token_hex(3)
    base = original_branch
    if len(base) > 80:
        base = base[:80]
    return f"{base}-resolved-{suffix}"


# ---------------------------------------------------------------------------
# Serialization helpers — mirror api_branches.py shape
# ---------------------------------------------------------------------------


def _serialize_thread(row) -> dict[str, Any]:
    payload = row["anchor_payload"]
    try:
        anchor = json.loads(payload) if payload else None
    except Exception:
        anchor = None
    return {
        "id": row["id"],
        "anchor_kind": row["anchor_kind"],
        "anchor_payload": anchor,
        "thread_kind": row["thread_kind"],
        "label": row["label"],
        "state": row["state"],
        "created_by": row["created_by"],
        "created_at": row["created_at"],
        "resolved_at": row["resolved_at"],
        "resolved_by": row["resolved_by"],
    }


def _serialize_message(row) -> dict[str, Any]:
    return {
        "id": row["id"],
        "thread_id": row["thread_id"],
        "role": row["role"],
        "author_user_id": row["author_user_id"],
        "author_login": row["author_login"],
        "author_display": row["author_display"],
        "model_id": row["model_id"],
        "text": row["text"],
        "quote": row["quote"],
        "created_at": row["created_at"],
    }
