"""bw_auth — "Sign in with BW" (Pattern B) drop-in, ported from the kit.

This is a faithful copy of /srv/system/id-auth/app-auth/bw_auth.py
(the canonical app-side contract — README.md / PATTERN-B.md there).
Deltas from the kit file: the __main__ self-check block was dropped
(covered by tests/test_auth_tokens.py) and one redundant exception
alias was folded (json.JSONDecodeError is a ValueError). Everything
semantic — PKCE, state handling, the id_token verification, the
server-to-server calls — is byte-level identical in behavior.

THE FOUR ENV VARS (read lazily — importing this module never requires them)
    BW_CLIENT_ID       your app's client_id (the --name slug you registered)
    BW_CLIENT_SECRET   the secret written by app-client-register --secret-out.
                       Lives in the 600 .bw-auth.env; NEVER commit or log it.
    BW_AUTH            https://auth.bowden.works   (the id-auth login host)
    BW_APP_DOMAIN      https://<your-app-host>     (builds the default
                       redirect_uri and the full-signout return URL)

THE id_token FORMAT (must match the gateway's signer byte-for-byte)
    id_token = "<value>|<hmac_hex>"
      value    = base64url( json(claims, separators=(",",":")) ), '=' STRIPPED
      claims   = {"aud": client_id, "sub": username, "username", "email",
                  "first", "last", "iat": <int>, "exp": <int == iat + 300>}
      hmac_hex = HMAC_SHA256(key=client_secret_utf8, msg=value_utf8).hexdigest()

The token is a ONE-SHOT bootstrap: verify it, pull the identity out, and
throw it away. Never store it, never treat it as a bearer credential.
"""

import base64
import contextlib
import hashlib
import hmac
import json
import os
import secrets
import ssl
import time
import urllib.parse
import urllib.request

# The four env-var names, in one place so the reference and docs can't drift.
ENV_CLIENT_ID = "BW_CLIENT_ID"
ENV_CLIENT_SECRET = "BW_CLIENT_SECRET"
ENV_AUTH = "BW_AUTH"
ENV_APP_DOMAIN = "BW_APP_DOMAIN"

# Default login host, used only if BW_AUTH is unset. Production is auth.bowden.works.
DEFAULT_BW_AUTH = "https://auth.bowden.works"

# HTTP timeout for the server-to-server calls (token exchange, userinfo,
# report-role). id-auth is on the LAN bridge; these are fast. Keep it short so a
# hung request never wedges a web worker.
HTTP_TIMEOUT = 8

# The id_token lifetime the gateway signs (iat + 300). We don't rely on this
# constant for verification (we read exp from the claims), it's here for docs.
TOKEN_TTL = 300


class BWAuthError(Exception):
    """Any failure in the BW-Auth flow: bad config, network error, a 4xx from
    id-auth, or an invalid/expired/forged id_token. The message is safe to log;
    it never contains the client_secret. Catch this around exchange_code() and
    verify_id_token() and treat it as "authentication failed — bounce to login".
    """


# --------------------------------------------------------------------------
# Config — read lazily so importing this module never requires the env to be set.
# --------------------------------------------------------------------------

def _env(name, default=None, required=False):
    val = os.environ.get(name, default)
    if required and not val:
        raise BWAuthError(
            f"{name} is not set. This app is not configured for BW-Auth. "
            f"Set {ENV_CLIENT_ID}, {ENV_CLIENT_SECRET}, {ENV_AUTH}, and "
            f"{ENV_APP_DOMAIN} in the app container environment."
        )
    return val


def client_id():
    """Your registered client_id (the --name slug). Required for every call."""
    return _env(ENV_CLIENT_ID, required=True)


def client_secret():
    """Your client_secret. Required for the server-to-server calls (token,
    userinfo, report-role) and to verify/sign the id_token HMAC. Lives ONLY in
    the app env — never logged, never sent in a query string, never to a browser.
    """
    return _env(ENV_CLIENT_SECRET, required=True)


def auth_base():
    """The id-auth login host, e.g. https://auth.bowden.works — no trailing slash."""
    return (_env(ENV_AUTH, default=DEFAULT_BW_AUTH) or DEFAULT_BW_AUTH).rstrip("/")


def app_domain():
    """This app's public origin, e.g. https://with.bowden.works — no trailing slash.
    Used to build the default redirect_uri and the full-signout return URL."""
    return _env(ENV_APP_DOMAIN, required=True).rstrip("/")


def default_redirect_uri():
    """The canonical callback URL: <BW_APP_DOMAIN>/auth/callback. This MUST be on
    the redirect_uri allowlist registered with app-client-register."""
    return f"{app_domain()}/auth/callback"


# --------------------------------------------------------------------------
# base64url helpers — MUST match the gateway's _b64url (urlsafe, '=' stripped).
# --------------------------------------------------------------------------

def _b64url_nopad(raw: bytes) -> str:
    """urlsafe base64 with '=' padding stripped — the exact encoding the gateway
    uses for both the id_token `value` and the PKCE code_challenge."""
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _b64url_decode(value: str) -> bytes:
    """Inverse of _b64url_nopad: re-pad to a multiple of 4, then urlsafe-decode."""
    pad = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + pad)


# --------------------------------------------------------------------------
# PKCE (S256)
# --------------------------------------------------------------------------

def new_pkce_pair():
    """Generate a fresh (code_verifier, code_challenge) PKCE pair.

    code_verifier  = secrets.token_urlsafe(48)   (a high-entropy random string)
    code_challenge = base64url( sha256(verifier) ), '=' stripped, method "S256"

    Store the VERIFIER server-side across the redirect (an HttpOnly+Secure cookie
    is the usual home — see start_login) and send the CHALLENGE in the authorize
    URL. At /app-token you present the verifier; id-auth recomputes the challenge
    and rejects the code on mismatch. This binds the code to the exact
    browser/session that started the login, defeating code interception.
    """
    verifier = secrets.token_urlsafe(48)
    challenge = _b64url_nopad(hashlib.sha256(verifier.encode("ascii")).digest())
    return verifier, challenge


# --------------------------------------------------------------------------
# Step 1 — build the /app-authorize redirect
# --------------------------------------------------------------------------

def build_authorize_url(code_verifier, redirect_uri=None, state=None, silent=False):
    """Build the GET <BW_AUTH>/app-authorize URL to send the browser to.
    Low-level: you own the verifier and state.

    Args:
        code_verifier: the PKCE verifier from new_pkce_pair() (kept server-side).
        redirect_uri:  where id-auth sends the code back. Defaults to
                       default_redirect_uri(). MUST be on the registered allowlist.
        state:         opaque anti-CSRF value echoed back to your callback. If
                       None, a fresh random one is generated. Store it (cookie or
                       session) and compare on the callback — reject on mismatch.
        silent:        SILENT probe (OIDC prompt=none). When True and the visitor
                       has NO BW account session, id-auth 302s back with
                       ?error=login_required instead of rendering its login page.

    Returns:
        (url, state) — redirect the browser to `url`; remember `state`.

    On success (session present, or after login) id-auth 302s to:
        <redirect_uri>?code=<single-use-code>&state=<state>
    Only a REAL account (not a guest/shared-password session) can complete it.
    """
    if state is None:
        state = secrets.token_urlsafe(24)
    if redirect_uri is None:
        redirect_uri = default_redirect_uri()
    challenge = _b64url_nopad(
        hashlib.sha256(code_verifier.encode("ascii")).digest()
    )
    params = {
        "client_id": client_id(),
        "redirect_uri": redirect_uri,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    if silent:
        params["prompt"] = "none"
    return f"{auth_base()}/app-authorize?{urllib.parse.urlencode(params)}", state


def is_login_required(query):
    """True if a SILENT (prompt=none) probe bounced back with no session — i.e.
    the visitor is anonymous. `query` is the callback's parsed query params
    (a dict of str->str or the raw querystring)."""
    if isinstance(query, str):
        query = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
    return (query.get("error") or "") == "login_required"


# Cookie names the framework helpers use to carry PKCE + state across the redirect.
PKCE_COOKIE = "bw_pkce"      # holds the code_verifier (HttpOnly, Secure, SameSite=Lax)
STATE_COOKIE = "bw_state"    # holds the state nonce (HttpOnly, Secure, SameSite=Lax)


def start_login(set_cookie, redirect_uri=None, cookie_max_age=600, silent=False):
    """Framework-agnostic helper that begins a login: it mints a PKCE pair + state,
    hands you the two short-lived cookies to set, and returns the authorize URL.

    `set_cookie` is a callback YOU provide that persists a cookie. It is called as:
        set_cookie(name, value, max_age)
    and must set an HttpOnly + Secure + SameSite=Lax cookie (these carry a secret
    verifier and a CSRF nonce across the round-trip; they must not be JS-readable).

    Returns:
        (authorize_url, state) — redirect the browser to authorize_url.

    On the callback, read the verifier from the PKCE_COOKIE and the expected state
    from the STATE_COOKIE, compare the returned `state`, then call
    finish_login(code, verifier) (or exchange_code + verify_id_token directly).
    """
    verifier, _challenge = new_pkce_pair()
    url, state = build_authorize_url(verifier, redirect_uri=redirect_uri, silent=silent)
    set_cookie(PKCE_COOKIE, verifier, cookie_max_age)
    set_cookie(STATE_COOKIE, state, cookie_max_age)
    return url, state


# --------------------------------------------------------------------------
# HTTP plumbing for the server-to-server POSTs. Talks ONLY to BW_AUTH over TLS.
# --------------------------------------------------------------------------

def _post_json(path, payload):
    """POST `payload` as JSON to <BW_AUTH><path> and return the parsed JSON dict.

    Raises BWAuthError on transport failure or any non-2xx (surfacing id-auth's
    {"error": ...} body when present). The client_secret may be in `payload`, so
    this function never logs the payload and never puts it in a URL/query string.
    """
    url = f"{auth_base()}{path}"
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url, data=body, method="POST",
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    # Standard verified TLS. BW_AUTH is https://auth.bowden.works with a real cert.
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as r:
            return json.loads(r.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        # id-auth returns {"error": "..."} on the documented 4xx cases.
        detail = ""
        with contextlib.suppress(Exception):
            detail = json.loads(e.read().decode("utf-8")).get("error", "")
        raise BWAuthError(
            f"{path} returned HTTP {e.code}"
            + (f": {detail}" if detail else "")
        ) from None
    except urllib.error.URLError as e:
        raise BWAuthError(f"could not reach id-auth at {url}: {e.reason}") from None
    except ValueError as e:  # includes json.JSONDecodeError
        raise BWAuthError(f"{path} returned a non-JSON response: {e}") from None


# --------------------------------------------------------------------------
# Step 2 — exchange the code at /app-token (server-to-server, from the BACKEND)
# --------------------------------------------------------------------------

def exchange_code(code, code_verifier, redirect_uri=None):
    """Redeem the single-use authorization code for an identity + id_token.

    This is a server-to-server call from the app BACKEND (never the browser) —
    it carries the client_secret. id-auth verifies the code (single-use, 60s TTL,
    bound to client_id + redirect_uri), checks the PKCE verifier against the
    challenge from step 1, and verifies the client_secret, then returns the
    signed id_token.

    Returns:
        {"identity": {"username","email","first","last"},
         "id_token": "<value>|<hmac_hex>"}

    You should still call verify_id_token(result["id_token"]) — it's the
    cryptographic proof of the audience and freshness. `identity` is a
    convenience copy; treat verify_id_token()'s claims as authoritative.

    Raises BWAuthError on a bad/expired/replayed code, PKCE mismatch, or bad
    client_secret (id-auth returns 4xx {"error": ...} for all of these).
    """
    if redirect_uri is None:
        redirect_uri = default_redirect_uri()
    resp = _post_json("/app-token", {
        "code": code,
        "client_id": client_id(),
        "client_secret": client_secret(),
        "redirect_uri": redirect_uri,
        "code_verifier": code_verifier,
    })
    if "id_token" not in resp or "identity" not in resp:
        raise BWAuthError(
            "/app-token response missing id_token/identity: "
            + (resp.get("error", "unexpected response shape"))
        )
    return resp


def finish_login(code, code_verifier, redirect_uri=None):
    """Convenience: exchange_code() + verify_id_token() in one call. Returns the
    verified claims dict (aud/sub/username/email/first/last/iat/exp). The
    id_token is verified and discarded — nothing to store."""
    result = exchange_code(code, code_verifier, redirect_uri=redirect_uri)
    return verify_id_token(result["id_token"])


# --------------------------------------------------------------------------
# id_token verification — the exact inverse of the gateway's signer.
# --------------------------------------------------------------------------

def verify_id_token(id_token, leeway=0):
    """Verify a BW id_token and return its claims, or raise BWAuthError.

    Format (must match the gateway byte-for-byte):
        id_token = "<value>|<hmac_hex>"
        value    = base64url( json(claims, separators=(",",":")) ), no '=' padding
        hmac_hex = HMAC_SHA256(key=client_secret, msg=value).hexdigest()

    Steps: split off the trailing HMAC, recompute HMAC over `value` with our
    client_secret, constant-time compare, then re-pad + base64url-decode `value`,
    json-load the claims, and assert aud == our client_id and exp > now.

    The token is a one-shot bootstrap credential: call this once in the callback,
    read the identity out of the returned claims, and DISCARD the token. Never
    store it, never accept it as a bearer on later requests.
    """
    if not id_token or "|" not in id_token:
        raise BWAuthError("id_token is empty or malformed (expected value|hmac)")
    value, sig = id_token.rsplit("|", 1)
    expected = hmac.new(
        client_secret().encode("utf-8"), value.encode("utf-8"), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise BWAuthError("id_token signature mismatch (wrong client_secret or forged token)")
    try:
        claims = json.loads(_b64url_decode(value).decode("utf-8"))
    except Exception as e:
        raise BWAuthError(f"id_token payload is not valid base64url JSON: {e}") from None
    if claims.get("aud") != client_id():
        raise BWAuthError(
            f"id_token audience mismatch: token aud={claims.get('aud')!r}, "
            f"expected {client_id()!r}"
        )
    now = int(time.time())
    if int(claims.get("exp", 0)) <= now - leeway:
        raise BWAuthError("id_token has expired")
    return claims


# --------------------------------------------------------------------------
# /app/userinfo — profile lookup when a trusted username is already known.
# --------------------------------------------------------------------------

def userinfo(username):
    """Look up a BW account's profile by username (server-to-server).

    For gated hosts where a trusted X-Auth-User header already names the
    account and there is no OAuth code to exchange. NOTE: this app does NOT
    use this path for authentication — identity comes only from the verified
    id_token (T-AZ-045; ADR #002). Kept for parity with the kit (and possible
    non-auth profile refresh later).

    Returns:
        {"username","email","first","last"}

    Raises BWAuthError if the username is unknown/inactive (id-auth returns 404).
    """
    return _post_json("/app/userinfo", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })


# --------------------------------------------------------------------------
# /app/report-role — publish a DISPLAY-ONLY role to the BW hub.
# --------------------------------------------------------------------------

def report_role(username, role):
    """Report this user's role WITHIN this app to the BW hub, for display only.

    Roles are purely cosmetic in id-auth/the hub — they grant no real access
    anywhere; this app's authz engine is the sole authority. Call after
    establishing/refreshing a session (idempotent). role=None (or "") clears.
    """
    resp = _post_json("/app/report-role", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "role": role,
    })
    if not resp.get("success"):
        raise BWAuthError(
            "report-role failed: " + resp.get("error", "unknown error")
        )
    return True


# --------------------------------------------------------------------------
# Logout — clear the app session, then hit id-auth for a full cross-site signout.
# --------------------------------------------------------------------------

def logout_url(next_url=None):
    """Build the id-auth full-signout URL.

    The app's /auth/logout FIRST clears its own session cookie, then redirects
    the browser here so the shared BW (SSO) session is revoked too — otherwise
    the user would be silently re-authed on the next visit.
    """
    dest = next_url or app_domain()
    return f"{auth_base()}/logout?redirect={urllib.parse.quote(dest, safe='')}"


# --------------------------------------------------------------------------
# Helper: is this X-Auth-User a real account, or a shared-password guest?
# --------------------------------------------------------------------------

def is_guest(x_auth_user):
    """True if the X-Auth-User header value is a shared-password GUEST session
    rather than a real BW account. id-auth reports guests as the literal
    "guest" or "guest:<label>" (the label is display-only, NOT an identity)."""
    u = (x_auth_user or "").strip().lower()
    return u == "" or u == "guest" or u.startswith("guest:")
