"""Auth flow routes + /api/me.

Route table is deliberately minimal (T-AZ-042): the app defines ONLY
/auth/login, /auth/callback, /auth/logout — never /login, /logout,
/authorize, or /_grant at the root (those are id-auth's, and a gate
snippet reverse-proxies them; owning them here would collide, see
PATTERN-B.md §1b).

Flow (account-mode app, full code+PKCE — ADR #002):
    unauthenticated page hit
      -> 302 /auth/login?next=...           (middleware)
      -> 302 auth.bowden.works/app-authorize (PKCE challenge + state;
             verifier/state parked in short-lived HttpOnly cookies)
      -> 302 back to /auth/callback?code&state
      -> state checked, code exchanged server-to-server, id_token
         signature+audience+expiry VERIFIED (bw_auth.finish_login),
         users row auto-provisioned, app session cookie minted
      -> 302 to the original destination.
"""

import hmac
import logging

from fastapi import APIRouter, Query, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.responses import JSONResponse, RedirectResponse, Response

from app import db
from app.models import Party, Tenant, User
from app.routers.deps import ActorDep, SessionDep
from app.services.auth import bw_auth
from app.services.auth import session as auth_session
from app.services.auth.provision import provision_user
from app.services.authz.context import Actor
from app.services.authz.view_as import VIEW_AS_COOKIE

log = logging.getLogger("with.auth")

router = APIRouter()

FLOW_COOKIE_MAX_AGE = 600  # bw_pkce / bw_state / with_next live 10 minutes


def _safe_next(raw: str | None) -> str:
    """Only same-origin absolute paths — never a scheme, host, or
    protocol-relative '//host' (open-redirect guard)."""
    if raw and raw.startswith("/") and not raw.startswith("//") and "\\" not in raw:
        return raw
    return "/"


def _set_flow_cookie(resp: Response, name: str, value: str, max_age: int) -> None:
    resp.set_cookie(
        name, value, max_age=max_age, httponly=True, secure=True, samesite="lax", path="/"
    )


def _auth_error(status: int, code: str, summary: str, detail: str) -> JSONResponse:
    return JSONResponse(
        status_code=status,
        content={"error_code": code, "summary": summary, "detail": detail},
    )


@router.get("/auth/login", include_in_schema=False)
def auth_login(next_url: str = Query("/", alias="next")) -> Response:
    """Start (or silently re-establish) a login: park PKCE verifier +
    state in short-lived HttpOnly cookies and bounce the browser to
    id-auth's /app-authorize. A visitor with a live BW SSO session
    round-trips without seeing a form."""
    pending: dict[str, tuple[str, int]] = {}

    def set_cookie(name: str, value: str, max_age: int) -> None:
        pending[name] = (value, max_age)

    url, _state = bw_auth.start_login(set_cookie, cookie_max_age=FLOW_COOKIE_MAX_AGE)
    resp = RedirectResponse(url=url, status_code=302)
    for name, (value, max_age) in pending.items():
        _set_flow_cookie(resp, name, value, max_age)
    destination = _safe_next(next_url)
    if destination != "/":
        _set_flow_cookie(resp, auth_session.NEXT_COOKIE, destination, FLOW_COOKIE_MAX_AGE)
    return resp


@router.get("/auth/callback", include_in_schema=False)
def auth_callback(request: Request) -> Response:
    """id-auth redirects back here. Verify state (anti-CSRF), redeem the
    code with the parked PKCE verifier, verify the id_token (signature,
    audience, expiry — T-AZ-040), provision the user, mint the session."""
    params = request.query_params

    if params.get("error"):
        # This app never sends prompt=none, so any error here is a failed
        # interactive attempt (e.g. cancelled login) — terminal, no session.
        log.warning("auth callback returned error=%r", params.get("error"))
        return _auth_error(
            401,
            "SIGN_IN_FAILED",
            "Sign-in was not completed",
            f"id-auth returned error={params.get('error')}. Retry at /auth/login.",
        )

    code = params.get("code", "")
    state = params.get("state", "")
    expected_state = request.cookies.get(bw_auth.STATE_COOKIE, "")
    verifier = request.cookies.get(bw_auth.PKCE_COOKIE, "")

    if not code or not state or not expected_state or not hmac.compare_digest(
        state, expected_state
    ):
        return _auth_error(
            400,
            "AUTH_STATE_MISMATCH",
            "Sign-in state check failed",
            "Missing or mismatched anti-CSRF state. Restart the flow at /auth/login.",
        )
    if not verifier:
        return _auth_error(
            400,
            "AUTH_PKCE_MISSING",
            "Sign-in session expired",
            "The PKCE verifier cookie is gone (flow older than 10 minutes?). "
            "Restart at /auth/login.",
        )

    try:
        # exchange_code + verify_id_token; claims are the VERIFIED identity.
        claims = bw_auth.finish_login(code, verifier)
    except bw_auth.BWAuthError as e:
        log.warning("BW sign-in failed: %s", e)
        return _auth_error(
            401,
            "SIGN_IN_FAILED",
            "Sign-in failed",
            "The authorization code or id_token did not verify. Retry at /auth/login.",
        )

    with Session(db.get_engine()) as db_session:
        user = provision_user(db_session, claims)
        token = auth_session.mint_session(
            username=user.idauth_username,
            email=user.email,
            is_super_admin=user.is_super_admin,
        )

    resp = RedirectResponse(
        url=_safe_next(request.cookies.get(auth_session.NEXT_COOKIE)), status_code=302
    )
    resp.set_cookie(
        auth_session.SESSION_COOKIE,
        token,
        max_age=auth_session.SESSION_TTL,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
    )
    # The flow cookies are single-use.
    resp.delete_cookie(bw_auth.PKCE_COOKIE, path="/")
    resp.delete_cookie(bw_auth.STATE_COOKIE, path="/")
    resp.delete_cookie(auth_session.NEXT_COOKIE, path="/")
    return resp


@router.get("/auth/logout", include_in_schema=False)
def auth_logout() -> Response:
    """End the APP session, then bounce to id-auth's full signout so the
    shared BW SSO session is revoked too — without that the very next
    request would silently re-establish the session (04-architecture:
    'sign-out ends the app session AND links the id-auth logout').
    Also clears any view-as state: impersonation never survives into
    the next session (T-VAS-13, inverting the old app's stale-cookie
    wart)."""
    resp = RedirectResponse(url=bw_auth.logout_url(), status_code=302)
    resp.delete_cookie(auth_session.SESSION_COOKIE, path="/")
    resp.delete_cookie(VIEW_AS_COOKIE, path="/")
    return resp


def _workspace_profile(session: Session, actor: Actor) -> str:
    """The current tenant's workspace-profile name (ADR #020, tenant modes).

    Reads ``tenants.settings.workspace_profile`` for the actor's current
    tenant; when unset it defaults BY SLUG (the ``plusroi`` tenant ->
    ``"plusroi"``, every other tenant -> ``"bowden-works"``). A NO_TENANT
    actor gets the default look. This is the ONLY presentation coupling in
    the backend — the SPA resolves the actual profile object from this name
    (the UI-is-data rip-out guarantee)."""
    if actor.tenant_id is not None:
        blob = (
            session.scalar(select(Tenant.settings).where(Tenant.id == actor.tenant_id))
            or {}
        )
        name = blob.get("workspace_profile")
        if isinstance(name, str) and name.strip():
            return name.strip()
    return "plusroi" if actor.tenant_slug == "plusroi" else "bowden-works"


class MeOut(BaseModel):
    """M3 shape: the real identity stays top-level (M1 contract); the
    effective identity, capability set, and view-as state ride along so
    the SPA can shape its UI from ONE call. R2 adds the tenant context
    (ADR #015): `tenants` is every book the actor may switch to (empty for
    a NO_TENANT user), `tenant` is the current one (null when none).
    v0.14 adds `workspace_profile` (ADR #020): the tenant-mode name the SPA
    renders its nav/shell/theme/columns from."""

    username: str
    email: str
    is_super_admin: bool
    effective: dict
    capabilities: dict[str, bool]
    view_as: dict
    view_as_candidates: list[dict]
    tenants: list[dict]
    tenant: dict | None
    workspace_profile: str


@router.get("/api/me", response_model=MeOut)
def api_me(session: SessionDep, actor: ActorDep) -> MeOut:
    """The signed-in identity + capabilities (the SPA shell consumes
    this). Capabilities come from the DB via the authz engine on every
    call — a super-admin grant or view-as swap is visible immediately,
    not at the next session mint."""
    candidates: list[dict] = []
    if actor.allowed("can_enter_view_as"):
        rows = session.execute(
            select(User.idauth_username, User.person_party_id).where(
                User.idauth_username != actor.real_username
            ).order_by(User.idauth_username)
        ).all()
        party_names = dict(
            session.execute(
                select(Party.id, Party.name).where(
                    Party.id.in_([p for _, p in rows if p is not None])
                )
            ).all()
        ) if rows else {}
        candidates = [
            {"username": username, "label": party_names.get(party_id) or username}
            for username, party_id in rows
        ]
    return MeOut(
        username=actor.real_username,
        email=actor.real_email,
        is_super_admin=actor.real_is_super_admin,
        effective={
            "username": actor.username,
            "role": actor.role,
            "is_super_admin": actor.is_super_admin,
            "label": actor.view_as_label or actor.username,
        },
        capabilities=dict(actor.capabilities),
        view_as={"active": actor.is_viewing_as, "label": actor.view_as_label},
        view_as_candidates=candidates,
        tenants=[{"slug": t.slug, "name": t.name} for t in actor.allowed_tenants],
        tenant=(
            {"slug": actor.tenant_slug, "name": actor.tenant_name}
            if actor.tenant_slug is not None
            else None
        ),
        workspace_profile=_workspace_profile(session, actor),
    )
