"""FastAPI entrypoint.

Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
the background reconciler. Per §4.2, single process, colocated SQLite —
no need for a separate worker.
"""
from __future__ import annotations

import logging
import os
import secrets
from contextlib import asynccontextmanager

from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware

from . import (
    api as api_routes,
    auth,
    cache,
    db,
    device_trust as device_trust_mod,
    digest,
    email_otc,
    hygiene,
    invites as invites_mod,
    otc,
    passcode as passcode_mod,
    projects,
    providers as providers_mod,
    ratelimit,
    registry as registry_mod,
    turnstile,
    webhooks,
)
from .bot import Bot
from .config import load_config
from .gitea import Gitea

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("rfc_app")


class OtcRequestBody(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    # v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
    # frontend widget. Optional in the body so a deployment that has
    # not yet wired the Turnstile site key (or a dev environment with
    # the widget intentionally skipped) still routes through the same
    # endpoint; the backend turnstile.verify_token call decides whether
    # to admit the request based on `TURNSTILE_REQUIRED` + presence of
    # the secret.
    turnstile_token: str | None = Field(default=None, max_length=4096)


class OtcVerifyBody(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    code: str = Field(min_length=1, max_length=16)
    # v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
    # OTC step. When true and verify succeeds, the server issues a fresh
    # device-trust row and sets the `rfc_device_trust` cookie on the
    # response. Defaults to false so existing clients that don't send
    # the flag continue to behave the way they did pre-v0.11.0.
    trust_device: bool = False


class TestLoginBody(BaseModel):
    # The single configured test identity to sign in as. Must equal
    # E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused —
    # see `/auth/test/login`.
    email: str = Field(min_length=3, max_length=320)


class PasscodeSetBody(BaseModel):
    passcode: str = Field(min_length=1, max_length=64)


class PasscodeVerifyBody(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    passcode: str = Field(min_length=1, max_length=64)
    # v0.11.0 — same trust-device opt-in as the OTC verify body.
    trust_device: bool = False


class InviteClaimBody(BaseModel):
    """v0.17.0 / roadmap item #16 — claim an admin-issued invite token.

    The frontend `/invites/claim?token=…` page reads the token from
    the URL and POSTs it here. The body bound matches the
    `secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
    the upper bound stays generous in case `TOKEN_BYTES` is ever
    raised. The token-shape is opaque to this layer — `invites.claim`
    bcrypt-checks it against the active candidate set.
    """
    token: str = Field(min_length=1, max_length=512)
    # v0.11.0-style opt-in: the claim flow's "trust this device" gesture
    # is bundled here so the invitee can land trusted on first sign-in
    # without an extra roundtrip. Defaults to false so the gesture is
    # explicit (the frontend modal renders a checkbox alongside the
    # claim CTA).
    trust_device: bool = False


@asynccontextmanager
async def lifespan(app: FastAPI):
    config = load_config()
    db.run_migrations(config)
    db.init(config)
    # v0.52.0: shout if the deployed-env E2E test-auth shortcut is live.
    # It mints owner sessions for one configured identity (see
    # `/auth/test/login`); it must only ever be on for a pre-prod (PPE)
    # host. A loud startup line means an accidental prod enablement is
    # visible in the logs rather than silent.
    if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get(
        "E2E_TEST_AUTH_EMAIL", ""
    ).strip():
        log.warning(
            "E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner "
            "session for %s. This must NEVER be set on production.",
            os.environ["E2E_TEST_AUTH_EMAIL"].strip(),
        )
    gitea = Gitea(config)
    # §22 framework heal: reconcile a divergent default-project collection id
    # (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
    # but the mirror expects 'default') BEFORE the mirror runs, so the mirror
    # merges onto the canonical 'default' collection instead of duplicating it.
    # Idempotent no-op on fresh / single-project / already-aligned deployments.
    projects.reconcile_default_collection_id(config)
    # §22.2: mirror the registry before anything reads projects/content_repo.
    # First boot has no last-good rows, so a missing/invalid registry is fatal
    # (loud-fail per separation-of-concerns); the reconciler sweep keeps it
    # fresh thereafter and tolerates a later bad PR.
    try:
        await registry_mod.refresh_registry(config, gitea, prune=True)
    except Exception as e:
        raise RuntimeError(
            f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
        ) from e
    # §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
    # deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
    # exists, so the original corpus lands at a meaningful /p/<id>/ and
    # 'default' is never a public URL. Idempotent no-op once done.
    projects.restamp_default_project(config)
    if projects.default_content_repo(config) is None:
        raise RuntimeError(
            f"registry does not describe the default project "
            f"{projects.resolved_default_id(config)!r} (no content_repo). "
            f"Add it to {config.registry_repo_full}/projects.yaml."
        )
    bot = Bot(gitea)
    reconciler = cache.Reconciler(config, gitea)
    digest_sched = digest.DigestScheduler()
    hygiene_sched = hygiene.HygieneScheduler(config=config, bot=bot)

    # §18 carryover: the multi-provider LLM abstraction. Provider
    # construction can fail (missing key, wrong env value) — if it does,
    # the rest of the app still serves; chat endpoints surface a clear
    # 503 instead of crashing the process.
    try:
        providers = providers_mod.load_from_config(config)
    except Exception:
        log.exception("provider construction failed; chat will be disabled")
        providers = {}

    app.state.config = config
    app.state.gitea = gitea
    app.state.bot = bot
    app.state.reconciler = reconciler
    app.state.providers = providers

    app.include_router(_oauth_router(config))
    app.include_router(api_routes.make_router(config, gitea, bot, providers))
    app.include_router(webhooks.make_router(config, gitea))

    reconciler.start()
    digest_sched.start()
    hygiene_sched.start()
    log.info("RFC app started — registry %s", config.registry_repo_full)
    try:
        yield
    finally:
        await hygiene_sched.stop()
        await digest_sched.stop()
        await reconciler.stop()
        await gitea.close()


def create_app() -> FastAPI:
    # The secret key is required at app construction (SessionMiddleware
    # is added before lifespan runs), so we read just that one value
    # eagerly via load_config(). Everything else waits for lifespan.
    config = load_config()
    app = FastAPI(lifespan=lifespan)
    # v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
    # auth credential and must carry `Secure` in production so it never
    # travels cleartext. Default to Secure; a dev box serving over plain
    # http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
    # HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
    session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
        "0", "false", "no", "off",
    )
    app.add_middleware(
        SessionMiddleware,
        secret_key=config.secret_key,
        session_cookie="rfc_session",
        max_age=60 * 60 * 24 * 30,
        https_only=session_secure,
    )
    return app


app = create_app()


def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
    """Attach the v0.11.0 device-trust cookie to the response.

    HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
    v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value` —
    "<row_id>.<raw_token>" — so `device_trust.lookup` can read one indexed
    row instead of scanning; server-side storage remains the bcrypt hash
    of the token half only. The cookie is "essential" per the v0.13.0
    cookie-consent contract (it is part of authentication), so we set it
    regardless of the user's analytics / other-cookies choice.

    Secure=True means the cookie is only ever sent over HTTPS — the
    device-trust cookie holds a 30-day credential and must never travel
    cleartext. (The session cookie now also defaults to Secure; see M4 in
    `create_app`.)
    """
    response.set_cookie(
        key=device_trust_mod.COOKIE_NAME,
        value=cookie_value,
        max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
        path="/",
        secure=True,
        httponly=True,
        samesite="lax",
    )


def _clear_device_trust_cookie(response: Response) -> None:
    """Delete the device-trust cookie on the response.

    Used when the framework detects a presented cookie that is
    expired, revoked, or otherwise stale — the next request from
    this device will not carry a dead token.
    """
    response.delete_cookie(
        key=device_trust_mod.COOKIE_NAME,
        path="/",
        secure=True,
        httponly=True,
        samesite="lax",
    )


def _oauth_router(config) -> APIRouter:
    router = APIRouter()

    @router.get("/auth/login")
    async def login(request: Request):
        state = auth.new_state()
        request.session[auth.SESSION_STATE_KEY] = state
        return RedirectResponse(auth.authorization_url(config, state))

    @router.get("/auth/callback")
    async def callback(request: Request, code: str = "", state: str = ""):
        if not code:
            raise HTTPException(400, "Missing code")
        stored_state = request.session.get(auth.SESSION_STATE_KEY)
        if not stored_state or not secrets.compare_digest(stored_state, state):
            raise HTTPException(400, "Invalid state")
        token_data = await auth.exchange_code(config, code)
        access_token = token_data.get("access_token")
        if not access_token:
            raise HTTPException(400, "Token exchange failed")
        profile = await auth.fetch_user_profile(config, access_token)
        if not auth.is_allowed_sign_in(profile):
            # Private-beta gate: clear any partial OAuth state and bounce to
            # the public /beta-pending page. The session is left empty so the
            # rejected viewer continues as anonymous read-only.
            request.session.pop(auth.SESSION_STATE_KEY, None)
            return RedirectResponse("/beta-pending")
        user = auth.provision_user(config, profile)
        auth.store_session(request, user)
        return RedirectResponse("/")

    @router.get("/auth/logout")
    async def logout(request: Request):
        request.session.clear()
        return RedirectResponse("/")

    # ---------------------------------------------------------------
    # v0.7.0: email + one-time-code sign-in (§6.2).
    #
    # Replaces the OAuth gesture as the primary human-auth path. The
    # /auth/callback handler above remains functional as a fallback;
    # the new UI no longer surfaces it. A future release retires the
    # OAuth path entirely once every active user has signed in at
    # least once via OTC.
    # ---------------------------------------------------------------

    @router.post("/auth/otc/request")
    async def otc_request(body: OtcRequestBody, request: Request):
        # v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
        # before the Turnstile network call or any bcrypt/SMTP work.
        if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
            raise HTTPException(429, "Too many requests; please wait a few minutes")
        # v0.12.0 / roadmap item #10: gate the request on a successful
        # Turnstile siteverify before the bcrypt hash + SMTP send. The
        # check runs first so a failed challenge spends no rate budget
        # and produces no envelope. When the operator has not wired the
        # secret AND TURNSTILE_REQUIRED=false (the default), the gate
        # opens — see `backend/app/turnstile.py` for the full matrix.
        client_ip = request.client.host if request.client else None
        ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
        if not ts.ok:
            if ts.reason == "misconfigured":
                # TURNSTILE_REQUIRED=true but the secret is unset. This
                # is an operator/config problem, not a client problem;
                # surface as 500 so the operator notices in their logs
                # rather than blaming the user's browser.
                raise HTTPException(500, "auth misconfigured")
            # missing-token / failed / network → uniform 400 so the
            # response does not enumerate which leg of the challenge
            # broke. The reason is in the server logs.
            raise HTTPException(400, "verification failed")

        outcome = otc.request_code(body.email)
        if outcome.reason == "cooldown":
            # Loud failure per the rate-limit primitive — the abuse
            # surface should be visible to clients hammering /request.
            raise HTTPException(429, "Wait before requesting another code")
        if outcome.sent and outcome.code is not None:
            email_otc.send_otc_email(body.email.strip(), outcome.code)
        # 202 regardless of allowlist/invalid — don't leak which
        # emails are recognized.
        return {"ok": True}

    @router.post("/auth/otc/verify")
    async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
        # v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
        # plus the per-email lockout enforced inside otc.verify_code.
        ip = ratelimit.client_key(request)
        if not ratelimit.verify_limiter.allow(ip):
            raise HTTPException(429, "Too many attempts; please wait a few minutes")
        result = otc.verify_code(body.email, body.code)
        if result.reason == "locked":
            raise HTTPException(
                423,
                {
                    "detail": "Too many failed attempts; wait a few minutes or request a new code",
                    "locked_until": result.locked_until,
                },
            )
        if not result.ok or result.user is None:
            raise HTTPException(400, "Invalid or expired code")
        # Legit sign-in: clear this IP's window so a user who fat-fingered
        # a couple of codes isn't left throttled.
        ratelimit.verify_limiter.reset(ip)
        auth.store_session(request, result.user)
        # v0.8.0: surface `needs_profile` so the Login.jsx surface can
        # decide whether to advance to the first/last/why capture step
        # or jump straight to "/". `needs_profile=true` iff the user
        # is `permission_state='pending'` AND the row has no profile
        # fields yet — a fresh OTC user. Grandfathered users
        # (`permission_state='granted'`) and pending users who already
        # captured their fields both read as false.
        row = db.conn().execute(
            "SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
            (result.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 = (
            result.user.permission_state == "pending"
            and not first_name
            and not last_name
            and not beta_request_reason
        )
        # v0.11.0 — opt-in device trust. The checkbox lives on the
        # Login.jsx OTC step; when true, the server mints a fresh
        # device-trust row and sets the long-lived cookie. The cookie
        # is "essential" per the v0.13.0 consent contract (it is part
        # of authentication, not analytics) so it lands regardless of
        # the user's analytics / other-cookies choice. We capture the
        # User-Agent at issuance so the /settings/devices surface can
        # render a rough device label.
        if body.trust_device:
            ua = request.headers.get("user-agent", "")
            outcome = device_trust_mod.issue(result.user.user_id, ua)
            _set_device_trust_cookie(response, outcome.cookie_value)
        return {
            "ok": True,
            "user": {
                "id": result.user.user_id,
                "display_name": result.user.display_name,
                "email": result.user.email,
                "role": result.user.role,
                "permission_state": result.user.permission_state,
            },
            "needs_profile": needs_profile,
        }

    # ---------------------------------------------------------------
    # v0.52.0: deployed-environment E2E test-auth shortcut.
    #
    # Running the Playwright E2E suite against a *deployed* environment
    # (PPE) is the §9 pre-prod gate. But the deployed env has neither of
    # the two scaffolds the Tier-1 docker stack relies on for auth: a
    # Mailpit sink to read the OTC code from, and direct SQLite access to
    # inject a granted-owner row. This endpoint replaces both with a
    # single gated gesture: it mints an authenticated OWNER session for
    # one pre-configured throwaway identity.
    #
    # It is FAIL-CLOSED and must never function in production:
    #   * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`
    #     are set — a prod deployment that sets neither cannot be coaxed
    #     into minting a session, and the route is invisible.
    #   * The caller must present the shared secret in `X-Test-Auth-Secret`
    #     (constant-time compare); a wrong/absent secret 404s (the route
    #     does not advertise itself to an unauthenticated caller).
    #   * Only the one configured email may be minted; any other address
    #     is refused (403). So an enabled PPE exposes exactly one
    #     throwaway owner identity, with the secret as the trust boundary.
    #
    # The hard secrets rule (§6.3) holds: the secret is a Secret Manager
    # ref injected as env on the VM (never a literal in the repo), and the
    # E2E runner presents it from SM at runtime (never echoed).
    @router.post("/auth/test/login")
    async def test_login(body: TestLoginBody, request: Request):
        secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip()
        configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip()
        # Feature off (the default, incl. production): route is invisible.
        if not secret or not configured_email:
            raise HTTPException(404, "Not Found")
        presented = request.headers.get("x-test-auth-secret", "")
        if not secrets.compare_digest(presented, secret):
            # Don't reveal that the route exists to a caller without the
            # secret — mirror the "off" shape exactly.
            raise HTTPException(404, "Not Found")
        if body.email.strip().lower() != configured_email.lower():
            raise HTTPException(403, "email not permitted")

        # Provision-or-link the row, then force it to a granted owner so
        # the metadata write paths (SLICE-4/5) accept it — the deployed
        # equivalent of the Tier-1 docker-compose backend-seed owner row.
        user = otc.provision_or_link_user(body.email)
        db.conn().execute(
            "UPDATE users SET role = 'owner', permission_state = 'granted', "
            "last_seen_at = datetime('now') WHERE id = ?",
            (user.user_id,),
        )
        db.conn().commit()
        user.role = "owner"
        user.permission_state = "granted"
        auth.store_session(request, user)
        return {"ok": True}

    # ---------------------------------------------------------------
    # v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
    #
    # After a successful OTC sign-in, a contributor may set a passcode
    # and use email + passcode for subsequent sign-ins. OTC remains the
    # forgot-passcode fallback — a verify failure beyond 5 consecutive
    # attempts locks the passcode path for 15 minutes; the OTC path is
    # unaffected by the lockout.
    # ---------------------------------------------------------------

    @router.get("/auth/passcode/check")
    async def passcode_check(request: Request, email: str = ""):
        """Does this email have a passcode set? Anonymous endpoint —
        the Login.jsx flow calls this after the user types their email
        to decide whether to render a passcode input or fall back to
        OTC. We surface only the boolean; lockout state, the hash, and
        the set-at stamp are not leaked here.

        v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
        boolean can't be bulk-harvested to enumerate accounts."""
        if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
            raise HTTPException(429, "Too many requests; please wait a few minutes")
        status = passcode_mod.passcode_status(email)
        return {"has_passcode": status.has_passcode}

    @router.post("/auth/passcode/set")
    async def passcode_set(body: PasscodeSetBody, request: Request):
        """Set or replace the signed-in user's passcode. Requires an
        active session (OTC- or passcode-authenticated)."""
        user = auth.require_user(request)
        try:
            passcode_mod.set_passcode(user.user_id, body.passcode)
        except passcode_mod.PasscodeValidationError as e:
            raise HTTPException(422, str(e))
        return {"ok": True}

    @router.delete("/auth/passcode")
    async def passcode_delete(request: Request):
        """Remove the signed-in user's passcode. The user is back to
        OTC-only on next sign-in."""
        user = auth.require_user(request)
        passcode_mod.clear_passcode(user.user_id)
        return {"ok": True}

    @router.post("/auth/passcode/verify")
    async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
        """Sign in with email + passcode. Returns the standard session
        payload on success; HTTP 423 with `locked_until` when the
        account is in the lockout window; HTTP 400 for every other
        failure (the wrong-vs-unknown distinction is intentionally
        collapsed so a probing client cannot enumerate emails).

        v0.11.0: the body's `trust_device` flag, if true, mints a
        fresh device-trust row and sets the long-lived cookie. Same
        opt-in contract as `/auth/otc/verify`."""
        # v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
        # passcode lockout, so fan-out across emails is throttled too.
        ip = ratelimit.client_key(request)
        if not ratelimit.verify_limiter.allow(ip):
            raise HTTPException(429, "Too many attempts; please wait a few minutes")
        result = passcode_mod.verify_passcode(body.email, body.passcode)
        if result.reason == "locked":
            raise HTTPException(
                423,
                {
                    "detail": "Too many failed attempts; sign in with a one-time code instead",
                    "locked_until": result.locked_until,
                },
            )
        if not result.ok or result.user is None:
            raise HTTPException(400, "Invalid passcode")
        ratelimit.verify_limiter.reset(ip)
        auth.store_session(request, result.user)
        if body.trust_device:
            ua = request.headers.get("user-agent", "")
            outcome = device_trust_mod.issue(result.user.user_id, ua)
            _set_device_trust_cookie(response, outcome.cookie_value)
        return {
            "ok": True,
            "user": {
                "id": result.user.user_id,
                "display_name": result.user.display_name,
                "email": result.user.email,
                "role": result.user.role,
            },
        }

    # ---------------------------------------------------------------
    # v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
    #
    # The admin-create surface lives at POST /api/admin/users (see
    # api_admin.py); this endpoint is the corresponding claim path the
    # invitee hits when they click the link in their invite email.
    # The frontend route `/invites/claim?token=…` reads the token from
    # the URL and POSTs it here.
    #
    # The claim itself is the first-sign-in for the invitee: clicking
    # the unique token in the email is proof of email control per the
    # roadmap, so this endpoint skips the OTC step entirely on first
    # sign-in. The session cookie lands; the response tells the
    # frontend whether to route to passcode-set (if v0.10.0 passcode
    # flow is in play and the user has not yet set a passcode) or to
    # home.
    #
    # The endpoint is anonymous-reachable: the entire point is to
    # establish the session, so we do not gate it on `require_user`.
    # The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
    # contract (the body's `trust_device` flag, when true, mints a
    # fresh device-trust row on the same response so the invitee
    # lands trusted on their first device).
    # ---------------------------------------------------------------

    @router.post("/api/invites/claim")
    async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
        result = invites_mod.claim(body.token)
        if result.reason == "expired":
            # The token's TTL window passed without a claim. HTTP 410
            # (Gone) so the frontend can render a "this invite has
            # expired — please contact the admin for a fresh one"
            # message distinct from the generic invalid-token shape.
            raise HTTPException(410, "This invite has expired")
        if result.reason == "claimed":
            # The token was already consumed. HTTP 410 for the same
            # reason — the row is dead either way.
            raise HTTPException(410, "This invite has already been claimed")
        if not result.ok or result.user is None:
            # 'unknown' / 'invalid' — the token does not match any
            # active invite row. HTTP 400 so it reads distinct from
            # the dead-token shape above.
            raise HTTPException(400, "Invalid invite token")

        # Establish the session. From here on the invitee is signed
        # in as the pre-provisioned user row carrying their
        # pre-assigned role.
        auth.store_session(request, result.user)

        # v0.11.0 — opt-in device trust on the claim response. Same
        # contract as OTC/passcode verify: when the body's flag is
        # true, the server mints a fresh device-trust row and sets
        # the long-lived cookie, so the invitee skips the email step
        # on subsequent visits to the same browser.
        if body.trust_device:
            ua = request.headers.get("user-agent", "")
            outcome = device_trust_mod.issue(result.user.user_id, ua)
            _set_device_trust_cookie(response, outcome.cookie_value)

        # Has the user already set a passcode? (Could only happen via
        # an admin pre-population path that doesn't exist yet, but
        # the response shape mirrors `/api/auth/me` so the frontend
        # can read it without a second call.) If `needs_passcode` is
        # true and v0.10.0 passcode flow is in play, the frontend
        # routes to /settings/notifications#sign-in to set a passcode
        # immediately; otherwise it routes to /.
        row = db.conn().execute(
            "SELECT passcode_hash FROM users WHERE id = ?",
            (result.user.user_id,),
        ).fetchone()
        has_passcode = bool(row and row["passcode_hash"])

        return {
            "ok": True,
            "user": {
                "id": result.user.user_id,
                "display_name": result.user.display_name,
                "email": result.user.email,
                "role": result.user.role,
                "permission_state": result.user.permission_state,
            },
            # Roadmap §16: the claim flow skips OTC entirely; the
            # natural next step is passcode-set (so the invitee can
            # sign back in without needing an email roundtrip on their
            # second visit). The frontend uses this hint to decide
            # whether to route to the passcode-set screen or to home.
            "needs_passcode": not has_passcode,
        }

    # ---------------------------------------------------------------
    # v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
    #
    # The /auth/device-trust/start endpoint resolves a presented
    # `rfc_device_trust` cookie. If it matches a non-expired,
    # non-revoked row, the session is re-established and the client
    # is told to skip OTC/passcode entry. A stale cookie (expired or
    # revoked) is cleared on the response. A miss is structurally
    # silent — the client falls back to the email step.
    #
    # The endpoint is anonymous-reachable: a returning visitor with
    # the cookie hits this before the email step. We do not gate it
    # on a session because the entire point is to establish one.
    # ---------------------------------------------------------------

    @router.post("/auth/device-trust/start")
    async def device_trust_start(request: Request):
        """Sign in via a presented device-trust cookie.

        On a hit, re-establishes the session in the cookie store and
        returns a user payload shaped like /auth/otc/verify (minus
        `needs_profile`, which a returning device-trust user is
        structurally past — they signed in at least once before).
        On a miss, returns 401 + clears the stale cookie. An
        'unknown' miss (cookie present but no row matches) also
        clears, since the token is dead to the server either way.

        Note on response construction: we return a `JSONResponse`
        directly rather than raising `HTTPException` on the miss
        path because FastAPI's exception handler builds a new
        response from scratch and would drop any `set_cookie` /
        `delete_cookie` calls. The hand-built `JSONResponse` lets
        us attach the cookie-clear header alongside the 401.
        """
        raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
        if not raw:
            return JSONResponse({"detail": "No device trust"}, status_code=401)
        outcome = device_trust_mod.lookup(raw)
        if not outcome.ok or outcome.user is None:
            # Clear the stale cookie so subsequent requests don't
            # keep replaying a dead token. We surface 401 in all
            # cases so a probing client can't tell "your row was
            # revoked" from "this token never existed".
            response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
            _clear_device_trust_cookie(response)
            return response
        auth.store_session(request, outcome.user)
        return {
            "ok": True,
            "user": {
                "id": outcome.user.user_id,
                "display_name": outcome.user.display_name,
                "email": outcome.user.email,
                "role": outcome.user.role,
                "permission_state": outcome.user.permission_state,
            },
        }

    return router
