"""API surface for Slice 1.

Carries the §17 endpoints exercised by the propose-to-super-draft
vertical, plus the catalog read endpoints (§7) and the super-draft view
read endpoints (§9.4). The rest of §17 lands in the relevant later
slices; the dispatch shape here leaves room for them.

Routing follows the §17 layout literally — `/api/rfcs`,
`/api/proposals/<n>`, etc. — so the next slice can extend the same
modules rather than untangling a layout that drifted.
"""
from __future__ import annotations

import json
from typing import Any

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel, Field

from . import (
    api_admin,
    api_branches,
    api_collections,
    api_contributions,
    api_deployment,
    api_discussion,
    api_graduation,
    api_invitations,
    api_join_requests,
    api_memberships,
    api_metadata,
    api_notifications,
    api_prs,
    auth,
    collections as collections_mod,
    projects as projects_mod,
    db,
    device_trust as device_trust_mod,
    docs as docs_mod,
    docs_sessions,
    docs_specs,
    entry as entry_mod,
    cache,
    facets,
    funder,
    health,
    notify,
    philosophy,
    providers as providers_mod,
    tag_suggest,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
from .providers import BaseProvider


class ProposeBody(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    slug: str = Field(min_length=1, max_length=80)
    pitch: str = Field(min_length=1)
    tags: list[str] = Field(default_factory=list)
    # Roadmap #26: optional "What will you be using this RFC for?" — the
    # concrete ground-truth use case, distinct from the `pitch`'s abstract
    # "why is this needed." Optional (NULL/omitted accepted), no minimum,
    # generous cap matching the pitch's free-text body bound.
    proposed_use_case: str | None = Field(default=None, max_length=8000)


class SuggestTagsBody(BaseModel):
    # Roadmap #27: the partial propose-RFC draft, sent as the user types
    # (debounced on the frontend). All fields optional — suggestions
    # refine as the draft fills in. `pitch` is the "why is this needed"
    # rationale; `use_case` is the #26 optional ground-truth field.
    # Bounds mirror the propose body's free-text caps.
    title: str = Field(default="", max_length=200)
    pitch: str = Field(default="", max_length=8000)
    use_case: str = Field(default="", max_length=8000)


class DeclineBody(BaseModel):
    comment: str = Field(min_length=1, max_length=4000)


class FunderCredentialBody(BaseModel):
    provider: str = Field(min_length=1, max_length=40)
    api_key: str = Field(min_length=1, max_length=2048)


class LastStateBody(BaseModel):
    # v0.23.0 / roadmap item #29: server-side sign-in state resume.
    # `route` is a frontend pathname the user was last on (bounded so a
    # hostile client can't stuff arbitrary blobs through). `state` is an
    # optional bag of *light* view state (scroll anchors, open tab,
    # filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
    # the frontend only ever sends ephemeral view state, and the column
    # comment in migration 022 + SPEC §6.2 are the binding contract.
    route: str = Field(min_length=1, max_length=2048)
    state: dict[str, Any] | None = None


class BetaRequestBody(BaseModel):
    # v0.8.0 — captured on the first OTC sign-in. All three fields are
    # required so the admin queue has a coherent triage shape.
    # The bounds match the v0.7.0 OTC body (320 chars for email-ish
    # headers; 4000 for the free-text reason — the same upper bound
    # DeclineBody uses elsewhere in this file).
    first_name: str = Field(min_length=1, max_length=120)
    last_name: str = Field(min_length=1, max_length=120)
    beta_request_reason: str = Field(min_length=1, max_length=4000)


def make_router(
    config: Config,
    gitea: Gitea,
    bot: Bot,
    providers: dict[str, BaseProvider] | None = None,
) -> APIRouter:
    # Use `is None` rather than `providers or {}` — an empty dict is
    # falsy, and the test harness mutates the dict the closure holds to
    # inject a fake provider; substituting a fresh `{}` here would
    # silently drop those mutations.
    if providers is None:
        providers = {}
    router = APIRouter()
    # Slice 2: the §8 active-RFC view's endpoints live in api_branches.
    # Mounting them on the same router keeps the §17 layout flat.
    router.include_router(api_branches.make_router(config, gitea, bot, providers))
    # Slice 3: the §10 PR-flow endpoints.
    router.include_router(api_prs.make_router(config, gitea, bot, providers))
    # Slice 5: §13 graduation + §13.1 claim.
    router.include_router(api_graduation.make_router(config, gitea, bot))
    # §22.4a SLICE-4/5: entry metadata edit + Owner-gated collection migrate.
    router.include_router(api_metadata.make_router(config, gitea, bot))
    # Slice 6: §15 notifications surface (inbox, watches, prefs,
    # quiet hours, per-user mute, email unsubscribe, bounce webhook).
    router.include_router(api_notifications.make_router(config))
    # Slice 7: §14 chrome (/philosophy read endpoint, user search for
    # the §15.8 mute typeahead) and the §6/§17 admin surfaces
    # (role, write-mute, audit-log, graduation-readiness queue).
    router.include_router(api_admin.make_router(config))
    # v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
    # The substrate is the existing threads/thread_messages tables;
    # rows whose branch_name IS NULL scope to the RFC's main view.
    # Contribution still requires a PR (api_prs above); this surface
    # is for discussion that does not yet warrant a branch.
    router.include_router(api_discussion.make_router())
    # v0.16.0 (roadmap item #12): owner-only invite for per-RFC
    # contribution + discussion. The RFC's owner can invite specific
    # users by email to either open PRs or join the discussion; non-
    # invited users keep read access but cannot write (v0.6.0
    # contract extended to per-RFC scope).
    router.include_router(api_invitations.make_router())
    # v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
    # (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
    # on accept; lands the request + owner notifications via §15 notify.
    router.include_router(api_contributions.make_router())
    # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
    # VITE_APP_NAME) + the old-URL 308 redirects.
    router.include_router(api_deployment.make_router(config, gitea, bot))
    router.include_router(api_collections.make_router(config, gitea, bot))
    # §22 S4 (C.2): the scope-role invitation surface — Owners grant
    # {owner, contributor} at project/collection scope to existing accounts.
    router.include_router(api_memberships.make_router())
    # §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
    # scope (naming a role); the scope's Owners across the subtree accept (writing
    # the membership row) or decline.
    router.include_router(api_join_requests.make_router())

    # ---------------------------------------------------------------
    # §17: /api/health — unauthenticated post-flight probe.
    # Used by ops tooling (flotilla) to verify a deploy landed via
    # version-match. v1 always reports ok; see backend/app/health.py.
    # ---------------------------------------------------------------

    @router.get("/api/health")
    async def get_health() -> dict[str, Any]:
        return {"version": health.VERSION, "status": "ok"}

    # ---------------------------------------------------------------
    # §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
    # No auth gate; anonymous visitors reach `/philosophy` per §14.1.
    # The body is read once at process start and refreshed on demand
    # via the reconciler; see backend/app/philosophy.py.
    # ---------------------------------------------------------------

    @router.get("/api/philosophy")
    async def get_philosophy() -> dict[str, Any]:
        payload = philosophy.load()
        return {"body": payload["body"]}

    # ---------------------------------------------------------------
    # /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
    # no auth gate, same disk-first load + cache shape, same intent —
    # public read surface for a markdown file checked into the repo.
    # ---------------------------------------------------------------

    @router.get("/api/docs")
    async def get_docs() -> dict[str, Any]:
        payload = docs_mod.load()
        return {"body": payload["body"]}

    # ---------------------------------------------------------------
    # v0.19.0 / roadmap item #30 — /api/docs/sessions/*
    #
    # The framework mediates reads against the public
    # `wiggleverse/ohm-session-history` gitea repo so the rendered
    # `/docs/sessions/*` surface inherits the same chrome as the
    # /docs/user-guide route and doesn't require a cross-origin
    # gesture from the frontend. See backend/app/docs_sessions.py
    # for the cache shape and env knobs.
    #
    # The route mapping for the three `status` values returned by
    # the fetchers:
    #
    #   "ok"     → HTTP 200, payload as documented per endpoint
    #   "404"    → HTTP 200/404 depending on the endpoint (the
    #              manifest's empty state is 200 + {} so the
    #              frontend can short-circuit without an error
    #              banner; transcripts/about return 404 so the
    #              frontend can render its own empty-state)
    #   "error"  → HTTP 502, {"error": ..., "detail": ...} so the
    #              frontend retry surface reads as "couldn't reach
    #              the session-history repo" rather than as a
    #              generic 5xx.
    # ---------------------------------------------------------------

    @router.get("/api/docs/sessions/manifest")
    async def get_sessions_manifest() -> dict[str, Any]:
        result = await docs_sessions.fetch_manifest()
        if result["status"] == "ok":
            return result["manifest"]
        if result["status"] == "404":
            # Empty-state contract: render no session rows in the
            # flyout but don't show an error banner. The frontend
            # treats `{}` as "no sessions published yet".
            return {}
        raise HTTPException(
            status_code=502,
            detail={
                "error": "session-history fetch failed",
                "detail": result.get("detail", "unknown"),
            },
        )

    @router.get("/api/docs/sessions/about")
    async def get_sessions_about() -> Response:
        result = await docs_sessions.fetch_about()
        if result["status"] == "ok":
            return PlainTextResponse(
                content=result["body"],
                media_type="text/markdown; charset=utf-8",
            )
        if result["status"] == "404":
            raise HTTPException(
                status_code=404,
                detail="session-history README not yet published",
            )
        raise HTTPException(
            status_code=502,
            detail={
                "error": "session-history fetch failed",
                "detail": result.get("detail", "unknown"),
            },
        )

    @router.get("/api/docs/sessions/{nnnn}/index")
    async def get_sessions_index(nnnn: str) -> dict[str, Any]:
        if not docs_sessions._is_valid_session_dir(nnnn):
            # 400 over 404: the request itself is malformed (the
            # session directory name doesn't match `^\d{4}$`),
            # distinct from "no such session published yet".
            raise HTTPException(status_code=400, detail="invalid session directory")
        result = await docs_sessions.fetch_session_index(nnnn)
        if result["status"] == "ok":
            return {"files": result["files"]}
        if result["status"] == "404":
            raise HTTPException(
                status_code=404,
                detail="no transcripts published for this session",
            )
        raise HTTPException(
            status_code=502,
            detail={
                "error": "session-history fetch failed",
                "detail": result.get("detail", "unknown"),
            },
        )

    @router.get("/api/docs/sessions/{nnnn}/{filename}")
    async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
        # Path-shape validation before any network — refuses anything
        # that would resolve outside the `NNNN/SESSION-...md` layout
        # (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
        # `../etc/passwd`, or any non-numeric session dir).
        if not docs_sessions._is_valid_session_dir(nnnn):
            raise HTTPException(status_code=400, detail="invalid session directory")
        if not docs_sessions._is_valid_transcript_filename(filename):
            raise HTTPException(status_code=400, detail="invalid transcript filename")
        result = await docs_sessions.fetch_transcript(nnnn, filename)
        if result["status"] == "ok":
            return PlainTextResponse(
                content=result["body"],
                media_type="text/markdown; charset=utf-8",
            )
        if result["status"] == "404":
            raise HTTPException(
                status_code=404,
                detail="transcript not found",
            )
        raise HTTPException(
            status_code=502,
            detail={
                "error": "session-history fetch failed",
                "detail": result.get("detail", "unknown"),
            },
        )

    # ---------------------------------------------------------------
    # v0.20.0 — /api/docs/specs/*
    #
    # Sibling of the v0.19.0 docs-sessions surface: the framework
    # mediates a fetch against the public gitea raw URL for each
    # configured spec so the rendered `/docs/specs/*` route inherits
    # the same chrome (and the same auth-less reach) as the user
    # guide and the session-history browser. See
    # backend/app/docs_specs.py for the manifest shape, the env
    # knobs, and the cache.
    #
    # Status-to-HTTP mapping mirrors docs_sessions:
    #   "ok"    → HTTP 200, payload as documented per endpoint
    #   "404"   → HTTP 200 / 404 (manifest 404 doesn't apply here —
    #             the manifest is derived from env, never 404s; spec
    #             404 returns HTTP 404 so the frontend can render
    #             "spec not yet published / unknown name")
    #   "error" → HTTP 502
    # ---------------------------------------------------------------

    @router.get("/api/docs/specs/manifest")
    async def get_specs_manifest() -> dict[str, Any]:
        # The manifest is derived from env (`OHM_DOCS_SPECS`) and
        # never fails — a malformed value falls back to the framework
        # default at parse time. So this endpoint always returns 200
        # + a list (the framework default is non-empty).
        result = docs_specs.fetch_specs_manifest()
        return {"specs": result["specs"]}

    @router.get("/api/docs/specs/{name}")
    async def get_spec(name: str) -> Response:
        # Slug validation before any network — refuses `..`, `/`,
        # uppercase, whitespace, etc. Same defense-in-depth posture
        # as the docs-sessions transcript endpoint.
        if not docs_specs._is_valid_name(name):
            raise HTTPException(status_code=400, detail="invalid spec name")
        result = await docs_specs.fetch_spec(name)
        if result["status"] == "ok":
            return PlainTextResponse(
                content=result["body"],
                media_type="text/markdown; charset=utf-8",
            )
        if result["status"] == "404":
            raise HTTPException(
                status_code=404,
                detail="spec not found",
            )
        raise HTTPException(
            status_code=502,
            detail={
                "error": "specs fetch failed",
                "detail": result.get("detail", "unknown"),
            },
        )

    # ---------------------------------------------------------------
    # Auth surface — reads role from our users table per §6.
    # ---------------------------------------------------------------

    @router.get("/api/auth/me")
    async def auth_me(request: Request) -> dict[str, Any]:
        user = auth.current_user(request)
        if user is None:
            return {"authenticated": False, "user": None}
        # v0.8.0 + v0.10.0: single round-trip for everything the
        # frontend gates UI off of — beta-access state + passcode state.
        row = db.conn().execute(
            "SELECT first_name, last_name, beta_request_reason, "
            "passcode_hash, passcode_set_at "
            "FROM users WHERE id = ?",
            (user.user_id,),
        ).fetchone()
        first_name = (row["first_name"] if row else None) or ""
        last_name = (row["last_name"] if row else None) or ""
        beta_request_reason = (row["beta_request_reason"] if row else None) or ""
        # "Needs profile" iff the user is pending AND hasn't yet
        # filed their beta-request capture. Granted users never see
        # the capture prompt; pending users who already filed see
        # the /beta-pending page without the capture form.
        needs_profile = (
            user.permission_state == "pending"
            and not first_name
            and not last_name
            and not beta_request_reason
        )
        has_passcode = bool(row and row["passcode_hash"])
        passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
        # v0.23.0 / item #29: fold the sign-in-resume state onto the
        # same round-trip the frontend already makes on boot. When
        # resume is disabled (resume_enabled = 0) we hand back a null
        # route so the client never redirects; the stored row stays put
        # so re-enabling later resumes the last-known route.
        state_row = db.conn().execute(
            "SELECT last_route, last_route_state, resume_enabled "
            "FROM user_session_state WHERE user_id = ?",
            (user.user_id,),
        ).fetchone()
        resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
        last_route = (
            state_row["last_route"]
            if (state_row and resume_enabled)
            else None
        )
        last_route_state = None
        if state_row and resume_enabled and state_row["last_route_state"]:
            try:
                last_route_state = json.loads(state_row["last_route_state"])
            except (ValueError, TypeError):
                last_route_state = None
        return {
            "authenticated": True,
            "user": {
                "id": user.user_id,
                "gitea_login": user.gitea_login,
                "display_name": user.display_name,
                "email": user.email,
                "avatar_url": user.avatar_url,
                "role": user.role,
                "permission_state": user.permission_state,
                "first_name": first_name,
                "last_name": last_name,
                "beta_request_reason": beta_request_reason,
                "needs_profile": needs_profile,
                "has_passcode": has_passcode,
                "passcode_set_at": passcode_set_at,
                # v0.23.0 / item #29 — sign-in state resume.
                "resume_enabled": resume_enabled,
                "last_route": last_route,
                "last_route_state": last_route_state,
            },
        }

    # ---------------------------------------------------------------
    # v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
    # (roadmap item #6). Lands first name, last name, and the free-
    # text "why I should be included in the beta" on the signed-in
    # user's row. Idempotent for the same already-pending user;
    # refuses to overwrite a row that's already granted (so a
    # bored already-granted user can't accidentally re-submit the
    # form and clobber the admin's audit trail). Uses
    # `require_user` rather than `require_contributor` because
    # `require_contributor` already enforces `permission_state =
    # 'granted'` and would refuse a pending user; the whole point
    # of this endpoint is to register the request _from_ a pending
    # user.
    # ---------------------------------------------------------------

    @router.post("/api/auth/me/beta-request")
    async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
        user = auth.require_user(request)
        row = db.conn().execute(
            "SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
            (user.user_id,),
        ).fetchone()
        if row is None:
            # Defensive — the session pointed at a deleted row.
            raise HTTPException(404, "User not found")
        # Granted users have no business filing a beta request.
        # 'revoked' likewise — the request flow is for fresh users
        # only. Both shapes refuse with 409 (conflict) so the client
        # can distinguish "you already have access" from
        # "your access was revoked".
        if row["permission_state"] == "granted":
            raise HTTPException(409, "Your account is already granted access")
        if row["permission_state"] == "revoked":
            raise HTTPException(409, "Your account's access has been revoked")
        # Re-submission from a pending user updates the row — the
        # admin sees the latest text rather than a stale draft.
        # The state stays 'pending'; only an admin can flip it.
        db.conn().execute(
            """
            UPDATE users
               SET first_name = ?,
                   last_name = ?,
                   beta_request_reason = ?
             WHERE id = ?
            """,
            (
                body.first_name.strip(),
                body.last_name.strip(),
                body.beta_request_reason.strip(),
                user.user_id,
            ),
        )
        # v0.9.0 (roadmap item #7): notify every admin/owner of the
        # fresh request. Only the first submission is the
        # "newly-pending" gesture — re-submits from the same user
        # would otherwise carpet the admin inbox. We fire only when
        # this is the row's first time getting all three fields
        # populated (the prior row carried at least one NULL).
        prior = row  # captured before the UPDATE above
        was_already_complete = bool(
            prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
        )
        if not was_already_complete:
            notify.fan_out_new_beta_request(requester_user_id=user.user_id)
        return {"ok": True}

    # ---------------------------------------------------------------
    # v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
    # resume. The frontend debounce-posts the user's current route +
    # a small bag of light view state here on every route change; the
    # next sign-in reads `last_route` off `/api/auth/me` and redirects.
    #
    # Per-user (NOT per-device) — one row per user, keyed on user_id.
    # `resume_enabled` is the opt-out flag (default on); when it's 0
    # this endpoint no-ops so a user who turned resume off doesn't keep
    # silently rewriting their stored route. PRIVACY: the body carries
    # route + light state ONLY, never draft-buffer contents (migration
    # 022 column comment + SPEC §6.2 are the binding contract).
    #
    # `require_user` (not `require_contributor`) — a pending/granted
    # distinction is irrelevant for "remember where I was", and a
    # pending user navigating read-only surfaces should still resume.
    # Anonymous callers get the 401 `require_user` raises.
    # ---------------------------------------------------------------

    @router.put("/api/me/last-state")
    async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
        user = auth.require_user(request)
        # Respect the opt-out: if a row already exists with resume
        # disabled, leave it untouched and report the no-op. A first-
        # ever POST (no row yet) defaults to enabled and stores.
        existing = db.conn().execute(
            "SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
            (user.user_id,),
        ).fetchone()
        if existing is not None and not existing["resume_enabled"]:
            return {"ok": True, "stored": False}
        state_json = json.dumps(body.state) if body.state is not None else None
        db.conn().execute(
            """
            INSERT INTO user_session_state
                (user_id, last_route, last_route_state, last_updated_at)
            VALUES (?, ?, ?, datetime('now'))
            ON CONFLICT(user_id) DO UPDATE SET
                last_route = excluded.last_route,
                last_route_state = excluded.last_route_state,
                last_updated_at = excluded.last_updated_at
            """,
            (user.user_id, body.route, state_json),
        )
        return {"ok": True, "stored": True}

    # ---------------------------------------------------------------
    # v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
    #
    # The mint path lives on the OAuth router (issuing the cookie is
    # coupled to OTC/passcode verify). This module owns the read/revoke
    # surface the /settings/devices page calls.
    # ---------------------------------------------------------------

    @router.get("/api/auth/me/devices")
    async def list_my_devices(request: Request) -> dict[str, Any]:
        """Active device-trust rows for the signed-in user.

        Active = not revoked, not expired. The current request's
        device (if any) is *not* singled out here — the surface
        shows the same row shape for every device so the user can
        revoke any of them without the page leaking which row
        carries the cookie they're using right now.
        """
        user = auth.require_user(request)
        rows = device_trust_mod.list_for_user(user.user_id)
        return {
            "items": [
                {
                    "id": r.id,
                    "created_at": r.created_at,
                    "expires_at": r.expires_at,
                    "last_seen_at": r.last_seen_at,
                    "user_agent": r.user_agent,
                }
                for r in rows
            ]
        }

    @router.delete("/api/auth/me/devices/{device_id}")
    async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
        """Revoke a single device-trust row for the signed-in user.

        The user-id scope is enforced in SQL so a hostile client
        cannot revoke another user's row by guessing ids. A row that
        doesn't exist, doesn't belong to this user, or is already
        revoked reads as 404 — the wrong-vs-already-revoked
        distinction would only help a probing client enumerate ids.
        """
        user = auth.require_user(request)
        ok = device_trust_mod.revoke(user.user_id, device_id)
        if not ok:
            raise HTTPException(404, "Device not found")
        return {"ok": True}

    @router.delete("/api/auth/me/devices")
    async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
        """Revoke every active device-trust row for the signed-in user.

        The user's current request stays authenticated via its
        session cookie; the device-trust cookie carried on the
        current device is also revoked, but `rfc_session` keeps the
        request flow alive until sign-out / expiry.
        """
        user = auth.require_user(request)
        count = device_trust_mod.revoke_all(user.user_id)
        return {"ok": True, "revoked": count}

    # ---------------------------------------------------------------
    # §7: the catalog
    # ---------------------------------------------------------------

    @router.get("/api/rfcs")
    async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
        """§7's left pane data.

        The chip-filter / sort / search combinatorics live on the
        client — the server returns the full set and lets the chips
        narrow it. The set is small (hundreds, not thousands) for the
        foreseeable future, so paginating here would buy nothing.

        §22.4c: pass `?unreviewed=true` to narrow to active entries
        still awaiting owner review.
        """
        viewer = auth.current_user(request)
        viewer_id = viewer.user_id if viewer else None
        # §22.5: a gated project's entries never surface in a non-member's
        # catalog. For the single public default project this is the full set.
        visible = auth.visible_project_ids(viewer)
        if not visible:
            return {"items": []}
        placeholders = ",".join("?" for _ in visible)
        params = list(visible)
        unreviewed_clause = ""
        if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
            unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
        rows = db.conn().execute(
            f"""
            SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
                   r.owners_json, r.arbiters_json, r.tags_json,
                   r.metadata_malformed,
                   r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
              FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
             WHERE r.state IN ('super-draft', 'active')
               AND c.project_id IN ({placeholders}){unreviewed_clause}
             ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
            """,
            params,
        ).fetchall()

        starred = set()
        if viewer_id is not None:
            starred = {
                r["rfc_slug"]
                for r in db.conn().execute(
                    "SELECT rfc_slug FROM stars WHERE user_id = ?", (viewer_id,)
                )
            }

        items = []
        for r in rows:
            items.append(
                {
                    "slug": r["slug"],
                    "title": r["title"],
                    "state": r["state"],
                    "id": r["rfc_id"],
                    "repo": r["repo"],
                    "owners": json.loads(r["owners_json"] or "[]"),
                    "arbiters": json.loads(r["arbiters_json"] or "[]"),
                    "tags": json.loads(r["tags_json"] or "[]"),
                    "last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
                    "starred_by_me": r["slug"] in starred,
                    "has_open_prs": False,  # wired in Slice 2 when per-RFC repos exist
                    "metadata_malformed": bool(r["metadata_malformed"]),
                }
            )
        return {"items": items}

    @router.get("/api/rfcs/{slug}")
    async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
        row = db.conn().execute(
            "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not found")
        viewer = auth.current_user(request)
        # §22.5 visibility gate (subtractive, §22.7): a gated project's entries
        # 404 to non-members. Recover the project via the entry's collection.
        auth.require_project_readable(viewer, auth.project_of_rfc(slug))
        # §13.7: a retired entry is removed from every browsing surface. The
        # sole exception is a site owner, so the un-retire affordance has
        # somewhere to live; everyone else gets a plain 404.
        if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
            raise HTTPException(404, "Not found")
        payload = _serialize_rfc(row)
        # Roadmap #26: surface the optional propose-time use case on the
        # RFC view. The idea PR closes on merge, but the canonical row in
        # `proposed_use_cases` persists; look it up by slug (the latest
        # 'rfc'-scope row for this slug). NULL == "left blank".
        uc = db.conn().execute(
            """
            SELECT use_case FROM proposed_use_cases
             WHERE scope = 'rfc' AND rfc_slug = ?
             ORDER BY id DESC LIMIT 1
            """,
            (slug,),
        ).fetchone()
        payload["proposed_use_case"] = uc["use_case"] if uc else None
        # §22.4a SLICE-4: contributor+ on the entry's collection may edit metadata.
        payload["can_edit_meta"] = bool(
            auth.can_contribute_in_collection(viewer, auth.collection_of_rfc(slug)))
        return payload

    # ---------------------------------------------------------------
    # §22.4 (Plan B): per-project RFC serving — the catalog + entry view
    # scoped to one project, identified by its own slug namespace
    # (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
    # default-project compat path; the frontend reads these scoped routes so a
    # second project's corpus renders under /p/<id>/.
    # ---------------------------------------------------------------

    def _require_collection_in_project(collection_id: str, project_id: str) -> None:
        # §22 S2: a collection-scoped route 404s when the collection does not
        # belong to the project in the path (shape matches an unknown id).
        if collections_mod.project_of_collection(collection_id) != project_id:
            raise HTTPException(404, "Not found")

    def _list_rfcs_for_collection(
        collection_id: str, viewer, unreviewed: str | None,
        query_params=None,
    ) -> dict[str, Any]:
        viewer_id = viewer.user_id if viewer else None
        # §22.4a SLICE-3: the collection's declared field schema drives the facet
        # set (None when undeclared → no facets, INV-5).
        col = collections_mod.get_collection(collection_id)
        fields_schema = (col or {}).get("fields") or None

        # Parse + validate filter selections from the query string. Unknown
        # field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
        # empty-valued selection is ignored, not an error (plan decision 6).
        selections: dict[str, set[str]] = {}
        only_malformed = False
        if query_params is not None:
            allowed = facets.allowed_filter_keys(fields_schema)
            facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
            for key in query_params.keys():
                if key not in allowed:
                    raise HTTPException(400, f"unknown filter field {key!r}")
            if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
                only_malformed = True
            for name in facet_names:
                vals = {v for v in query_params.getlist(name) if v != ""}
                if vals:
                    selections[name] = vals

        unreviewed_clause = ""
        if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
            unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
        rows = db.conn().execute(
            f"""
            SELECT slug, title, state, rfc_id, repo,
                   owners_json, arbiters_json, tags_json, metadata_malformed,
                   meta_json,
                   last_main_commit_at, last_entry_commit_at, updated_at
              FROM cached_rfcs
             WHERE state IN ('super-draft', 'active')
               AND collection_id = ?{unreviewed_clause}
             ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
            """,
            (collection_id,),
        ).fetchall()
        starred = set()
        if viewer_id is not None:
            starred = {
                r["rfc_slug"]
                for r in db.conn().execute(
                    "SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
                    (viewer_id, collection_id),
                )
            }

        # Build entry dicts the facet helper understands (state + malformed +
        # parsed meta), preserving SQL order.
        entries = []
        for r in rows:
            try:
                meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
            except (TypeError, ValueError):
                meta = {}
            entries.append({
                "slug": r["slug"],
                "title": r["title"],
                "state": r["state"],
                "id": r["rfc_id"],
                "repo": r["repo"],
                "owners": json.loads(r["owners_json"] or "[]"),
                "arbiters": json.loads(r["arbiters_json"] or "[]"),
                "tags": json.loads(r["tags_json"] or "[]"),
                "last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
                "starred_by_me": r["slug"] in starred,
                "has_open_prs": False,
                "metadata_malformed": bool(r["metadata_malformed"]),
                "meta": meta,
            })

        filtered, facet_counts = facets.filter_and_count(
            entries, fields_schema, selections, only_malformed=only_malformed
        )
        return {"items": filtered, "facets": facet_counts}

    def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
        row = db.conn().execute(
            "SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
            (collection_id, slug),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not found")
        if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
            raise HTTPException(404, "Not found")
        payload = _serialize_rfc(row)
        uc = db.conn().execute(
            """
            SELECT use_case FROM proposed_use_cases
             WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
             ORDER BY id DESC LIMIT 1
            """,
            (slug, collection_id),
        ).fetchone()
        payload["proposed_use_case"] = uc["use_case"] if uc else None
        # §22.4a SLICE-4: contributor+ on the collection may edit metadata (INV-4).
        payload["can_edit_meta"] = bool(
            auth.can_contribute_in_collection(viewer, collection_id))
        return payload

    @router.get("/api/projects/{project_id}/rfcs")
    async def list_project_rfcs(
        project_id: str, request: Request, unreviewed: str | None = None
    ) -> dict[str, Any]:
        viewer = auth.current_user(request)
        # §22.5 read gate: a gated project's catalog 404s to a non-member.
        auth.require_project_readable(viewer, project_id)
        # §22 S1: the project-scoped route serves the default collection.
        collection_id = collections_mod.default_collection_id(project_id)
        return _list_rfcs_for_collection(
            collection_id, viewer, unreviewed, query_params=request.query_params
        )

    @router.get("/api/projects/{project_id}/rfcs/{slug}")
    async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
        viewer = auth.current_user(request)
        auth.require_project_readable(viewer, project_id)
        collection_id = collections_mod.default_collection_id(project_id)
        return _get_rfc_for_collection(collection_id, slug, viewer)

    # §22 S2: collection-scoped serve + propose. The catalog/entry views read
    # these under /p/<project>/c/<collection>/; the project-scoped routes above
    # stay as the default-collection compat surface.
    @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
    async def list_collection_rfcs(
        project_id: str, collection_id: str, request: Request,
        unreviewed: str | None = None,
    ) -> dict[str, Any]:
        viewer = auth.current_user(request)
        auth.require_project_readable(viewer, project_id)
        _require_collection_in_project(collection_id, project_id)
        # §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
        auth.require_collection_readable(viewer, collection_id)
        return _list_rfcs_for_collection(
            collection_id, viewer, unreviewed, query_params=request.query_params
        )

    @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
    async def get_collection_rfc(
        project_id: str, collection_id: str, slug: str, request: Request
    ) -> dict[str, Any]:
        viewer = auth.current_user(request)
        auth.require_project_readable(viewer, project_id)
        _require_collection_in_project(collection_id, project_id)
        auth.require_collection_readable(viewer, collection_id)
        return _get_rfc_for_collection(collection_id, slug, viewer)

    # ---------------------------------------------------------------
    # §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
    # ---------------------------------------------------------------

    @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
    async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
        """§22.4c — clear an active entry's `unreviewed` flag. Authority is the
        §B.2 collection Owner (a collection/project/global Owner or deployment
        owner/admin reaching the entry's collection)."""
        viewer = auth.require_user(request)
        auth.require_project_readable(viewer, project_id)
        collection_id = collections_mod.default_collection_id(project_id)
        if not auth.is_collection_superuser(viewer, collection_id):
            raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
        row = db.conn().execute(
            "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
            (slug, collection_id),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not found")
        if row["state"] != "active" or not row["unreviewed"]:
            raise HTTPException(409, "Entry is not an unreviewed active entry")
        # §22/G-15: write to the entry's project content_repo + collection
        # subfolder, not the deployment default.
        org, meta_repo, md_path = projects_mod.entry_location(config, collection_id, slug)
        try:
            await bot.mark_entry_reviewed(
                viewer.as_actor(),
                org=org,
                meta_repo=meta_repo,
                slug=slug,
                reviewed_by=viewer.gitea_login,
                reviewed_at=entry_mod.today(),
                file_path=md_path,
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        await cache.refresh_meta_repo(config, gitea)
        return {"ok": True}

    # ---------------------------------------------------------------
    # §7.3 / §9.3: pending ideas
    # ---------------------------------------------------------------

    def _proposal_use_case(pr_number: int) -> str | None:
        """Roadmap #26: read the optional use case for an idea PR from the
        canonical side table. Returns None when none was supplied (the
        "left blank" sentinel the frontend renders tastefully)."""
        row = db.conn().execute(
            "SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
            (pr_number,),
        ).fetchone()
        return row["use_case"] if row else None

    @router.get("/api/proposals")
    async def list_proposals(request: Request) -> dict[str, Any]:
        # §22.5: idea PRs in a gated project never surface to non-members.
        visible = auth.visible_project_ids(auth.current_user(request))
        if not visible:
            return {"items": []}
        placeholders = ",".join("?" for _ in visible)
        rows = db.conn().execute(
            f"""
            SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
              FROM cached_prs
             WHERE pr_kind = 'idea' AND state = 'open'
               AND project_id IN ({placeholders})
             ORDER BY opened_at DESC
            """,
            visible,
        ).fetchall()
        return {
            "items": [
                {
                    "slug": r["rfc_slug"],
                    "pr_number": r["pr_number"],
                    "title": r["title"],
                    "description": r["description"],
                    "opened_by": r["opened_by"],
                    "opened_at": r["opened_at"],
                    "proposed_use_case": _proposal_use_case(r["pr_number"]),
                }
                for r in rows
            ]
        }

    @router.get("/api/projects/{project_id}/proposals")
    async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
        # §22.4/§22.5: the pending idea-PRs scoped to one project.
        viewer = auth.current_user(request)
        auth.require_project_readable(viewer, project_id)
        rows = db.conn().execute(
            """
            SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
              FROM cached_prs
             WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
             ORDER BY opened_at DESC
            """,
            (project_id,),
        ).fetchall()
        return {
            "items": [
                {
                    "slug": r["rfc_slug"],
                    "pr_number": r["pr_number"],
                    "title": r["title"],
                    "description": r["description"],
                    "opened_by": r["opened_by"],
                    "opened_at": r["opened_at"],
                    "proposed_use_case": _proposal_use_case(r["pr_number"]),
                }
                for r in rows
            ]
        }

    @router.get("/api/proposals/{pr_number}")
    async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
        """§9.3 pending-idea view data.

        Reads the proposed file from the proposer's branch on the meta
        repo, so the viewer sees the entry as it will land. The chat
        thread is not yet implemented; thread_id surfaces as null until
        Slice 2's chat wiring lands.
        """
        row = db.conn().execute(
            """
            SELECT * FROM cached_prs
             WHERE pr_kind = 'idea' AND pr_number = ?
            """,
            (pr_number,),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not a proposal PR")
        # §22.5 visibility gate (subtractive): gated → 404 to non-members.
        auth.require_project_readable(auth.current_user(request), row["project_id"])
        # Read the proposed entry file from the head branch.
        slug = row["rfc_slug"]
        head = row["head_branch"]
        # §22/G-15: the proposal lives in its project's content_repo under its
        # collection's `<subfolder>/rfcs/`. cached_prs carries project_id but not
        # collection_id, so resolve the repo from the project and locate the file
        # by trying each of the project's collection subfolders (default first).
        repo = (projects_mod.content_repo(row["project_id"])
                or projects_mod.default_content_repo(config) or "")
        result = None
        for col in collections_mod.list_collections(row["project_id"], include_unlisted=True):
            sub = col["subfolder"] or ""
            cand = f"{sub}/rfcs/{slug}.md" if sub else f"rfcs/{slug}.md"
            result = await gitea.read_file(config.gitea_org, repo, cand, ref=head)
            if result:
                break
        entry_payload: dict[str, Any] | None = None
        if result:
            text, _sha = result
            try:
                entry = entry_mod.parse(text)
                entry_payload = _entry_payload(entry)
            except Exception:
                entry_payload = None

        viewer = auth.current_user(request)
        affordances = _proposal_affordances(viewer, row)
        return {
            "slug": slug,
            "pr_number": pr_number,
            "title": row["title"],
            "description": row["description"],
            "state": row["state"],
            "opened_by": row["opened_by"],
            "opened_at": row["opened_at"],
            "entry": entry_payload,
            "affordances": affordances,
            "proposed_use_case": _proposal_use_case(pr_number),
        }

    # ---------------------------------------------------------------
    # §9.1: propose a new RFC
    # ---------------------------------------------------------------

    async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
        # Default-collection wrapper (§22 S1/S2): resolve the project's default
        # collection and delegate. Keeps the project-scoped propose routes intact.
        return await _propose_into_collection(
            project_id, collections_mod.default_collection_id(project_id), payload, user
        )

    async def _propose_into_collection(
        project_id: str, collection_id: str, payload: ProposeBody, user
    ) -> dict[str, Any]:
        # §B.2 (S3): proposing a new entry requires contribute standing in the
        # *target collection* — the four-layer scope-role union, with the
        # grandfathered implicit-public baseline on the default collection.
        if not auth.can_contribute_in_collection(user, collection_id):
            raise HTTPException(403, "You do not have contribute access to this collection")
        slug = payload.slug.strip().lower()
        if not entry_mod.is_valid_slug(slug):
            raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")

        # §9.1 uniqueness — against rfcs/ on main *and* against open idea PRs.
        # We re-check atomically here even though the client also checks
        # on every keystroke, since a concurrent submission could land
        # between dialog-open and submit.
        clash = db.conn().execute(
            "SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
        ).fetchone()
        if clash:
            raise HTTPException(409, f"Slug `{slug}` is already taken")
        idea_clash = db.conn().execute(
            "SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
            "AND rfc_slug = ? AND project_id = ?",
            (slug, project_id),
        ).fetchone()
        if idea_clash:
            raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")

        # §22.4b: the target collection's landing state (the per-corpus field
        # moved down to the collection in migration 029).
        landing_state = (
            "active" if collections_mod.collection_initial_state(collection_id) == "active"
            else "super-draft"
        )

        entry = entry_mod.Entry(
            slug=slug,
            title=payload.title.strip(),
            state=landing_state,
            id=None,
            repo=None,
            proposed_by=user.email or user.gitea_login,
            proposed_at=entry_mod.today(),
            graduated_at=None,
            graduated_by=None,
            # §9.2: the proposer is the implicit first owner at propose time.
            # The §13.1 claim flow exists for *other* contributors to become
            # owners on an RFC they didn't propose; the proposer never needs
            # to claim their own RFC.
            owners=[user.gitea_login],
            arbiters=[],
            tags=[t.strip() for t in payload.tags if t.strip()],
            body=payload.pitch.strip() + "\n",
            unreviewed=(landing_state == "active"),
        )
        contents = entry_mod.serialize(entry)
        pr_title = f"Propose: {entry.title}"
        # Slice 1's AI-drafted PR description is deferred — the v1 spec
        # calls for it (§9.2) but the wiring belongs with the rest of
        # the AI-on-the-propose-modal work; for now we send the pitch.
        pr_description = (
            f"**Topic:** {entry.title}\n\n"
            f"{payload.pitch.strip()}"
        )
        # §22 S2: write the entry under the target collection's <subfolder>/rfcs.
        subfolder = collections_mod.subfolder_of(collection_id)
        rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
        try:
            pr = await bot.open_idea_pr(
                user.as_actor(),
                org=config.gitea_org,
                meta_repo=(projects_mod.content_repo(project_id) or ""),
                slug=slug,
                file_contents=contents,
                pr_title=pr_title,
                pr_description=pr_description,
                rfcs_dir=rfcs_dir,
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")

        # Refresh the meta-PRs cache so the proposer sees the new entry
        # on the pending-ideas disclosure immediately, without waiting
        # for the webhook to arrive. (The webhook will arrive too; the
        # cache write is idempotent.)
        await cache.refresh_meta_pulls(config, gitea)

        # Roadmap #26: persist the optional use case to the canonical,
        # reconcile-proof side table keyed by the idea PR number. NULL/
        # blank simply writes no row (absence == "left blank"). Done after
        # the refresh so the cache row exists; the mirror onto cached_prs
        # keeps the cache column in parity for any read that uses it.
        use_case = (payload.proposed_use_case or "").strip()
        if use_case:
            db.conn().execute(
                """
                INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
                VALUES ('rfc', ?, ?, ?, ?)
                ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
                """,
                (slug, pr["number"], use_case, collection_id),
            )
            db.conn().execute(
                "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
                (use_case, pr["number"], project_id),
            )

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

    @router.post("/api/rfcs/propose")
    async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
        # Default-project compat path (pre-multi-project clients).
        user = auth.require_contributor(request)
        return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)

    @router.post("/api/projects/{project_id}/rfcs/propose")
    async def propose_project_rfc(
        project_id: str, payload: ProposeBody, request: Request
    ) -> dict[str, Any]:
        # §22.4: propose a new entry into a specific project (read-gated first
        # so a gated project 404s a non-member before the contribute check).
        user = auth.require_contributor(request)
        auth.require_project_readable(user, project_id)
        return await _propose_into_project(project_id, payload, user)

    @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
    async def propose_collection_rfc(
        project_id: str, collection_id: str, payload: ProposeBody, request: Request
    ) -> dict[str, Any]:
        # §22 S2: propose a new entry into a specific collection of a project.
        user = auth.require_contributor(request)
        auth.require_project_readable(user, project_id)
        _require_collection_in_project(collection_id, project_id)
        # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
        # before the contribute check (existence is not revealed).
        auth.require_collection_readable(user, collection_id)
        return await _propose_into_collection(project_id, collection_id, payload, user)

    # ---------------------------------------------------------------
    # §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
    # propose-RFC fields fill in. The modal debounce-posts the partial
    # draft; we constrain Haiku to the corpus's existing tag set and
    # return a short ranked list of clickable chips. Gated to
    # contributors (same gate as propose) so the cost surface is bounded
    # to people who can actually file an RFC; rate-limited per user as a
    # backstop. Degrades to an empty list (no error) when no Anthropic
    # key is bound, the corpus has no tags yet, or the draft is empty —
    # so the modal simply shows nothing extra.
    # ---------------------------------------------------------------

    @router.post("/api/rfcs/suggest-tags")
    async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
        user = auth.require_contributor(request)
        if not tag_suggest.rate_limit_ok(user.user_id):
            raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
        provider = tag_suggest.haiku_provider(config)
        if provider is None:
            return {"suggestions": []}
        universe = tag_suggest.gather_tag_universe()
        draft = tag_suggest.Draft(
            title=payload.title, pitch=payload.pitch, use_case=payload.use_case
        )
        suggestions = tag_suggest.suggest(provider, draft, universe)
        return {"suggestions": suggestions}

    # ---------------------------------------------------------------
    # §9.3: merge / decline / withdraw an idea PR
    # ---------------------------------------------------------------

    @router.post("/api/proposals/{pr_number}/merge")
    async def merge_proposal(pr_number: int, request: Request) -> dict[str, Any]:
        user = auth.require_admin(request)
        row = _require_open_idea_pr(pr_number)
        try:
            await bot.merge_idea_pr(
                user.as_actor(),
                org=config.gitea_org,
                # §22/G-15: the idea PR lives in its project's content_repo.
                meta_repo=(projects_mod.content_repo(row["project_id"])
                           or projects_mod.default_content_repo(config) or ""),
                pr_number=pr_number,
                slug=row["rfc_slug"],
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        # Refresh both surfaces — the entry is now on main, and the PR
        # is now closed.
        await cache.refresh_meta_repo(config, gitea)
        await cache.refresh_meta_pulls(config, gitea)
        return {"ok": True, "slug": row["rfc_slug"]}

    @router.post("/api/proposals/{pr_number}/decline")
    async def decline_proposal(pr_number: int, body: DeclineBody, request: Request) -> dict[str, Any]:
        user = auth.require_admin(request)
        row = _require_open_idea_pr(pr_number)
        try:
            await bot.decline_idea_pr(
                user.as_actor(),
                org=config.gitea_org,
                meta_repo=(projects_mod.content_repo(row["project_id"])
                           or projects_mod.default_content_repo(config) or ""),
                pr_number=pr_number,
                slug=row["rfc_slug"],
                comment=body.comment,
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        await cache.refresh_meta_pulls(config, gitea)
        return {"ok": True}

    @router.post("/api/proposals/{pr_number}/withdraw")
    async def withdraw_proposal(pr_number: int, request: Request) -> dict[str, Any]:
        user = auth.require_contributor(request)
        row = _require_open_idea_pr(pr_number)
        # Only the proposer can withdraw their own proposal, except that
        # owner/admin can also act (they have all contributor powers per
        # §6.1, and the withdraw path here doesn't expose decline-only
        # affordances).
        if row["opened_by"] != user.gitea_login and user.role not in ("owner", "admin"):
            raise HTTPException(403, "Only the proposer can withdraw")
        try:
            await bot.withdraw_idea_pr(
                user.as_actor(),
                org=config.gitea_org,
                meta_repo=(projects_mod.content_repo(row["project_id"])
                           or projects_mod.default_content_repo(config) or ""),
                pr_number=pr_number,
                slug=row["rfc_slug"],
            )
        except GiteaError as e:
            raise HTTPException(502, f"Gitea: {e.detail}")
        await cache.refresh_meta_pulls(config, gitea)
        return {"ok": True}

    # ---------------------------------------------------------------
    # §6.7 / §17: per-RFC credential delegation — the funder surface.
    # /api/users/me/funder is the self-serve read; the two write
    # endpoints register credentials and the two slug-scoped endpoints
    # toggle consent. Per §6.7, the funder cannot expand the operator
    # universe: registering for a provider family the operator has not
    # enabled is refused. A consent without registered credentials is
    # also refused since it would be operationally inert.
    # ---------------------------------------------------------------

    @router.get("/api/users/me/funder")
    async def get_funder_self(request: Request) -> dict[str, Any]:
        user = auth.require_user(request)
        return {
            "credentials": funder.list_credentials(user.user_id),
            "consents": funder.list_consents(user.user_id),
        }

    @router.post("/api/users/me/funder/credentials")
    async def register_funder_credential(payload: FunderCredentialBody, request: Request) -> dict[str, Any]:
        user = auth.require_contributor(request)
        provider = payload.provider.strip().lower()
        if provider not in providers_mod.FUNDER_PROVIDER_FAMILIES:
            raise HTTPException(422, f"Unknown provider `{provider}`")
        # §6.7: the funder cannot expand the operator universe. The
        # provider family must back at least one operator-enabled key.
        operator_family_keys = providers_mod.picker_keys_for_family(provider, list(providers.keys()))
        if not operator_family_keys:
            raise HTTPException(409, f"Operator has not enabled any `{provider}` models")
        funder.upsert_credential(user.user_id, provider, payload.api_key.strip())
        return {"ok": True, "provider": provider}

    @router.delete("/api/users/me/funder/credentials/{provider}")
    async def delete_funder_credential(provider: str, request: Request) -> dict[str, Any]:
        user = auth.require_user(request)
        provider = provider.strip().lower()
        funder.delete_credential(user.user_id, provider)
        return {"ok": True, "provider": provider}

    @router.post("/api/rfcs/{slug}/funder/consent")
    async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
        user = auth.require_contributor(request)
        rfc = db.conn().execute(
            "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
        ).fetchone()
        if rfc is None:
            raise HTTPException(404, "RFC not found")
        # §22.5 visibility gate (subtractive): gated → 404 to non-members.
        auth.require_project_readable(user, auth.project_of_rfc(slug))
        # §6.7: refuse consent from a user with no registered credentials
        # — a consent without a universe would be inert and the surface
        # should fail loudly rather than silently.
        if not funder.has_any_credentials(user.user_id):
            raise HTTPException(409, "Register credentials before consenting to fund")
        funder.add_consent(user.user_id, slug)
        return {"ok": True, "slug": slug}

    @router.delete("/api/rfcs/{slug}/funder/consent")
    async def remove_funder_consent(slug: str, request: Request) -> dict[str, Any]:
        user = auth.require_user(request)
        funder.remove_consent(user.user_id, slug)
        return {"ok": True, "slug": slug}

    # ---------------------------------------------------------------
    # Helpers
    # ---------------------------------------------------------------

    def _require_open_idea_pr(pr_number: int):
        row = db.conn().execute(
            """
            SELECT * FROM cached_prs
             WHERE pr_kind = 'idea' AND pr_number = ? AND state = 'open'
            """,
            (pr_number,),
        ).fetchone()
        if row is None:
            raise HTTPException(404, "Not an open proposal PR")
        return row

    return router


def _serialize_rfc(row) -> dict[str, Any]:
    return {
        "slug": row["slug"],
        "title": row["title"],
        "state": row["state"],
        "id": row["rfc_id"],
        "repo": row["repo"],
        "proposed_by": row["proposed_by"],
        "proposed_at": row["proposed_at"],
        "graduated_at": row["graduated_at"],
        "graduated_by": row["graduated_by"],
        "owners": json.loads(row["owners_json"] or "[]"),
        "arbiters": json.loads(row["arbiters_json"] or "[]"),
        "tags": json.loads(row["tags_json"] or "[]"),
        "body": row["body"] or "",
        "metadata_malformed": bool(row["metadata_malformed"]),
        # §22.4a SLICE-4: the full per-entry metadata mapping (known + custom
        # fields) so the detail panel can render schema-driven controls.
        "meta": json.loads(row["meta_json"] or "{}"),
    }


def _entry_payload(entry: entry_mod.Entry) -> dict[str, Any]:
    return {
        "slug": entry.slug,
        "title": entry.title,
        "state": entry.state,
        "id": entry.id,
        "repo": entry.repo,
        "proposed_by": entry.proposed_by,
        "proposed_at": entry.proposed_at,
        "owners": entry.owners,
        "arbiters": entry.arbiters,
        "tags": entry.tags,
        "body": entry.body,
    }


def _proposal_affordances(viewer, row) -> dict[str, bool]:
    """§9.3 header strip affordances by role."""
    is_owner_admin = viewer is not None and viewer.role in ("owner", "admin")
    is_proposer = viewer is not None and row["opened_by"] == viewer.gitea_login
    return {
        "merge": is_owner_admin,
        "decline": is_owner_admin,
        "withdraw": is_proposer or is_owner_admin,
    }
