"""§17 admin endpoints + Slice 7 user search.

The admin's repertoire grew across the prior slices without earning a
centralized home: role grants (§6.1), the §6.2 app-wide write-mute, the
§6.5 / §5 audit logs, and the §13 graduation-readiness queue. Slice 7
consolidates them behind `/api/admin/*` so the chrome can hold them in
one tabbed surface (`Admin.jsx`).

The endpoints in this module:

  - `GET  /api/admin/users`                       — list users with role + mute
  - `POST /api/admin/users/<id>/role`             — set role per §6.1
  - `POST /api/admin/users/<id>/mute`             — set the §6.2 write-mute
  - `POST /api/admin/users`                       — v0.17.0: create user + invite
  - `GET  /api/admin/users/invites`               — v0.17.0: pending invites
  - `GET  /api/admin/audit`                       — paged `actions` log
  - `GET  /api/admin/permission-events`           — paged `permission_events` log
  - `GET  /api/admin/graduation-queue`            — super-drafts ready to graduate
  - `GET  /api/users/search?q=…`                  — typeahead for §15.8 mute add

Permission gates: every `/api/admin/*` endpoint requires
`require_admin` (owner or admin); the role-change endpoint additionally
refuses any non-owner caller granting `owner`, since owner-zero is the
only owner bootstrap path per §6.1 and a downgrade from owner needs the
sitting owner's hand. The user-search endpoint is open to any
authenticated viewer — it powers the §15.8 mute typeahead and contains
no privileged information beyond what every authenticated viewer
already sees in the catalog and chat-author surfaces.
"""
from __future__ import annotations

import json
from typing import Any

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

from . import auth, db, email_invite, invites
from .config import Config
from .email import EmailConfig


# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------


class RoleBody(BaseModel):
    role: str = Field(pattern="^(owner|admin|contributor)$")


class MuteBody(BaseModel):
    muted: bool


class PermissionStateBody(BaseModel):
    # v0.9.0: the admin flip from the user-management page (roadmap
    # item #7). `pending` is not surfaceable from the admin UI —
    # only the OTC verify path lands a row in `pending` — but we
    # accept it in the pattern in case a future restore-to-queue
    # gesture wants to re-pend a granted user; today the UI only
    # exposes `granted` and `revoked`.
    state: str = Field(pattern="^(pending|granted|revoked)$")


class AllowlistAddBody(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    note: str | None = Field(default=None, max_length=200)


class CreateUserInviteBody(BaseModel):
    """v0.17.0 / roadmap item #16 — admin-create user + invite email.

    The admin types these fields on the "Create user + invite" modal on
    `/admin/users`. The email + role are required; first/last name and
    the optional custom message round out the body.

    Bounds mirror the rest of the codebase:
      * `email`: 320 chars — RFC 5321 envelope limit, same as
        `OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
      * `first_name` / `last_name`: 120 chars — same as the v0.8.0
        `BetaRequestBody` capture form.
      * `role`: pydantic regex pinned to the §6.1 set so an unknown
        role fails at the body bound (422) instead of landing as a
        CHECK constraint violation in the migration.
      * `custom_message`: 500 chars — the brief calls this out as
        the max. The frontend modal shows a "remaining chars"
        counter to match.
    """
    email: str = Field(min_length=3, max_length=320)
    first_name: str = Field(default="", max_length=120)
    last_name: str = Field(default="", max_length=120)
    role: str = Field(pattern="^(owner|admin|contributor)$")
    custom_message: str = Field(default="", max_length=500)


# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------


def make_router(config: Config) -> APIRouter:
    del config  # unused for now; reserved for future per-deployment knobs
    router = APIRouter()

    # ----- User listing -----

    @router.get("/api/admin/users")
    async def list_users(request: Request) -> dict[str, Any]:
        """v0.9.0: the user-management surface (roadmap item #7).

        The listing carries every column the admin queue needs to triage
        pending beta-access requests alongside the existing role/mute
        affordances. Sort order surfaces pending requests first (so the
        admin lands on the inbox shape), then granted, then revoked;
        within a state, ownership/role and recency are the tiebreakers
        so the legacy ordering (owner first, then admin, then by name)
        is preserved inside the granted bucket.

        `permission_decided_by_login` joins the deciding admin row so
        the UI can render "granted by @ben" without a second round-trip.

        v0.16.0 (roadmap item #12) additive: each user row now carries
        an `rfc_invitations` array — the per-RFC invitations the user
        has accepted. This is the "permission-grant requests from
        invited users" hook the roadmap text calls for: when a user
        accepts a per-RFC invite and they're not yet platform-granted,
        the admin sees "here because @ben invited them to <RFC> as
        <role>" alongside their pending row, informing (not deciding)
        the platform grant. The two write surfaces remain distinct —
        the RFC's owner controls per-RFC roles; the admin controls
        platform-grant state.
        """
        auth.require_admin(request)
        rows = db.conn().execute(
            """
            SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
                   u.created_at, u.last_seen_at,
                   u.permission_state, u.first_name, u.last_name,
                   u.beta_request_reason,
                   u.permission_decided_by, u.permission_decided_at,
                   d.gitea_login AS decided_by_login,
                   d.display_name AS decided_by_display
              FROM users u
         LEFT JOIN users d ON d.id = u.permission_decided_by
             ORDER BY
                CASE u.permission_state
                  WHEN 'pending' THEN 0
                  WHEN 'granted' THEN 1
                  WHEN 'revoked' THEN 2
                  ELSE 3
                END,
                u.role = 'owner' DESC, u.role = 'admin' DESC,
                COALESCE(u.last_seen_at, u.created_at) DESC,
                u.display_name COLLATE NOCASE
            """
        ).fetchall()
        # v0.16.0 — per-user accepted per-RFC invitations. One query
        # over the full set, indexed bucket-by-user-id in Python so
        # the per-row attachment below is O(1). Empty array for users
        # who hold no accepted invitations.
        invitation_rows = db.conn().execute(
            """
            SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
                   r.title AS rfc_title,
                   i.id AS invitation_id, i.invitee_email,
                   ui.gitea_login AS inviter_login,
                   ui.display_name AS inviter_display
              FROM rfc_collaborators c
         LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
         LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
         LEFT JOIN users ui ON ui.id = i.inviter_user_id
             ORDER BY c.created_at DESC
            """
        ).fetchall()
        per_user_invites: dict[int, list[dict]] = {}
        for ir in invitation_rows:
            per_user_invites.setdefault(ir["user_id"], []).append({
                "rfc_slug": ir["rfc_slug"],
                "rfc_title": ir["rfc_title"] or ir["rfc_slug"],
                "role_in_rfc": ir["role_in_rfc"],
                "invited_at": ir["created_at"],
                "invitation_id": ir["invitation_id"],
                "invitee_email": ir["invitee_email"],
                "inviter_login": ir["inviter_login"],
                "inviter_display": ir["inviter_display"],
            })
        # v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
        # is NULL is one of two things — a brand-new row that was just
        # provisioned (rare, and the v0.7.0 OTC verify path stamps
        # last_seen_at on the same call that creates the row), or an
        # admin-created invite-pending row (v0.17.0 — created by
        # `POST /api/admin/users`). We surface a `pending_invite_id`
        # field by joining through `user_invite_tokens` so the
        # Users tab can render a "(pending invite)" badge alongside
        # the role/state controls. Filters to invites that are
        # neither expired nor claimed — once the invitee clicks
        # through, the badge clears (and `last_seen_at` populates).
        pending_invite_rows = db.conn().execute(
            """
            SELECT invited_user_id, id AS invite_id, expires_at
              FROM user_invite_tokens
             WHERE claimed_at IS NULL
               AND datetime(expires_at) > datetime('now')
            """
        ).fetchall()
        pending_invites = {
            r["invited_user_id"]: {
                "invite_id": r["invite_id"],
                "expires_at": r["expires_at"],
            }
            for r in pending_invite_rows
        }
        return {
            "items": [
                {
                    "id": r["id"],
                    "gitea_login": r["gitea_login"],
                    "display_name": r["display_name"],
                    "email": r["email"] or "",
                    "role": r["role"],
                    "muted": bool(r["muted"]),
                    "created_at": r["created_at"],
                    "last_seen_at": r["last_seen_at"],
                    "permission_state": r["permission_state"] or "granted",
                    "first_name": r["first_name"] or "",
                    "last_name": r["last_name"] or "",
                    "beta_request_reason": r["beta_request_reason"] or "",
                    "permission_decided_at": r["permission_decided_at"],
                    "permission_decided_by_login": r["decided_by_login"],
                    "permission_decided_by_display": r["decided_by_display"],
                    # v0.16.0 additive — never null, always an array.
                    "rfc_invitations": per_user_invites.get(r["id"], []),
                    # v0.17.0: present iff the row is invited-but-not-
                    # claimed-yet. The frontend renders a "(pending
                    # invite)" badge when this is non-null.
                    "pending_invite": pending_invites.get(r["id"]),
                }
                for r in rows
            ]
        }

    # ----- Create user + invite (v0.17.0 / roadmap item #16) -----

    @router.post("/api/admin/users")
    async def create_user_with_invite(
        body: CreateUserInviteBody, request: Request,
    ) -> dict[str, Any]:
        """Provision a fresh `users` row with a pre-assigned role + send
        an invite email carrying a claim link.

        Refusals:
          * `422` — the admin tries to invite their own email (no
            self-invite; symmetric to `set_permission`'s self-flip
            refusal and `set_role`'s self-downgrade refusal). Use
            the existing role-change channel for self-edits.
          * `422` — the admin tries to grant `owner` without being
            owner themselves. §6.1: owner-zero is the only owner
            bootstrap path; new owners come from a sitting owner's
            hand. A 422 here matches the message shape; a 403 would
            also be defensible, but staying with 422 keeps the
            "your input is bad" framing.
          * `409` — the email already maps to a `users` row. The
            admin should use the existing role / grant gestures on
            the existing user, not create a duplicate.
          * `422` — pydantic-level: malformed email, role outside
            the §6.1 set, custom_message over 500 chars.

        On success:
          1. The invitee `users` row lands with the chosen role and
             `permission_state='granted'` (admin's hand is the grant)
             and `last_seen_at IS NULL` (the "(pending invite)"
             discriminator the listing surface joins through).
          2. The `user_invite_tokens` row lands with the bcrypt-
             hashed opaque token; the raw token rides only in the
             email link.
          3. The invite email dispatches with subject "You're
             invited to <app> by <admin>" and the custom message
             embedded in a clearly-delimited block if present.
          4. A `permission_events` row records the admin-create
             gesture so the §6.5 / `permissions` admin tab carries
             the audit trail alongside the existing grant/revoke
             flips.
        """
        viewer = auth.require_admin(request)
        email_clean = body.email.strip().lower()
        if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
            raise HTTPException(422, "Email looks malformed")

        # Self-invite refusal. Compare the admin's own email
        # case-insensitively against the invite target.
        viewer_row = db.conn().execute(
            "SELECT email FROM users WHERE id = ?", (viewer.user_id,)
        ).fetchone()
        viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
        if viewer_email and viewer_email == email_clean:
            raise HTTPException(
                422,
                "You cannot invite yourself — use the role-change channel "
                "if you need to edit your own row",
            )

        # Owner-grant refusal: §6.1 says only a sitting owner can mint
        # a new owner. An admin trying to invite-as-owner is refused
        # at 422; the admin should ask the owner to issue the invite,
        # or invite as `admin` and let the owner promote later.
        if body.role == "owner" and viewer.role != "owner":
            raise HTTPException(
                422,
                "Only an owner can invite a new owner — invite as admin and "
                "ask the owner to promote, or have the owner issue this invite",
            )

        # Duplicate-email refusal. A pre-existing row (regardless of
        # permission_state) means the admin should use the existing
        # role / grant gestures, not create a parallel user.
        existing = db.conn().execute(
            "SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
            (email_clean,),
        ).fetchone()
        if existing is not None:
            raise HTTPException(409, "A user with this email already exists")

        # Create the invitee row + token row + send the email.
        outcome = invites.create_invite(
            email=email_clean,
            first_name=body.first_name,
            last_name=body.last_name,
            role=body.role,
            custom_message=body.custom_message,
            created_by_admin_id=viewer.user_id,
        )

        # Audit row in permission_events so the admin Permissions tab
        # carries the gesture. The before-state is "n/a" (the row
        # did not exist); the after-state is the granted role. We
        # use a new `event_kind='user_invited'` so the existing
        # grant/revoke kinds stay scoped to their flip surface.
        db.conn().execute(
            """
            INSERT INTO permission_events
                (actor_user_id, subject_user_id, event_kind, details)
            VALUES (?, ?, 'user_invited', ?)
            """,
            (
                viewer.user_id,
                outcome.invited_user_id,
                json.dumps({
                    "email": email_clean,
                    "role": body.role,
                    "invite_id": outcome.invite_id,
                    "custom_message_chars": len(body.custom_message or ""),
                }),
            ),
        )

        # Build the claim URL using the same APP_URL the email module
        # reads. The token rides as a query-string param to the
        # frontend route `/invites/claim?token=…`; the frontend POSTs
        # it back to `/api/invites/claim` which consumes the row.
        cfg = EmailConfig.from_env()
        from urllib.parse import urlencode
        claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"

        # Fetch the inviter display so the email body can render
        # "Ben Stull (ben@example.com) has invited you to …". We
        # read off the row fresh rather than trusting the session
        # cookie's cached display_name.
        inviter_row = db.conn().execute(
            "SELECT display_name, email FROM users WHERE id = ?",
            (viewer.user_id,),
        ).fetchone()
        inviter_display = (
            (inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
        )
        inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""

        email_invite.send_invite_email(
            to_address=email_clean,
            claim_url=claim_url,
            inviter_display=inviter_display,
            inviter_email=inviter_email_for_body,
            custom_message=body.custom_message,
        )

        return {
            "ok": True,
            "invite_id": outcome.invite_id,
            "invited_user_id": outcome.invited_user_id,
            "email": email_clean,
            "role": body.role,
        }

    @router.get("/api/admin/users/invites")
    async def list_user_invites(request: Request) -> dict[str, Any]:
        """List active (not claimed, not expired) admin-issued invites.

        Powers the admin's "I sent these but they haven't been claimed
        yet" view. The frontend uses this alongside `list_users` —
        the user-listing's `pending_invite` field carries the per-row
        flag; this endpoint carries the full invite shape for a
        dedicated drill-in surface.
        """
        auth.require_admin(request)
        rows = invites.list_pending_invites()
        # Join through to the admin display names so the surface can
        # render "invited by @ben" without a second client call.
        admin_ids = {r.created_by_admin_id for r in rows}
        admin_lookup: dict[int, dict[str, str]] = {}
        if admin_ids:
            placeholders = ",".join("?" * len(admin_ids))
            admin_rows = db.conn().execute(
                f"SELECT id, gitea_login, display_name FROM users "
                f"WHERE id IN ({placeholders})",
                tuple(admin_ids),
            ).fetchall()
            admin_lookup = {
                ar["id"]: {
                    "gitea_login": ar["gitea_login"] or "",
                    "display_name": ar["display_name"] or "",
                }
                for ar in admin_rows
            }
        return {
            "items": [
                {
                    "id": r.id,
                    "email": r.email,
                    "role": r.role,
                    "first_name": r.first_name,
                    "last_name": r.last_name,
                    "custom_message": r.custom_message,
                    "created_at": r.created_at,
                    "expires_at": r.expires_at,
                    "invited_user_id": r.invited_user_id,
                    "created_by_admin_id": r.created_by_admin_id,
                    "created_by_login": admin_lookup.get(
                        r.created_by_admin_id, {}
                    ).get("gitea_login", ""),
                    "created_by_display": admin_lookup.get(
                        r.created_by_admin_id, {}
                    ).get("display_name", ""),
                }
                for r in rows
            ]
        }

    # ----- Role change (§6.1) -----

    @router.post("/api/admin/users/{user_id}/role")
    async def set_role(user_id: int, body: RoleBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_admin(request)
        target = db.conn().execute(
            "SELECT id, role, gitea_login FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        if target is None:
            raise HTTPException(404, "User not found")
        # §6.1: only an owner can grant `owner`, and only an owner can
        # revoke an owner. Admins can flip contributor ↔ admin freely.
        if body.role == "owner" and viewer.role != "owner":
            raise HTTPException(403, "Only an owner can grant the owner role")
        if target["role"] == "owner" and viewer.role != "owner":
            raise HTTPException(403, "Only an owner can change an owner's role")
        # An owner cannot demote themselves — that would orphan owner-zero
        # if they are the only owner, and the role-change UI should not
        # smuggle a "downgrade self" path through this endpoint.
        if target["id"] == viewer.user_id and body.role != viewer.role:
            raise HTTPException(403, "Use the explicit succession path to change your own role")

        before = target["role"]
        if before == body.role:
            return {"ok": True, "role": body.role, "changed": False}

        db.conn().execute(
            "UPDATE users SET role = ? WHERE id = ?", (body.role, user_id),
        )
        db.conn().execute(
            """
            INSERT INTO permission_events
                (actor_user_id, subject_user_id, event_kind, details)
            VALUES (?, ?, 'role_changed', ?)
            """,
            (
                viewer.user_id,
                user_id,
                json.dumps({"before": before, "after": body.role}),
            ),
        )
        return {"ok": True, "role": body.role, "changed": True}

    # ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----

    @router.post("/api/admin/users/{user_id}/permission")
    async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
        """Flip a user's `permission_state` between pending/granted/revoked.

        v0.8.0 wired the column shape but shipped no admin UI for it —
        the grant gesture was a manual `UPDATE users` against the DB.
        v0.9.0 (roadmap item #7) lands the admin user-management page;
        this endpoint is its single write surface.

        Audit shape: every flip writes a `permission_events` row with
        event_kind in {'permission_granted', 'permission_revoked',
        'permission_repended'} so §6.5's log carries the change. The
        `permission_decided_by` / `permission_decided_at` columns on
        the user row are co-stamped so the user listing can render
        "granted by @ben at <date>" without a second join through
        the audit table.

        Refuses with 422 if the admin tries to flip their own row
        (no self-grant / self-revoke; symmetric to set_mute's
        self-mute refusal and set_role's self-downgrade refusal).
        """
        viewer = auth.require_admin(request)
        target = db.conn().execute(
            "SELECT id, role, permission_state FROM users WHERE id = ?",
            (user_id,),
        ).fetchone()
        if target is None:
            raise HTTPException(404, "User not found")
        if target["id"] == viewer.user_id:
            raise HTTPException(422, "You cannot change your own permission state")

        before = target["permission_state"] or "granted"
        after = body.state
        if before == after:
            return {"ok": True, "permission_state": after, "changed": False}

        db.conn().execute(
            """
            UPDATE users
               SET permission_state = ?,
                   permission_decided_by = ?,
                   permission_decided_at = datetime('now')
             WHERE id = ?
            """,
            (after, viewer.user_id, user_id),
        )
        event_kind = {
            "granted": "permission_granted",
            "revoked": "permission_revoked",
            "pending": "permission_repended",
        }[after]
        db.conn().execute(
            """
            INSERT INTO permission_events
                (actor_user_id, subject_user_id, event_kind, details)
            VALUES (?, ?, ?, ?)
            """,
            (
                viewer.user_id,
                user_id,
                event_kind,
                json.dumps({"before": before, "after": after}),
            ),
        )
        return {"ok": True, "permission_state": after, "changed": True}

    # ----- Write-mute (§6.2) -----

    @router.post("/api/admin/users/{user_id}/mute")
    async def set_mute(user_id: int, body: MuteBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_admin(request)
        target = db.conn().execute(
            "SELECT id, role, muted FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        if target is None:
            raise HTTPException(404, "User not found")
        if target["id"] == viewer.user_id:
            raise HTTPException(422, "You cannot write-mute yourself")
        # §6.2 + §6.1: admins/owners are not write-mutable. The write-mute
        # is a contributor-only refusal — an admin's authority is the
        # role-change channel, not a mute.
        if target["role"] in ("owner", "admin"):
            raise HTTPException(
                403,
                "Owners and admins cannot be write-muted — change the role instead",
            )

        before = bool(target["muted"])
        after = bool(body.muted)
        if before == after:
            return {"ok": True, "muted": after, "changed": False}

        db.conn().execute(
            "UPDATE users SET muted = ? WHERE id = ?", (1 if after else 0, user_id),
        )
        db.conn().execute(
            """
            INSERT INTO permission_events
                (actor_user_id, subject_user_id, event_kind, details)
            VALUES (?, ?, ?, ?)
            """,
            (
                viewer.user_id,
                user_id,
                "muted" if after else "restored",
                json.dumps({"before": before, "after": after}),
            ),
        )
        return {"ok": True, "muted": after, "changed": True}

    # ----- Audit log (`actions` + `permission_events`) -----

    @router.get("/api/admin/audit")
    async def list_audit(
        request: Request,
        action_kind: str | None = None,
        actor_user_id: int | None = None,
        rfc_slug: str | None = None,
        limit: int = Query(default=100, ge=1, le=500),
        before_id: int | None = None,
    ) -> dict[str, Any]:
        auth.require_admin(request)
        clauses: list[str] = []
        args: list[Any] = []
        if action_kind:
            clauses.append("a.action_kind = ?")
            args.append(action_kind)
        if actor_user_id is not None:
            clauses.append("a.actor_user_id = ?")
            args.append(actor_user_id)
        if rfc_slug:
            clauses.append("a.rfc_slug = ?")
            args.append(rfc_slug)
        if before_id is not None:
            clauses.append("a.id < ?")
            args.append(before_id)
        where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
        rows = db.conn().execute(
            f"""
            SELECT a.id, a.actor_user_id, a.on_behalf_of, a.action_kind,
                   a.rfc_slug, a.branch_name, a.pr_number, a.bot_commit_sha,
                   a.details, a.created_at,
                   u.gitea_login AS actor_login, u.display_name AS actor_display
              FROM actions a
         LEFT JOIN users u ON u.id = a.actor_user_id
             {where}
             ORDER BY a.id DESC
             LIMIT ?
            """,
            (*args, limit),
        ).fetchall()
        # The distinct-action-kinds list powers the filter chip in
        # `Admin.jsx`; cheap to compute alongside the page since the
        # action_kind set is bounded.
        kinds = [
            r["action_kind"]
            for r in db.conn().execute(
                "SELECT DISTINCT action_kind FROM actions ORDER BY action_kind"
            )
        ]
        return {
            "items": [
                {
                    "id": r["id"],
                    "action_kind": r["action_kind"],
                    "actor_user_id": r["actor_user_id"],
                    "actor_login": r["actor_login"],
                    "actor_display": r["actor_display"],
                    "on_behalf_of": r["on_behalf_of"],
                    "rfc_slug": r["rfc_slug"],
                    "branch_name": r["branch_name"],
                    "pr_number": r["pr_number"],
                    "bot_commit_sha": r["bot_commit_sha"],
                    "details": _safe_json(r["details"]),
                    "created_at": r["created_at"],
                }
                for r in rows
            ],
            "action_kinds": kinds,
            "has_more": len(rows) == limit,
        }

    @router.get("/api/admin/outbound-emails")
    async def list_outbound_emails(
        request: Request,
        kind: str | None = None,
        status: str | None = None,
        to_address: str | None = None,
        limit: int = Query(default=100, ge=1, le=500),
        before_id: int | None = None,
    ) -> dict[str, Any]:
        """v0.18.0 Slice 4: read-only inspection of the
        `outbound_emails` audit table.

        Answers questions like "did this person ever get their
        invite?" without grepping VM logs. Filterable by kind
        ('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
        status ('sent' | 'failed' | 'deferred' | 'bounced'), and
        to_address; the latter is exact-match because the audit
        question is usually "the specific person who said they
        didn't receive it." Per the proposal, no admin UI ships
        with v0.18.0 — operator queries via curl + jq for now.
        """
        auth.require_admin(request)
        clauses: list[str] = []
        args: list[Any] = []
        if kind:
            clauses.append("kind = ?")
            args.append(kind)
        if status:
            clauses.append("status = ?")
            args.append(status)
        if to_address:
            clauses.append("LOWER(to_address) = LOWER(?)")
            args.append(to_address)
        if before_id is not None:
            clauses.append("id < ?")
            args.append(before_id)
        where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
        rows = db.conn().execute(
            f"""
            SELECT id, to_address, from_address, subject, kind, sent_at,
                   status, error, notification_id, message_id
              FROM outbound_emails
             {where}
             ORDER BY id DESC
             LIMIT ?
            """,
            (*args, limit),
        ).fetchall()
        return {
            "items": [
                {
                    "id": r["id"],
                    "to_address": r["to_address"],
                    "from_address": r["from_address"],
                    "subject": r["subject"],
                    "kind": r["kind"],
                    "sent_at": r["sent_at"],
                    "status": r["status"],
                    "error": r["error"],
                    "notification_id": r["notification_id"],
                    "message_id": r["message_id"],
                }
                for r in rows
            ],
            "has_more": len(rows) == limit,
        }

    @router.get("/api/admin/permission-events")
    async def list_permission_events(
        request: Request,
        limit: int = Query(default=100, ge=1, le=500),
        before_id: int | None = None,
    ) -> dict[str, Any]:
        auth.require_admin(request)
        clauses: list[str] = []
        args: list[Any] = []
        if before_id is not None:
            clauses.append("p.id < ?")
            args.append(before_id)
        where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
        rows = db.conn().execute(
            f"""
            SELECT p.id, p.event_kind, p.details, p.created_at,
                   p.actor_user_id, p.subject_user_id,
                   au.gitea_login AS actor_login, au.display_name AS actor_display,
                   su.gitea_login AS subject_login, su.display_name AS subject_display
              FROM permission_events p
         LEFT JOIN users au ON au.id = p.actor_user_id
         LEFT JOIN users su ON su.id = p.subject_user_id
             {where}
             ORDER BY p.id DESC
             LIMIT ?
            """,
            (*args, limit),
        ).fetchall()
        return {
            "items": [
                {
                    "id": r["id"],
                    "event_kind": r["event_kind"],
                    "actor_login": r["actor_login"],
                    "actor_display": r["actor_display"],
                    "subject_login": r["subject_login"],
                    "subject_display": r["subject_display"],
                    "details": _safe_json(r["details"]),
                    "created_at": r["created_at"],
                }
                for r in rows
            ],
            "has_more": len(rows) == limit,
        }

    # ----- Graduation-readiness queue (§13.2) -----

    @router.get("/api/admin/graduation-queue")
    async def graduation_queue(request: Request) -> dict[str, Any]:
        auth.require_admin(request)
        # §13 / §13.2: a super-draft is ready when (a) it has at least one
        # owner claimed via §13.1 and (b) it has zero open meta_body_edit
        # PRs. We compute both with one pass, returning the ready set and
        # the not-yet-ready set so the admin sees what is gating each.
        rows = db.conn().execute(
            """
            SELECT slug, title, owners_json, arbiters_json, tags_json,
                   proposed_at, last_entry_commit_at
              FROM cached_rfcs
             WHERE state = 'super-draft'
             ORDER BY COALESCE(last_entry_commit_at, proposed_at) DESC
            """
        ).fetchall()
        items_ready: list[dict[str, Any]] = []
        items_blocked: list[dict[str, Any]] = []
        for r in rows:
            owners = json.loads(r["owners_json"] or "[]")
            blocking = db.conn().execute(
                """
                SELECT COUNT(*) AS n FROM cached_prs
                 WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
                """,
                (r["slug"],),
            ).fetchone()["n"]
            payload = {
                "slug": r["slug"],
                "title": r["title"],
                "owners": owners,
                "tags": json.loads(r["tags_json"] or "[]"),
                "proposed_at": r["proposed_at"],
                "last_entry_commit_at": r["last_entry_commit_at"],
                "blocking_prs": blocking,
                "owners_set": len(owners) > 0,
            }
            if payload["owners_set"] and blocking == 0:
                items_ready.append(payload)
            else:
                items_blocked.append(payload)
        return {"ready": items_ready, "blocked": items_blocked}

    # ----- §13.7: retired (soft-deleted) entries — site owners only -----

    @router.get("/api/admin/retired-rfcs")
    async def retired_rfcs(request: Request) -> dict[str, Any]:
        # Retired entries are hidden from every browsing surface (§13.7);
        # this owner-gated list is how a site owner discovers them to
        # un-retire. Admins do not get this surface — un-retire authority is
        # site-owner-only, so neither is the list that feeds it.
        viewer = auth.require_admin(request)
        if viewer.role != "owner":
            raise HTTPException(403, "Site owner role required")
        rows = db.conn().execute(
            """
            SELECT slug, title, rfc_id, owners_json, tags_json,
                   proposed_at, updated_at
              FROM cached_rfcs
             WHERE state = 'retired'
             ORDER BY updated_at DESC
            """
        ).fetchall()
        items = []
        for r in rows:
            # The state it would return to on un-retire, mirroring
            # api_graduation._prior_state_before_retire's audit lookup.
            prior = db.conn().execute(
                """
                SELECT details FROM actions
                 WHERE rfc_slug = ? AND action_kind = 'retire'
                 ORDER BY id DESC LIMIT 1
                """,
                (r["slug"],),
            ).fetchone()
            restored_state = None
            if prior and prior["details"]:
                try:
                    restored_state = json.loads(prior["details"]).get("prior_state")
                except (ValueError, TypeError):
                    restored_state = None
            if restored_state not in ("super-draft", "active"):
                restored_state = "active" if r["rfc_id"] else "super-draft"
            items.append({
                "slug": r["slug"],
                "title": r["title"],
                "id": r["rfc_id"],
                "owners": json.loads(r["owners_json"] or "[]"),
                "tags": json.loads(r["tags_json"] or "[]"),
                "retired_at": r["updated_at"],
                "restores_to": restored_state,
            })
        return {"items": items}

    # ----- User search (typeahead for §15.8 mute add) -----

    @router.get("/api/users/search")
    async def search_users(
        request: Request, q: str = Query(default="", min_length=0, max_length=80),
    ) -> dict[str, Any]:
        viewer = auth.require_user(request)
        needle = q.strip().lower()
        # An empty query returns recent users; a non-empty query matches
        # against gitea_login and display_name with a prefix-first
        # ranking so the typeahead surfaces obvious matches early.
        if not needle:
            rows = db.conn().execute(
                """
                SELECT id, gitea_login, display_name, role
                  FROM users WHERE id != ?
                 ORDER BY last_seen_at DESC LIMIT 10
                """,
                (viewer.user_id,),
            ).fetchall()
        else:
            like = f"%{needle}%"
            prefix = f"{needle}%"
            rows = db.conn().execute(
                """
                SELECT id, gitea_login, display_name, role
                  FROM users
                 WHERE id != ?
                   AND (LOWER(gitea_login) LIKE ? OR LOWER(display_name) LIKE ?)
                 ORDER BY
                    CASE WHEN LOWER(gitea_login) LIKE ? THEN 0 ELSE 1 END,
                    LOWER(gitea_login)
                 LIMIT 10
                """,
                (viewer.user_id, like, like, prefix),
            ).fetchall()
        return {
            "items": [
                {
                    "id": r["id"],
                    "gitea_login": r["gitea_login"],
                    "display_name": r["display_name"],
                    "role": r["role"],
                }
                for r in rows
            ]
        }

    # ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----

    @router.get("/api/admin/allowlist")
    async def list_allowlist(request: Request) -> dict[str, Any]:
        auth.require_admin(request)
        rows = db.conn().execute(
            """
            SELECT a.email, a.note, a.created_at,
                   u.gitea_login AS added_by_login,
                   u.display_name AS added_by_display
              FROM allowed_emails a
         LEFT JOIN users u ON u.id = a.added_by_user_id
             ORDER BY a.created_at DESC
            """
        ).fetchall()
        return {
            "active": len(rows) > 0,
            "items": [
                {
                    "email": r["email"],
                    "note": r["note"] or "",
                    "added_by_login": r["added_by_login"],
                    "added_by_display": r["added_by_display"],
                    "created_at": r["created_at"],
                }
                for r in rows
            ],
        }

    @router.post("/api/admin/allowlist")
    async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
        viewer = auth.require_admin(request)
        email = body.email.strip()
        if "@" not in email or len(email.split("@")[-1]) < 2:
            raise HTTPException(422, "Email looks malformed")
        existing = db.conn().execute(
            "SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
        ).fetchone()
        if existing is not None:
            raise HTTPException(409, "Email already on the allowlist")
        db.conn().execute(
            """
            INSERT INTO allowed_emails (email, added_by_user_id, note)
            VALUES (?, ?, ?)
            """,
            (email, viewer.user_id, body.note),
        )
        # Audit trail: when the email already maps to a known user, emit a
        # permission_events row so §6.5's log stays the single place to
        # look for "who let this person in." For brand-new emails the
        # allowed_emails row itself carries (added_by_user_id, created_at)
        # which is sufficient until the user actually signs in.
        subject = db.conn().execute(
            "SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
        ).fetchone()
        if subject is not None:
            db.conn().execute(
                """
                INSERT INTO permission_events
                    (actor_user_id, subject_user_id, event_kind, details)
                VALUES (?, ?, 'allowlist_added', ?)
                """,
                (
                    viewer.user_id,
                    subject["id"],
                    json.dumps({"email": email, "note": body.note or ""}),
                ),
            )
        return {"ok": True, "email": email}

    @router.delete("/api/admin/allowlist/{email}")
    async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
        viewer = auth.require_admin(request)
        existing = db.conn().execute(
            "SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
        ).fetchone()
        if existing is None:
            raise HTTPException(404, "Email not on the allowlist")
        db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
        subject = db.conn().execute(
            "SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
        ).fetchone()
        if subject is not None:
            db.conn().execute(
                """
                INSERT INTO permission_events
                    (actor_user_id, subject_user_id, event_kind, details)
                VALUES (?, ?, 'allowlist_removed', ?)
                """,
                (
                    viewer.user_id,
                    subject["id"],
                    json.dumps({"email": email}),
                ),
            )
        return {"ok": True, "email": email}

    return router


def _safe_json(blob: str | None) -> Any:
    if not blob:
        return None
    try:
        return json.loads(blob)
    except Exception:
        return blob
