"""End-to-end integration test for the Slice 1 vertical.

Stands up the FastAPI app against a mocked Gitea transport that
simulates the meta repo and the propose-to-merge lifecycle. The test
walks the same path a user would: sign in (a forged session cookie
substitutes for the OAuth round-trip, since OAuth itself is not in
scope to mock end-to-end), open a propose modal
(POST /api/rfcs/propose), exercise the bot wrapper through to the
Gitea HTTP layer, merge the PR as an owner, refresh the cache, and
verify the super-draft surfaces in GET /api/rfcs and
GET /api/rfcs/<slug>.

The mocked Gitea is intentionally narrow — it only honors the
endpoints the slice actually exercises. Adding routes to it as later
slices land is the right shape: the test surface tracks the production
surface.
"""
from __future__ import annotations

import base64
import json
import os
import re
import tempfile
from pathlib import Path

import httpx
import pytest


# ---------------------------------------------------------------------------
# Fake Gitea
# ---------------------------------------------------------------------------


class FakeGitea:
    """A narrow in-memory simulation of the Gitea API the slices exercise.

    Slice 2 extends the seam to cover per-RFC repos: PUT contents
    (update file), POST orgs/{org}/repos (create repo), and branch
    listing with commit timestamps. The simulator is intentionally
    minimal — only the routes the production paths actually call.
    """

    def __init__(self):
        # files: (owner, repo, branch, path) -> {"content": str, "sha": str}
        self.files: dict[tuple[str, str, str, str], dict] = {}
        # branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str,
        #   "base_main_files": {path -> str}}}
        self.branches: dict[tuple[str, str], dict[str, dict]] = {}
        # pulls: (owner, repo) -> list[pull-dict]
        self.pulls: dict[tuple[str, str], list[dict]] = {}
        # repos: set of (owner, repo)
        self.repos: set[tuple[str, str]] = set()
        self._pr_counter = 0
        self._commit_counter = 0
        # count of batch ChangeFiles commits (one per /contents POST with a
        # files[] array) — lets tests assert "N files, one commit" (§22.4a D7).
        self.change_files_calls = 0
        self._seed_repo("wiggleverse", "meta")
        # §22 M3: the deployment's project registry. Startup refresh_registry
        # reads projects.yaml here; the single 'default' project's content_repo
        # points back at the seeded meta repo so the corpus mirror is unchanged.
        self._seed_repo("wiggleverse", "registry")
        self.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
            "content": (
                "deployment:\n"
                "  name: Test Deployment\n"
                "  tagline: A test deployment\n"
                "projects:\n"
                "  - id: default\n"
                "    name: Test Deployment\n"
                "    type: document\n"
                "    content_repo: meta\n"
                "    visibility: public\n"
            ),
            "sha": "regsha0001",
        }

    def _seed_repo(self, owner, repo):
        self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
        self.pulls[(owner, repo)] = []
        self.repos.add((owner, repo))

    def seed_rfc_repo(self, owner, repo, *, rfc_md_body):
        """Convenience: seed a per-RFC repo with an RFC.md on main."""
        self._seed_repo(owner, repo)
        sha = self._next_sha()
        self.files[(owner, repo, "main", "RFC.md")] = {"content": rfc_md_body, "sha": sha}
        self.branches[(owner, repo)]["main"] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}

    def _next_sha(self):
        self._commit_counter += 1
        return f"sha{self._commit_counter:04d}"

    def _dir_listing(self, owner, repo, ref, dirpath):
        """Children directly under `dirpath` on (owner, repo, ref): files as
        `type: file` and immediate subdirectories as `type: dir` (the shape real
        Gitea returns for a contents listing)."""
        prefix = (dirpath.rstrip("/") + "/") if dirpath else ""
        files: dict[str, dict] = {}
        dirs: set[str] = set()
        for (o, r, br, p), data in self.files.items():
            if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix):
                continue
            rest = p[len(prefix):]
            if "/" in rest:
                dirs.add(rest.split("/", 1)[0])
            elif rest:
                files[p] = data
        children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)]
        children += [
            {"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]}
            for p, d in sorted(files.items())
        ]
        return children

    def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
        """Return the PR with mergeability fields filled in.

        Gitea's PR responses carry `mergeable` and `merge_commit_sha`
        plus the head sha; for the per-RFC repo paths in §10 we mirror
        that shape.
        """
        out = dict(pr)
        head_branch = pr["head"]["ref"]
        head_sha = (self.branches.get((owner, repo)) or {}).get(head_branch, {}).get("sha")
        out["head"] = dict(pr["head"])
        if head_sha:
            out["head"]["sha"] = head_sha
        out["mergeable"] = self._is_mergeable(owner, repo, pr) if pr["state"] == "open" else False
        return out

    def _is_mergeable(self, owner: str, repo: str, pr: dict) -> bool:
        """A PR is mergeable when the file content under main matches the
        branch's snapshot of main at cut-time on every path the branch
        either inherited or touched. This collapses to "no path on the
        branch has diverged from main since cut" — sufficient for the
        single-file RFC.md surface and the §10.9 conflict-replay test
        path.
        """
        head_branch = pr["head"]["ref"]
        branch_data = self.branches.get((owner, repo), {}).get(head_branch, {})
        base_snapshot: dict[str, str] = branch_data.get("base_main_files") or {}
        # Touch every path the branch tracks plus every path on main, so a
        # file deleted on main also surfaces.
        paths = set(base_snapshot.keys())
        for (o, r, br, p) in self.files.keys():
            if (o, r, br) == (owner, repo, head_branch):
                paths.add(p)
            if (o, r, br) == (owner, repo, "main"):
                paths.add(p)
        for p in paths:
            main_content = (self.files.get((owner, repo, "main", p)) or {}).get("content")
            base_content = base_snapshot.get(p)
            if main_content != base_content:
                return False
        return True

    def handle(self, request: httpx.Request) -> httpx.Response:
        path = request.url.path.replace("/api/v1", "", 1)
        method = request.method
        body = request.read().decode() if request.content else ""
        payload = json.loads(body) if body else {}

        # GET /repos/{owner}/{repo}
        m_repo = re.fullmatch(r"/repos/([^/]+)/([^/]+)", path)
        if method == "GET" and m_repo:
            owner, repo = m_repo.groups()
            if (owner, repo) in self.repos:
                return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
            return httpx.Response(404, json={"message": "not found"})

        # DELETE /repos/{owner}/{repo} — Slice 5 graduation rollback uses
        # this to undo step 1 (repo create). The FakeGitea drops every
        # file, branch, and PR tied to the repo so a subsequent retry
        # graduation can re-create the repo cleanly.
        if method == "DELETE" and m_repo:
            owner, repo = m_repo.groups()
            self.repos.discard((owner, repo))
            self.branches.pop((owner, repo), None)
            self.pulls.pop((owner, repo), None)
            self.files = {k: v for k, v in self.files.items() if (k[0], k[1]) != (owner, repo)}
            return httpx.Response(204, json={})

        # POST /orgs/{org}/repos
        m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
        if method == "POST" and m:
            org = m.group(1)
            name = payload["name"]
            self._seed_repo(org, name)
            return httpx.Response(201, json={"name": name, "full_name": f"{org}/{name}"})

        # GET /repos/{owner}/{repo}/branches (list)
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
        if method == "GET" and m:
            owner, repo = m.groups()
            items = []
            for name, b in self.branches.get((owner, repo), {}).items():
                items.append({"name": name, "commit": {"id": b["sha"], "timestamp": b.get("ts")}})
            return httpx.Response(200, json=items)

        # GET /repos/{owner}/{repo}/branches/{branch}. Branch name may
        # contain slashes per the §19.2 path-routing candidate Slice 8
        # settles — the FakeGitea matcher mirrors what real Gitea
        # accepts on the wire.
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/(.+)", path)
        if method == "GET" and m:
            owner, repo, branch = m.groups()
            b = self.branches.get((owner, repo), {}).get(branch)
            if not b:
                return httpx.Response(404, json={"message": "not found"})
            return httpx.Response(200, json={"name": branch, "commit": {"id": b["sha"]}})

        # DELETE /repos/{owner}/{repo}/branches/{branch} — Slice 8 §12
        # hygiene actuator and the graduation-rollback branch cleanup
        # both reach this endpoint via `bot.delete_branch`. Branch path
        # may contain slashes (the §19.2 path-routing candidate) so the
        # regex catches the rest-of-path.
        m_delbr = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/(.+)", path)
        if method == "DELETE" and m_delbr:
            owner, repo, branch = m_delbr.groups()
            br_map = self.branches.get((owner, repo), {})
            if branch not in br_map:
                return httpx.Response(404, json={"message": "not found"})
            br_map.pop(branch, None)
            # Drop the branch's files too so a subsequent read 404s.
            self.files = {
                k: v for k, v in self.files.items()
                if not ((k[0], k[1], k[2]) == (owner, repo, branch))
            }
            return httpx.Response(204, json={})

        # POST /repos/{owner}/{repo}/branches
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
        if method == "POST" and m:
            owner, repo = m.groups()
            new = payload["new_branch_name"]
            old = payload["old_branch_name"]
            old_sha = self.branches[(owner, repo)][old]["sha"]
            # Snapshot the parent branch's files at cut time so we can
            # surface §10.5 merge conflicts when main diverges later.
            snapshot: dict[str, str] = {}
            for (o, r, br, p), data in list(self.files.items()):
                if (o, r, br) == (owner, repo, old):
                    self.files[(owner, repo, new, p)] = dict(data)
                    snapshot[p] = data["content"]
            self.branches[(owner, repo)][new] = {
                "sha": old_sha,
                "ts": "2026-05-23T00:00:00Z",
                "base_main_files": snapshot,
            }
            return httpx.Response(201, json={"name": new})

        # GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2:
        # the registry mirror walks the content-repo root for collection
        # subfolders, so the simulator models a root directory listing that
        # surfaces both file and `dir` children.
        m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
        if method == "GET" and m_root:
            owner, repo = m_root.groups()
            ref = request.url.params.get("ref", "main")
            return httpx.Response(200, json=self._dir_listing(owner, repo, ref, ""))

        # GET /repos/{owner}/{repo}/contents/{path}?ref=...
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
        if method == "GET" and m:
            owner, repo, fpath = m.groups()
            ref = request.url.params.get("ref", "main")
            key = (owner, repo, ref, fpath)
            if key in self.files:
                f = self.files[key]
                return httpx.Response(200, json={
                    "name": fpath.rsplit("/", 1)[-1],
                    "path": fpath,
                    "type": "file",
                    "sha": f["sha"],
                    "content": base64.b64encode(f["content"].encode()).decode(),
                })
            # Directory listing — both file and subdir children.
            children = self._dir_listing(owner, repo, ref, fpath)
            if children:
                return httpx.Response(200, json=children)
            return httpx.Response(404, json={"message": "not found"})

        # POST /repos/{owner}/{repo}/contents — ChangeFiles (batch, one commit).
        # §22.4a SLICE-1: the frontmatter→sidecar migration writes N files in a
        # single commit. Matches the no-path /contents route (the per-path POST
        # below needs a /contents/<path> suffix).
        m_batch = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
        if method == "POST" and m_batch:
            owner, repo = m_batch.groups()
            branch = payload["branch"]
            self.change_files_calls += 1
            sha = self._next_sha()
            for f in payload["files"]:
                op = f["operation"]
                fpath = f["path"]
                if op == "delete":
                    self.files.pop((owner, repo, branch, fpath), None)
                else:
                    content = base64.b64decode(f["content"]).decode()
                    self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
            br = self.branches[(owner, repo)].setdefault(branch, {})
            br["sha"] = sha
            br["ts"] = "2026-05-23T00:00:00Z"
            return httpx.Response(201, json={"commit": {"sha": sha}})

        # POST /repos/{owner}/{repo}/contents/{path}
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
        if method == "POST" and m:
            owner, repo, fpath = m.groups()
            branch = payload["branch"]
            content = base64.b64decode(payload["content"]).decode()
            sha = self._next_sha()
            self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
            br = self.branches[(owner, repo)].setdefault(branch, {})
            br["sha"] = sha
            br["ts"] = "2026-05-23T00:00:00Z"
            return httpx.Response(201, json={"commit": {"sha": sha}})

        # PUT /repos/{owner}/{repo}/contents/{path} — update_file
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
        if method == "PUT" and m:
            owner, repo, fpath = m.groups()
            branch = payload["branch"]
            content = base64.b64decode(payload["content"]).decode()
            sha = self._next_sha()
            self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
            br = self.branches[(owner, repo)].setdefault(branch, {})
            br["sha"] = sha
            br["ts"] = "2026-05-23T00:00:00Z"
            return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})

        # GET /repos/{owner}/{repo}/pulls?state=...
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path)
        if method == "GET" and m:
            owner, repo = m.groups()
            state = request.url.params.get("state", "open")
            items = self.pulls.get((owner, repo), [])
            filtered = [self._enrich_pr(owner, repo, p) for p in items if (state == "all") or (p["state"] == state)]
            return httpx.Response(200, json=filtered)

        # POST /repos/{owner}/{repo}/pulls
        if method == "POST" and m:
            owner, repo = m.groups()
            self._pr_counter += 1
            head_branch = payload["head"]
            pr = {
                "number": self._pr_counter,
                "title": payload["title"],
                "body": payload["body"],
                "head": {"ref": head_branch, "sha": self.branches[(owner, repo)][head_branch]["sha"]},
                "base": {"ref": payload["base"]},
                "state": "open",
                "merged": False,
                "merged_at": None,
                "closed_at": None,
                "created_at": "2026-05-23T00:00:00Z",
                "user": {"login": "rfc-bot"},
            }
            self.pulls[(owner, repo)].append(pr)
            return httpx.Response(201, json=self._enrich_pr(owner, repo, pr))

        # GET /repos/{owner}/{repo}/pulls/{number}
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)", path)
        if method == "GET" and m:
            owner, repo, num = m.groups()
            for pr in self.pulls.get((owner, repo), []):
                if pr["number"] == int(num):
                    return httpx.Response(200, json=self._enrich_pr(owner, repo, pr))
            return httpx.Response(404, json={"message": "not found"})

        # POST /repos/{owner}/{repo}/pulls/{number}/merge
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
        if method == "POST" and m:
            owner, repo, num = m.groups()
            for pr in self.pulls[(owner, repo)]:
                if pr["number"] == int(num):
                    if pr["state"] != "open":
                        return httpx.Response(409, json={"message": "PR is not open"})
                    if not self._is_mergeable(owner, repo, pr):
                        return httpx.Response(409, json={"message": "merge conflict with main"})
                    head_branch = pr["head"]["ref"]
                    for (o, r, br, p), data in list(self.files.items()):
                        if (o, r, br) == (owner, repo, head_branch):
                            self.files[(owner, repo, "main", p)] = dict(data)
                    pr["state"] = "closed"
                    pr["merged"] = True
                    pr["merged_at"] = "2026-05-23T01:00:00Z"
                    pr["closed_at"] = "2026-05-23T01:00:00Z"
                    # Per §10.5: a no-fast-forward merge advances main
                    # via a new merge commit SHA, not by reusing the
                    # branch's tip. We mint a fresh sha to model that.
                    merge_sha = self._next_sha()
                    pr["merge_commit_sha"] = merge_sha
                    self.branches[(owner, repo)]["main"]["sha"] = merge_sha
                    self.branches[(owner, repo)]["main"]["ts"] = "2026-05-23T01:00:00Z"
                    return httpx.Response(200, json={"merged": True, "merge_commit_sha": merge_sha})
            return httpx.Response(404, json={"message": "not found"})

        # GET /repos/{owner}/{repo}/hooks
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/hooks", path)
        if method == "GET" and m:
            return httpx.Response(200, json=[])

        # PATCH /repos/{owner}/{repo}/issues/{number} — Gitea close path.
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)", path)
        if method == "PATCH" and m:
            owner, repo, num = m.groups()
            for pr in self.pulls.get((owner, repo), []):
                if pr["number"] == int(num) and payload.get("state") == "closed":
                    pr["state"] = "closed"
                    pr["closed_at"] = "2026-05-23T02:00:00Z"
                    return httpx.Response(200, json={"state": "closed"})
            return httpx.Response(200, json={})

        # POST /repos/{owner}/{repo}/issues/{number}/comments
        m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)/comments", path)
        if method == "POST" and m:
            return httpx.Response(201, json={"id": 1, "body": payload.get("body", "")})

        return httpx.Response(404, json={"message": f"unmocked {method} {path}"})


# ---------------------------------------------------------------------------
# Session helpers — forge a SessionMiddleware cookie directly to skip OAuth.
# ---------------------------------------------------------------------------


def _sign_session(session_data: dict, secret: str) -> str:
    from itsdangerous import TimestampSigner
    data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
    signer = TimestampSigner(secret)
    return signer.sign(data).decode("utf-8")


def sign_in_as(client, *, user_id, gitea_login, display_name, role, email=""):
    payload = {
        "user": {
            "user_id": user_id,
            "gitea_id": user_id,
            "gitea_login": gitea_login,
            "display_name": display_name,
            "email": email,
            "avatar_url": "",
            "role": role,
        }
    }
    cookie = _sign_session(payload, os.environ["SECRET_KEY"])
    client.cookies.set("rfc_session", cookie)


def provision_user_row(*, user_id: int, login: str, role: str) -> None:
    from app import db
    db.conn().execute(
        """
        INSERT OR REPLACE INTO users (id, gitea_id, gitea_login, email, display_name, avatar_url, role)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """,
        (user_id, user_id, login, f"{login}@test", login.capitalize(), "", role),
    )


def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
    """v0.16.0 / item #12 test seam: directly insert an accepted-
    invitation collaborator row so a non-owner contributor can pass
    the per-RFC write gate without going through the email round-trip.

    Equivalent in effect to the invitation→accept dance the production
    code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
    "alice owns OHM, bob contributes" shape without rewriting the
    setup. The invitation_id is left NULL — collaborators minted via
    a direct admin gesture (a §19.2 candidate) carry the same shape.
    """
    from app import db
    db.conn().execute(
        """
        INSERT OR REPLACE INTO rfc_collaborators
            (rfc_slug, user_id, role_in_rfc, invitation_id)
        VALUES (?, ?, ?, NULL)
        """,
        (rfc_slug, user_id, role_in_rfc),
    )


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def tmp_env(monkeypatch):
    tmpdir = tempfile.mkdtemp(prefix="rfc-app-test-")
    db_path = Path(tmpdir) / "test.db"
    env = {
        "GITEA_URL": "http://gitea.test",
        "GITEA_BOT_USER": "rfc-bot",
        "GITEA_BOT_TOKEN": "bot-token",
        "GITEA_ORG": "wiggleverse",
        "REGISTRY_REPO": "registry",
        "OAUTH_CLIENT_ID": "cid",
        "OAUTH_CLIENT_SECRET": "csec",
        "APP_URL": "http://localhost:8000",
        "SECRET_KEY": "test-secret-key-for-cookies",
        "DATABASE_PATH": str(db_path),
        "OWNER_GITEA_LOGIN": "ben",
        # v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
        # per the email + webhook hygiene proposal. Tests bind a fake
        # value so the framework boots; tests that want to exercise
        # the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
        "GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
        "ENABLED_MODELS": "claude",
        # v0.27.0 (audit 0026 M4): the session cookie now defaults to
        # Secure. The TestClient talks plain http://testserver, so a
        # Secure cookie is never sent back and every authenticated flow
        # would fail. Tests opt out explicitly, exactly as a dev box on
        # plain http does.
        "SESSION_COOKIE_SECURE": "false",
        # v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
        # when its secret is unset. Tests exercise the legacy behavioral
        # path via the documented dev opt-in, mirroring the
        # RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
        # fail-closed default delenv this key themselves.
        "RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
    }
    for k, v in env.items():
        monkeypatch.setenv(k, v)
    yield env


@pytest.fixture
def app_with_fake_gitea(tmp_env, monkeypatch):
    fake = FakeGitea()
    real_client_cls = httpx.AsyncClient

    def patched_client(*args, **kwargs):
        kwargs["transport"] = httpx.MockTransport(fake.handle)
        return real_client_cls(*args, **kwargs)

    monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched_client)

    # The db module memoizes its connection — reset across tests so each
    # test gets the tmpdir db its env points at, not a previous test's.
    from app import db
    if db._CONN is not None:
        db._CONN.close()
        db._CONN = None

    from app.main import create_app
    app = create_app()
    return app, fake


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


def test_propose_to_super_draft_vertical(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea

    with TestClient(app) as client:
        # The catalog is empty before anything happens.
        r = client.get("/api/rfcs")
        assert r.status_code == 200
        assert r.json()["items"] == []

        # A contributor proposes a new RFC.
        provision_user_row(user_id=2, login="alice", role="contributor")
        provision_user_row(user_id=1, login="ben", role="owner")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
        r = client.post("/api/rfcs/propose", json={
            "title": "Open Human Model",
            "slug": "open-human-model",
            "pitch": "A shared definition of what we mean by *human*.",
            "tags": ["identity", "schema"],
        })
        assert r.status_code == 200, r.text
        pr_number = r.json()["pr_number"]
        assert r.json()["slug"] == "open-human-model"

        # The proposal surfaces on the pending-ideas list.
        r = client.get("/api/proposals")
        items = r.json()["items"]
        assert len(items) == 1
        assert items[0]["slug"] == "open-human-model"
        assert items[0]["pr_number"] == pr_number

        # A contributor cannot merge.
        r = client.post(f"/api/proposals/{pr_number}/merge")
        assert r.status_code == 403

        # Switch to the owner. The pending-idea view exposes the merge affordance.
        sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
        r = client.get(f"/api/proposals/{pr_number}")
        assert r.status_code == 200, r.text
        proposal = r.json()
        assert proposal["entry"]["title"] == "Open Human Model"
        assert proposal["entry"]["state"] == "super-draft"
        # §9.2: the proposer is the implicit first owner at propose time.
        # The owners field is a single-element list containing exactly the
        # session user's gitea_login — no request-supplied owner field
        # exists or is honored.
        assert proposal["entry"]["owners"] == ["alice"]
        assert proposal["affordances"]["merge"] is True

        # Owner merges. The catalog picks up the new super-draft.
        r = client.post(f"/api/proposals/{pr_number}/merge")
        assert r.status_code == 200, r.text
        assert r.json()["slug"] == "open-human-model"

        r = client.get("/api/rfcs")
        items = r.json()["items"]
        assert len(items) == 1
        assert items[0]["slug"] == "open-human-model"
        assert items[0]["state"] == "super-draft"
        assert "identity" in items[0]["tags"]

        # The super-draft view renders the body.
        r = client.get("/api/rfcs/open-human-model")
        assert r.status_code == 200
        view = r.json()
        assert view["state"] == "super-draft"
        assert "shared definition" in view["body"]
        # §9.2: the auto-set proposer-owner survives the meta-repo round-trip
        # — it's in the file's frontmatter on main after merge, not just
        # in the pending-PR view above.
        assert view["owners"] == ["alice"]

        # The pending-ideas list no longer carries the merged proposal.
        r = client.get("/api/proposals")
        assert r.json()["items"] == []

        # The bot's actions are recorded in the audit log per §6.5.
        actions = db.conn().execute(
            "SELECT action_kind, on_behalf_of FROM actions ORDER BY id"
        ).fetchall()
        kinds = [(a["action_kind"], a["on_behalf_of"]) for a in actions]
        assert ("propose_rfc", "alice") in kinds
        assert ("merge_proposal", "ben") in kinds


def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
    """Regression: a merged idea PR whose branch was deleted must not
    linger as a 'pending idea' ghost.

    Found via the ROADMAP #35 operator authoring lane: merging an idea
    PR from the CLI with `--delete-branch` makes Gitea report the PR's
    `head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
    `propose/<slug>`. `refresh_meta_pulls` derives the slug from the
    branch name, so the sentinel parsed to slug=None, the row was skipped,
    and `cached_prs.state` stayed frozen at 'open' — leaving the entry
    showing as BOTH a super-draft (cached_rfcs reconciled off the push)
    AND a pending idea (cached_prs never updated). The fix recovers the
    original branch name from the already-stored cached_prs row.

    The web UX never tripped this because it leaves the branch in place
    (the repo's default_delete_branch_after_merge is false).

    The bug only manifests on an out-of-band merge (the PR merged +
    branch deleted directly in Gitea, with the in-app merge endpoint never
    reconciling the row while the branch still existed) -- which is exactly
    what the #35 CLI lane does. An in-app merge reconciles cached_prs to
    'merged' before the branch is gone, so it never trips this; the test
    therefore drives the Gitea state directly to reproduce the CLI path.
    """
    from fastapi.testclient import TestClient
    from app import db, cache, gitea as gitea_mod
    from app.config import load_config

    app, fake = app_with_fake_gitea

    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
        r = client.post("/api/rfcs/propose", json={
            "title": "Informed Consent",
            "slug": "informed-consent",
            "pitch": "A first-class definition of consent in OHM.",
            "tags": [],
        })
        assert r.status_code == 200, r.text
        pr_number = r.json()["pr_number"]

        # The proposal is cached as an open idea PR.
        items = client.get("/api/proposals").json()["items"]
        assert any(i["pr_number"] == pr_number for i in items)

        # Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
        # its branch deleted directly in Gitea, WITHOUT the in-app merge
        # endpoint ever running. So cached_prs still says state='open' and
        # Gitea now reports the merged PR's head.ref as the sentinel. This
        # is the exact state `rfc-authoring.sh pr-merge --delete-branch`
        # leaves behind.
        for pr in fake.pulls[("wiggleverse", "meta")]:
            if pr["number"] == pr_number:
                # land the file on main (the push side already reconciles
                # cached_rfcs into a super-draft via the webhook/sweep)
                for (o, rp, br, p), data in list(fake.files.items()):
                    if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
                        fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
                pr["state"] = "closed"
                pr["merged"] = True
                pr["merged_at"] = "2026-05-29T12:13:00Z"
                pr["closed_at"] = "2026-05-29T12:13:00Z"
                pr["merge_commit_sha"] = fake._next_sha()
                pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
        fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)

        # The reconcile sweep runs (a later webhook, or the 5-min safety net).
        import asyncio
        cfg = load_config()
        gclient = gitea_mod.Gitea(cfg)
        asyncio.run(cache.refresh_meta_repo(cfg, gclient))
        asyncio.run(cache.refresh_meta_pulls(cfg, gclient))

        # The bug: this used to still list informed-consent (frozen 'open'
        # row, slug unparseable from the sentinel). The fix recovers the
        # stored branch name, so the row reconciles to merged and the ghost
        # is gone.
        assert client.get("/api/proposals").json()["items"] == []

        # And the cached_prs row is correctly merged, not a frozen 'open'.
        row = db.conn().execute(
            "SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
        ).fetchone()
        assert row["state"] == "merged", f"expected merged, got {row['state']}"

        # The super-draft itself is unaffected — still in the catalog.
        items = client.get("/api/rfcs").json()["items"]
        assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)


def test_slug_uniqueness_enforced(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=5, login="alice", role="contributor")
        sign_in_as(client, user_id=5, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={
            "title": "First", "slug": "first", "pitch": "p", "tags": [],
        })
        assert r.status_code == 200, r.text
        r = client.post("/api/rfcs/propose", json={
            "title": "First Again", "slug": "first", "pitch": "p", "tags": [],
        })
        assert r.status_code == 409


def test_invalid_slug_rejected(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=7, login="alice", role="contributor")
        sign_in_as(client, user_id=7, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={
            "title": "Bad slug", "slug": "Bad Slug!", "pitch": "p", "tags": [],
        })
        assert r.status_code == 422


def test_anonymous_cannot_propose(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post("/api/rfcs/propose", json={
            "title": "A", "slug": "a", "pitch": "p", "tags": [],
        })
        assert r.status_code == 401


def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
    """§9.2: the owners field on the new entry is always exactly
    `[session.gitea_login]`. The propose endpoint never accepts an owner
    from the client; a request payload that smuggles one in is ignored
    by the Pydantic body model and the auto-set value lands instead.
    """
    from fastapi.testclient import TestClient
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=11, login="alice", role="contributor")
        sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
        # Extra unknown fields like `owners` are dropped by the
        # ProposeBody model; the session user is the only source of truth.
        r = client.post("/api/rfcs/propose", json={
            "title": "Spoof attempt",
            "slug": "spoof-attempt",
            "pitch": "p",
            "tags": [],
            "owners": ["mallory", "eve"],
            "proposed_by": "mallory@test",
        })
        assert r.status_code == 200, r.text
        pr_number = r.json()["pr_number"]
        r = client.get(f"/api/proposals/{pr_number}")
        assert r.status_code == 200, r.text
        entry = r.json()["entry"]
        assert entry["owners"] == ["alice"]
        # proposed_by also comes from the session, never the body.
        assert entry["proposed_by"] in ("alice@test", "alice")


def test_withdraw_by_proposer_works(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=9, login="alice", role="contributor")
        provision_user_row(user_id=10, login="bob", role="contributor")
        sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post("/api/rfcs/propose", json={
            "title": "X", "slug": "x", "pitch": "p", "tags": [],
        })
        pr_number = r.json()["pr_number"]

        # A different contributor cannot withdraw someone else's proposal.
        sign_in_as(client, user_id=10, gitea_login="bob", display_name="Bob", role="contributor")
        r = client.post(f"/api/proposals/{pr_number}/withdraw")
        assert r.status_code == 403

        # The proposer can.
        sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
        r = client.post(f"/api/proposals/{pr_number}/withdraw")
        assert r.status_code == 200, r.text
        r = client.get("/api/proposals")
        assert r.json()["items"] == []
