"""The app's OWN session — a short-TTL, HMAC-signed, HttpOnly cookie.

Follows the kit's token construction (bw_auth id_token format:
base64url(compact-json)|hmac_hex) rather than pulling in itsdangerous —
same crypto, zero dependencies, and one shape to audit. Mirrors
/srv/apps/with/app/services/auth/session.py.

Why stateless + short: PATTERN-B keeps the id_token one-shot and tells
the app to hold its own session. We keep that session SHORT (12h) so
id-auth-side revocation converges quickly, and rely on silent
re-establishment: an expired cookie just 302s back into /auth/login,
and a visitor with a live BW SSO session round-trips auth.bowden.works
without seeing a login form.

Signing key precedence:
  1. SESSION_SECRET env (explicit override, if ever staged)
  2. derived from BW_CLIENT_SECRET (HMAC-SHA256 with a fixed context
     string) — the production default: no extra secret to stage, and
     rotating the client secret also revokes every app session
  3. an ephemeral per-process random key (dev/tests without BW config;
     sessions die with the process, which is correct there)

This module also signs the VIEW-AS cookie (a separate cookie, separate
derived key context). Unlike with's plain-username cookie, ours is
signed and bound to the REAL username so a forged/replayed cookie can
never even NAME a target — and the per-request bw_view_as.verify() in
app/services/view_as.py re-authorizes whatever it names anyway
(defense in depth, both layers fail closed).
"""

import base64
import hashlib
import hmac
import json
import os
import secrets
import time

SESSION_COOKIE = "vb_session"
NEXT_COOKIE = "vb_next"            # post-login destination, set by /auth/login
SESSION_TTL = 12 * 60 * 60         # 12 hours

VIEW_AS_COOKIE = "vb_view_as"
VIEW_AS_TTL = 8 * 60 * 60          # 8h, matching with's view-as window

_SESSION_KEY_CONTEXT = b"volleyboard-session-key-v1"
_VIEW_AS_KEY_CONTEXT = b"volleyboard-viewas-key-v1"
_EPHEMERAL_KEY = secrets.token_bytes(32)


def _base_secret() -> bytes | None:
    explicit = os.environ.get("SESSION_SECRET", "")
    if explicit:
        return explicit.encode("utf-8")
    client_secret = os.environ.get("BW_CLIENT_SECRET", "")
    if client_secret:
        return client_secret.encode("utf-8")
    return None


def _key(context: bytes) -> bytes:
    base = _base_secret()
    if base is None:
        # No configured secret (dev/tests without BW config): per-process
        # ephemeral key — sessions die with the process, which is correct.
        return hmac.new(_EPHEMERAL_KEY, context, hashlib.sha256).digest()
    return hmac.new(base, context, hashlib.sha256).digest()


def _b64url_nopad(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _b64url_decode(value: str) -> bytes:
    pad = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + pad)


def _sign(claims: dict, context: bytes) -> str:
    value = _b64url_nopad(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
    sig = hmac.new(_key(context), value.encode("utf-8"), hashlib.sha256).hexdigest()
    return f"{value}|{sig}"


def _verify(token: str, context: bytes, *, now: int | None = None) -> dict | None:
    """Return the claims, or None for anything invalid — a bad signature,
    garbage, or an expired token all read as "no state". Never raises: an
    attacker-controlled cookie must not 500 the app."""
    if not token or "|" not in token:
        return None
    value, sig = token.rsplit("|", 1)
    expected = hmac.new(_key(context), value.encode("utf-8"), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        return None
    try:
        claims = json.loads(_b64url_decode(value).decode("utf-8"))
    except (ValueError, UnicodeDecodeError):
        return None
    if not isinstance(claims, dict):
        return None
    current = int(now if now is not None else time.time())
    try:
        if int(claims.get("exp", 0)) <= current:
            return None
    except (TypeError, ValueError):
        return None
    return claims


# ------------------------------------------------------------------ app session


def mint_session(
    username: str,
    email: str = "",
    *,
    ttl: int = SESSION_TTL,
    now: int | None = None,
) -> str:
    """Sign a session token for a provisioned user. Set it as an
    HttpOnly + Secure + SameSite=Lax cookie named SESSION_COOKIE."""
    issued = int(now if now is not None else time.time())
    claims = {
        "username": username,
        "email": email,
        "iat": issued,
        "exp": issued + int(ttl),
    }
    return _sign(claims, _SESSION_KEY_CONTEXT)


def verify_session(token: str, *, now: int | None = None) -> dict | None:
    """Return the session claims, or None for anything invalid (bad
    signature / garbage / expired — the middleware then routes into
    silent re-establishment)."""
    claims = _verify(token, _SESSION_KEY_CONTEXT, now=now)
    if claims is None or not claims.get("username"):
        return None
    return claims


# --------------------------------------------------------------- view-as cookie


def mint_view_as(
    real_username: str,
    target: str,
    mode: str = "readonly",
    *,
    ttl: int = VIEW_AS_TTL,
    now: int | None = None,
) -> str:
    """Sign the view-as state cookie. Bound to the REAL username so it is
    inert on any other session."""
    issued = int(now if now is not None else time.time())
    claims = {
        "real": real_username,
        "target": target,
        "mode": mode,
        "iat": issued,
        "exp": issued + int(ttl),
    }
    return _sign(claims, _VIEW_AS_KEY_CONTEXT)


def read_view_as(token: str, real_username: str, *, now: int | None = None) -> dict | None:
    """Return {"target","mode"} when the cookie is validly signed, unexpired,
    AND was minted for this exact real user; else None."""
    claims = _verify(token, _VIEW_AS_KEY_CONTEXT, now=now)
    if claims is None or not claims.get("target"):
        return None
    real = claims.get("real") or ""
    if not real_username or not hmac.compare_digest(real, real_username):
        return None
    mode = claims.get("mode")
    if mode not in ("readonly", "act"):
        mode = "readonly"
    return {"target": claims["target"], "mode": mode}
