"""BW Auth (Pattern B, PUBLIC mode) — sign-in, silent probe, callback, sign-out.

All routes here are PUBLIC: they sit outside /api, so AuthMiddleware lets them
through. They are how a visitor *becomes* authenticated.

  GET /login           explicit "Sign in" click -> interactive OAuth (silent=False)
  GET /auth/probe      client-triggered one-time SILENT SSO probe (prompt=none)
  GET /auth/callback   the OAuth return: a real ?code, or a probe miss
  GET /logout          clear the app session, opt out of the probe, full BW sign-out

Sync `def`: these do blocking server-to-server calls to id-auth plus a DB write.
FastAPI runs sync handlers in a threadpool, which is correct for blocking work —
marking them `async` would block the event loop (react.md).
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse, RedirectResponse

from .. import bw_auth
from ..app_session import (
    COOKIE_KW,
    NEXT_COOKIE,
    OPTOUT_COOKIE,
    OPTOUT_MAX_AGE,
    PROBE_MAX_AGE,
    PROBED_COOKIE,
    SESSION_COOKIE,
    safe_next,
    sign_session,
    verify_session,
)
from ..config import get_settings
from ..db import get_db
from ..services.identity import establish_user, report_role

log = logging.getLogger(__name__)

router = APIRouter(tags=["auth"])


def _apply_login_cookies(resp, pending: dict) -> None:
    """Attach the short-lived PKCE verifier + state cookies that start_login()
    handed back. HttpOnly: they carry the PKCE secret and the CSRF nonce across
    the round-trip and must not be readable by JS."""
    for name, (value, max_age) in pending.items():
        resp.set_cookie(name, value, max_age=max_age, **COOKIE_KW)


@router.get("/login")
def login(request: Request):
    """Explicit sign-in. Interactive: a visitor with no BW SSO session lands on
    the BW-hosted login page at auth.bowden.works — expected, and the only place
    credentials are ever entered. Clicking Sign in is an explicit opt-IN, so it
    clears the opt-out and probe-guard cookies a previous /logout set."""
    nxt = safe_next(request.query_params.get("next"))
    pending: dict = {}

    def set_cookie(name, value, max_age):
        pending[name] = (value, max_age)

    url, _state = bw_auth.start_login(set_cookie, silent=False)
    resp = RedirectResponse(url, status_code=302)
    resp.delete_cookie(OPTOUT_COOKIE, path="/")
    resp.delete_cookie(PROBED_COOKIE, path="/")
    resp.set_cookie(NEXT_COOKIE, nxt, max_age=600, **COOKIE_KW)
    _apply_login_cookies(resp, pending)
    return resp


@router.get("/auth/probe")
def probe(request: Request):
    """One-time SILENT SSO probe, triggered by the SPA when /api/me reports
    should_probe.

    Fires a prompt=none authorize: a visitor already SSO'd with BW anywhere
    comes back with a code and is signed in without ever seeing a login page; an
    anonymous visitor comes back with error=login_required and gets this app's
    own welcome page. The bw_probed cookie loop-guards it to at most once per
    window — without that guard an anonymous visitor bounces forever.
    """
    settings = get_settings()

    # Already signed in, already probed, opted out, or not configured to sign
    # anyone in — do not probe. Each of these would be a wasted round-trip at
    # best and a loop at worst.
    if verify_session(
        request.cookies.get(SESSION_COOKIE, ""), settings.app_session_secret
    ):
        return RedirectResponse("/", status_code=302)
    if request.cookies.get(OPTOUT_COOKIE) or request.cookies.get(PROBED_COOKIE):
        return RedirectResponse("/", status_code=302)
    if settings.domain_mode != "public" or not settings.auth_configured:
        return RedirectResponse("/", status_code=302)

    nxt = safe_next(request.query_params.get("next") or "/")
    pending: dict = {}

    def set_cookie(name, value, max_age):
        pending[name] = (value, max_age)

    url, _state = bw_auth.start_login(set_cookie, silent=True)
    resp = RedirectResponse(url, status_code=302)
    resp.set_cookie(PROBED_COOKIE, "1", max_age=PROBE_MAX_AGE, **COOKIE_KW)
    resp.set_cookie(NEXT_COOKIE, nxt, max_age=600, **COOKIE_KW)
    _apply_login_cookies(resp, pending)
    return resp


@router.get("/auth/callback")
def callback(request: Request, db: Session = Depends(get_db)):
    """The OAuth return. Two outcomes:

    * silent-probe miss (?error=login_required) — an anonymous visitor. Land on
      the SPA with the loop-guard set; it shows this app's own welcome page. Do
      NOT re-probe.
    * a real ?code — verify state, redeem the code with the stored PKCE
      verifier, verify the id_token, establish the account and the app session.
    """
    settings = get_settings()
    nxt = safe_next(request.cookies.get(NEXT_COOKIE))

    if bw_auth.is_login_required(dict(request.query_params)):
        resp = RedirectResponse(nxt, status_code=302)
        resp.set_cookie(PROBED_COOKIE, "1", max_age=PROBE_MAX_AGE, **COOKIE_KW)
        resp.delete_cookie(NEXT_COOKIE, path="/")
        return resp

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

    # Anti-CSRF: the state returned must match the one set before redirecting.
    if not code or not state or not expected_state or state != expected_state:
        return PlainTextResponse("invalid or missing state", status_code=400)
    if not verifier:
        return PlainTextResponse(
            "missing PKCE verifier (session expired?)", status_code=400
        )

    try:
        # exchange_code + verify_id_token. The id_token is one-shot: verified
        # here, its identity read, then discarded. Never stored.
        claims = bw_auth.finish_login(code, verifier)
    except bw_auth.BWAuthError as exc:
        log.warning("BW login failed: %s", exc)
        resp = RedirectResponse("/?auth_error=1", status_code=302)
        resp.delete_cookie(bw_auth.PKCE_COOKIE, path="/")
        resp.delete_cookie(bw_auth.STATE_COOKIE, path="/")
        return resp

    user = establish_user(db, claims)
    username, role = user.external_username, user.role
    db.commit()
    # Mirror the role to the BW hub, outside the transaction. Display-only.
    report_role(username, role)

    ttl = settings.app_session_days * 86400
    resp = RedirectResponse(nxt, status_code=302)
    resp.set_cookie(
        SESSION_COOKIE,
        sign_session(username, settings.app_session_secret, ttl),
        max_age=ttl,
        **COOKIE_KW,
    )
    resp.delete_cookie(bw_auth.PKCE_COOKIE, path="/")
    resp.delete_cookie(bw_auth.STATE_COOKIE, path="/")
    resp.delete_cookie(PROBED_COOKIE, path="/")
    resp.delete_cookie(NEXT_COOKIE, path="/")
    return resp


@router.get("/logout")
def logout():
    """Clear the app session, SET the opt-out marker so the silent probe does
    not instantly sign the user back in, then full BW sign-out — which revokes
    the shared SSO session. Without that last step the next visit silently
    re-authenticates and sign-out looks broken."""
    resp = RedirectResponse(bw_auth.logout_url(), status_code=302)
    resp.delete_cookie(SESSION_COOKIE, path="/")
    resp.set_cookie(OPTOUT_COOKIE, "1", max_age=OPTOUT_MAX_AGE, **COOKIE_KW)
    resp.delete_cookie(PROBED_COOKIE, path="/")
    return resp
