"""
bw_auth.py — "Sign in with BW" (Pattern B) drop-in for any app on this server.

This is the app-side half of the BW-Auth (id-auth) OAuth-with-PKCE contract.
Copy this file into your app, set four env vars, and call the functions below.
It is framework-agnostic and STDLIB-ONLY (no PyJWT, no requests) so it drops
cleanly into Flask, FastAPI, Django, a bare wsgiref app, or a background worker.

--------------------------------------------------------------------------------
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 printed ONCE by app-client-register.
                       Keep it in the container env or a 600 .env. NEVER commit it.
    BW_AUTH            https://auth.bowden.works   (the id-auth login host)
    BW_APP_DOMAIN      https://<your-app-host>     (used to build the default
                       redirect_uri, https://<host>/auth/callback, and the
                       full-signout return URL)

--------------------------------------------------------------------------------
WHAT id-auth GUARANTEES (so your app doesn't have to)
--------------------------------------------------------------------------------
  * Only a REAL BW ACCOUNT reaches /app-authorize's success path. Guests
    (shared-password sessions) cannot mint an app code — so a successful token
    exchange PROVES the visitor holds a real account.
  * The authorization code is single-use, 60s TTL, bound to (client_id,
    redirect_uri), and PKCE-bound to your code_verifier.
  * The id_token is HMAC-signed with YOUR client_secret and audience-bound to
    YOUR client_id, so a token minted for another app can't be replayed at yours.
  * The redirect_uri MUST be on the allowlist you registered, or /app-authorize
    returns 400 — an attacker can't redirect the code to a host they control.

--------------------------------------------------------------------------------
THE id_token FORMAT (must match the gateway's signer byte-for-byte)
--------------------------------------------------------------------------------
    id_token = "<value>|<hmac_hex>"
      value    = base64url( json(claims, separators=(",",":")) ), '=' PADDING 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()

  verify_id_token() below recomputes the HMAC over `value` with your
  client_secret, constant-time-compares it, then re-pads and base64url-decodes
  `value`, json-loads the claims, and asserts aud == BW_CLIENT_ID and exp > now.
  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, never put it in a cookie.

--------------------------------------------------------------------------------
PUBLIC API (see each function's docstring for details)
--------------------------------------------------------------------------------
  new_pkce_pair()                       -> (code_verifier, code_challenge)
  build_authorize_url(verifier, ...)    -> (url, state)      low-level
  start_login(set_cookie, ...)          -> (url, state)      framework-helper
  exchange_code(code, code_verifier,    -> {"identity": {...}, "id_token": "..."}
                redirect_uri=None)
  verify_id_token(id_token)             -> claims dict (raises BWAuthError if bad)
  userinfo(username)                    -> {"username","email","first","last"}
  report_role(username, role)           -> True   (role=None clears)
  logout_url(next_url=None)             -> str     (id-auth full-signout URL)

Every network call talks ONLY to BW_AUTH (https://auth.bowden.works) over TLS.
The app NEVER talks to the gateway directly — id-auth is the broker.
"""

import base64
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 two 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
# (a per-app Claude can `import bw_auth` to introspect it without a live config).
# --------------------------------------------------------------------------

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://myapp.example.com — 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 you registered with app-client-register. If your
    app mounts the callback elsewhere, pass an explicit redirect_uri to
    build_authorize_url()/start_login()/exchange_code() and register THAT."""
    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 if it doesn't match. 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 https://auth.bowden.works/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() (<BW_APP_DOMAIN>/auth/callback).
                       MUST be on your 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) — for PUBLIC-mode apps that
                       want to auto-sign-in an already-SSO'd visitor WITHOUT ever
                       showing the BW login page to an anonymous one. When True and
                       the visitor has NO BW account session, id-auth 302s back to
                       <redirect_uri>?error=login_required&state=... (instead of the
                       login page) — your callback catches that and renders your OWN
                       welcome page. When False (an explicit "Sign in" click), a
                       non-SSO'd visitor lands on the BW login page as normal.

    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 and your app should render its own welcome page. `query`
    is the callback's parsed query params (a dict of str->str or the raw querystring).
    Use this to branch in your /auth/callback: login_required -> welcome page (and
    do NOT re-probe — loop-guard it); a `code` present -> exchange_code() as normal."""
    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).

    Example (Flask): pass a closure that stashes (name, value, max_age) and, after
    building your redirect response, calls resp.set_cookie(name, value,
    max_age=max_age, httponly=True, secure=True, samesite="Lax"). See example-flask/app.py.

    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


# --------------------------------------------------------------------------
# SPA / PWA silent-probe helpers.
#
# A single-page app (React/Vue/…), ESPECIALLY served as a PWA, cannot use the
# server-rendered silent probe that example-flask does on "/": the service worker
# serves the cached index.html for "/", so a server-side probe there never runs.
# The SPA pattern instead is:
#
#   1. A PUBLIC endpoint GET /api/me returns {authenticated, should_probe, …profile}.
#      The SERVER computes should_probe — the loop-guard/opt-out cookies below are
#      HttpOnly, so the SPA's JS cannot read them itself.
#   2. On load the SPA calls /api/me; if (!authenticated && should_probe) it does a
#      FULL-PAGE navigation (window.location, NOT fetch) to /auth/probe.
#   3. /auth/probe calls start_probe() (sets the loop-guard cookie) and 302s into the
#      SILENT (prompt=none) authorize. The callback returns a ?code (signed in) or
#      ?error=login_required (anonymous → the SPA renders its OWN welcome page).
#
# CRITICAL for PWAs: /login, /logout, and /auth/* (INCLUDING /auth/probe) MUST be in
# the service worker's navigateFallbackDenylist, or the SW answers them from cache and
# sign-in silently fails with no request reaching the server. See PATTERN-B.md.
# --------------------------------------------------------------------------

PROBED_COOKIE = "bw_probed"   # set just before a silent probe; loop-guards re-probing
OPTOUT_COOKIE = "bw_optout"   # set by your /logout; suppresses auto-probe after sign-out
PROBE_MAX_AGE = 600           # probe an anonymous visitor at most once per this window (s)
OPTOUT_MAX_AGE = 86400        # after sign-out, stay opted-out this long (or until explicit login)


def should_probe(mode, authenticated, cookies):
    """Should the SPA fire a silent auto-sign-in probe on load?

    True only when ALL hold: the domain is PUBLIC mode (account/password-gated domains
    are already behind the gate — no client probe needed); the visitor is NOT already
    authenticated in your app; they have NOT opted out (no OPTOUT_COOKIE, set by your
    /logout); and they have NOT been probed this window (no PROBED_COOKIE). Computed on
    the SERVER for /api/me because those cookies are HttpOnly.

    Args:
        mode:          your domain mode ("public" | "password" | "account").
        authenticated: whether the caller already has an APP session.
        cookies:       a mapping of cookie name -> value (e.g. request.cookies).
    """
    if str(mode).lower() != "public" or authenticated:
        return False
    return not cookies.get(OPTOUT_COOKIE) and not cookies.get(PROBED_COOKIE)


def me_payload(authenticated, mode, cookies, profile=None):
    """Build the JSON body for your PUBLIC `GET /api/me`: the SPA reads `authenticated`
    to pick its view and `should_probe` to decide whether to navigate to /auth/probe.
    Merge in whatever public profile fields your app wants to expose (never secrets)."""
    body = {"authenticated": bool(authenticated),
            "should_probe": should_probe(mode, authenticated, cookies)}
    if profile:
        body.update(profile)
    return body


def start_probe(set_cookie, redirect_uri=None):
    """Begin a SILENT probe from your `/auth/probe` route: set the loop-guard cookie
    (so an anonymous visitor is probed at most once per PROBE_MAX_AGE window) and
    return the authorize URL to 302 the browser to. Mirrors start_login(silent=True).

    `set_cookie(name, value, max_age)` is your framework's cookie setter — the same
    callback shape start_login uses; it must set HttpOnly + Secure + SameSite=Lax.
    Returns (authorize_url, state)."""
    set_cookie(PROBED_COOKIE, "1", PROBE_MAX_AGE)
    return start_login(set_cookie, redirect_uri=redirect_uri, silent=True)


def clear_probe_cookies(delete_cookie):
    """After a successful sign-in (in /auth/callback) clear the loop-guard + opt-out
    cookies so the app is in a clean state. `delete_cookie(name)` deletes one cookie."""
    delete_cookie(PROBED_COOKIE)
    delete_cookie(OPTOUT_COOKIE)


def mark_opted_out(set_cookie, max_age=OPTOUT_MAX_AGE):
    """Call from your /logout AFTER clearing the app session: sets OPTOUT_COOKIE so the
    silent probe does NOT immediately re-sign-in the user who just left. An explicit
    'Sign in' click (start_login, not a probe) clears it."""
    set_cookie(OPTOUT_COOKIE, "1", max_age)


# --------------------------------------------------------------------------
# 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 answers a refused call with a 4xx and a JSON body in one of two
        # shapes: {"error": ...} (a malformed request) or {"error_code", "summary"}
        # (a refused action — the app forwarders). Surface the HUMAN reason. Reading
        # only "error" turned every refusal into a bare "returned HTTP 400" and hid
        # the one thing the operator needed ("used outside this app", "needs an
        # email", "daily cap reached").
        detail = ""
        try:
            b = json.loads(e.read().decode("utf-8"))
            detail = (b.get("summary") or b.get("error") or b.get("error_code") or "")
        except Exception:
            pass
        raise BWAuthError(detail or f"{path} returned HTTP {e.code}") from None
    except urllib.error.URLError as e:
        raise BWAuthError(f"could not reach id-auth at {url}: {e.reason}") from None
    except (ValueError, json.JSONDecodeError) as e:
        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 your 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 your client_secret, then returns the
    signed id_token.

    Args:
        code:          the ?code=... value id-auth put on your callback URL.
        code_verifier: the PKCE verifier you stored before the redirect.
        redirect_uri:  MUST be byte-identical to the one used in step 1. Defaults
                       to default_redirect_uri().

    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 of the same fields; 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). Use this in a
    callback when you just want "give me the trusted identity or raise". 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 your 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


# --------------------------------------------------------------------------
# Step 3 — /app/userinfo — fetch a profile for an ACCOUNT-domain visitor.
# --------------------------------------------------------------------------

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

    For ACCOUNT-gated domains: the whole host is behind the gate, so every request
    arrives with a trusted X-Auth-User header (the account's username). There is
    no OAuth code to exchange — you already know WHO the visitor is. Call
    userinfo(x_auth_user) to pull their email/first/last so you can upsert the app
    account, then set your own app session. No id_token is involved in this path.

    Args:
        username: the value from the (Caddy-set, unspoofable) X-Auth-User header.
                  Lowercase account usernames only — a guest ("guest" / "guest:...")
                  is NOT an account and will 404 here (guests can't reach an
                  ACCOUNT-gated host at all).

    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,
    })


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

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

    Roles are purely cosmetic in id-auth/the hub: they help the owner see "adi is
    an editor in myapp" at a glance. They do NOT grant any real access —
    your app is the sole authority on what a role can do. An app can only ever
    write its OWN client_id's roles, and the role never feeds back into id-auth's
    real site-access list.

    Call this right after you establish/refresh a user's app session (on every
    login is fine — it's idempotent). Pass role=None (or "") to CLEAR the role,
    e.g. when a user is deactivated in your app.

    Args:
        username: the BW account username (your app_accounts primary key).
        role:     a short display string ("admin", "editor", "viewer", ...), or
                  None/"" to clear.

    Returns:
        True on success. Raises BWAuthError on a bad client_secret / unknown user.
    """
    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


def report_access(username, level, instances=None):
    """Report a user's FULL access in your app — app-wide level plus optional
    per-instance levels — for central visibility (the access console and the
    user's own auth.bowden.works/account "Your apps" list).

    Superset of report_role: same display-only contract (grants live in YOUR
    app; nothing here changes real access), same call-on-every-login idempotent
    usage. If your app has instances (projects/workspaces/sites-within-the-app),
    pass them; if not, this behaves exactly like report_role.

    NOTE: this call is authoritative for the stored report — the instances you
    pass REPLACE any previously reported for this user (pass the full current
    list each time, not a delta). Pass level=None/"" when the user is removed
    from your app (clears the whole entry).

    Args:
        username:  the BW account username.
        level:     app-wide display level ("admin", "member", ...), or None/"" to
                   clear the user entirely.
        instances: optional list of {"id": ..., "label": ..., "level": ...} —
                   the instances this user can reach and their level in each.

    Returns True on success. Raises BWAuthError on a bad secret/user.
    """
    resp = _post_json("/app/report-access", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "level": level,
        "instances": instances or [],
    })
    if not resp.get("success"):
        raise BWAuthError(
            "report-access failed: " + resp.get("error", "unknown error")
        )
    return True


def report_instances(instances):
    """Report your app's instance CATALOG — the current list of projects/
    workspaces/etc. that exist in the app, as [{"id": ..., "label": ...}].

    Full-replace semantics: each report IS the catalog (send the complete list;
    an omitted instance disappears from central displays). Call it whenever an
    instance is created/renamed/deleted, or lazily on app start. Display-only,
    like every report_* call.

    Returns True on success. Raises BWAuthError on a bad secret.
    """
    resp = _post_json("/app/report-instances", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "instances": instances or [],
    })
    if not resp.get("success"):
        raise BWAuthError(
            "report-instances failed: " + resp.get("error", "unknown error")
        )
    return True


def invite_user(username, email, first="", last=""):
    """Invite a user to Bowden Works FROM your app (the in-app "add account" flow).

    Creates the BW account if it doesn't exist (always class=external — an app can
    never mint staff) and emails a 48h set-password link labeled with your app's
    name; the link lands them back on your app's host after setup. For an existing
    account that hasn't set a password yet, re-sends the setup link. For an
    existing account WITH a password, no email is sent — the response carries
    `existing_with_password: True`; just grant them access app-side.

    The link/token is never returned to your app (email-only delivery). After a
    successful invite, add them in your own accounts table and call
    report_access() so central visibility is immediate.

    Returns the response dict: {success, user, created, emailed_to?,
    existing_with_password?}. Raises BWAuthError on a bad secret / missing email /
    rate limit.
    """
    resp = _post_json("/app/invite-user", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "email": email,
        "first": first,
        "last": last,
    })
    if not resp.get("success"):
        raise BWAuthError("invite-user failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return resp


def create_user_with_password(username, email, first="", last=""):
    """Create a BW account for someone with NO existing account, and get back a
    generated password ONCE — the MANUAL HANDOFF flow. Nothing is emailed.

    For "add this person now; I'll send my own onboarding message with their
    login in it." Use `invite_user()` instead when a plain system email is fine.

    Returns {success, user, created, delivery: "password", password, note}. The
    `password` is returned exactly once and is not retrievable afterwards (only
    its hash is stored) — surface it to the operator immediately and never log
    or persist it in your app.

    NEW ACCOUNTS ONLY, enforced server-side: if the username already has a BW
    account you get ACCOUNT_EXISTS. That boundary is deliberate — an app must
    never be able to mint a working password for an identity it does not own.
    Just add an existing account directly; it needs no credential from you.

    Raises BWAuthError on a bad secret, an existing account, a missing/invalid
    email, or the per-app daily cap.
    """
    resp = _post_json("/app/invite-user", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "email": email,
        "first": first,
        "last": last,
        "delivery": "password",
    })
    if not resp.get("success"):
        raise BWAuthError("create-user-with-password failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return resp


def send_reset(username):
    """Email a password-reset link to one of YOUR app's users (the in-app admin
    "reset password" action).

    Scoped by the server on an UNFORGEABLE signal: it works only for a user who
    has actually SIGNED INTO your app (completed the BW OAuth flow — recorded at
    token mint). Reporting a username via report_access does NOT make it eligible,
    because an app can write any username into its own reports; the sign-in signal
    can't be forged, so an app can never reset-spam arbitrary BW accounts. A user
    who was onboarded out-of-band (or signed in before this gate existed) is linked
    by the owner once: `srv-gw app-link-user --client-id <app> --user <name>`
    (or `--from-reports` to backfill everyone you've reported). Beyond that check
    the response is constant-shaped (never reveals whether an email was sent).

    Returns True. Raises BWAuthError on a bad secret, or NOT_YOUR_USER for a user
    who hasn't signed in / been linked.
    """
    resp = _post_json("/app/send-reset", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })
    if not resp.get("success"):
        raise BWAuthError("send-reset failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return True


def notify_added(username):
    """Email one of YOUR app's users a plain "you've been given access to <app>"
    notification — the add-EXISTING-account flow's optional courtesy email. No
    token, no credential link (nothing to phish); the email names your app and
    links its host. Server-side guards: the account must exist and be active,
    and the send shares your app's daily invite cap plus the per-account
    auth-email cap. Returns True. Raises BWAuthError on a bad secret, unknown
    user, or a hit cap."""
    resp = _post_json("/app/notify-added", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })
    if not resp.get("success"):
        raise BWAuthError("notify-added failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return True


def send_mail(username, subject, text):
    """Email one of YOUR app's users a message of your own: the Interaction
    Standard's delivery seam (§7), email first. A digest of what is waiting for
    them, a round closing, an answer they asked for by email.

    The address is the account's stored one; your app never supplies or sees
    it. Server-side scope is the UNFORGEABLE sign-in signal, like send_reset:
    it works only for a user who has actually signed into your app. Separate
    daily caps from the auth emails (per app, and per account per app), sized
    for a digest an hour, not a firehose: batch what you send. The subject is
    prefixed with your app's name by the server. Returns True. Raises
    BWAuthError on a bad secret, NOT_YOUR_USER, an empty message, or a cap."""
    resp = _post_json("/app/send-mail", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "subject": subject,
        "text": text,
    })
    if not resp.get("success"):
        raise BWAuthError("send-mail failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return True


def reset_password_generated(username):
    """Replace one of YOUR app's users' passwords with a generated one, returned
    ONCE — the manual-handoff counterpart of send_reset(): for the operator who
    will send the new password in their own message. Nothing is emailed, and every
    central session for the account is revoked (the old password stops working).

    Server-enforced boundary: the account must be EXTERNAL (never staff/the
    owner), must have signed into YOUR app, and must be used NOWHERE ELSE — no
    other app, no gated-site grants. A shared BW identity means one app must never
    hand out a working password that opens another app's door. Anything outside
    that is refused with a pointer to the owner console (auth.bowden.works/admin),
    where the owner can reset any account.

    Returns {success, user, password, note}. Surface `password` immediately and
    never log or store it. Raises BWAuthError with an operator-facing reason.
    """
    return _post_json("/app/reset-password", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })


def search_users(q, limit=8):
    """Directory typeahead (P2): search BW accounts for your app's user pickers
    (add person / invite / view-as target). Returns
    [{username, email, first, last, class}] — prefix-weighted, active accounts only.

    PRIVACY SCOPE (server-enforced, per client): by default your app sees only its
    OWN population — users who have signed into it, users it reported, users it
    invited — plus insider (staff) accounts. External clients belonging to OTHER
    apps are invisible. The owner can widen a first-party app to the full directory:
    `srv-gw app-client-update --client-id <app> --directory-scope all`.

    Queries under 2 characters return []. Rate-limited per app (server-side); a
    RATE_LIMITED error means back off. Every query is audit-logged.
    """
    resp = _post_json("/app/search-users", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "q": q,
        "limit": limit,
    })
    if not resp.get("success"):
        raise BWAuthError("search-users failed: "
                          + (resp.get("summary") or resp.get("error_code")
                             or "unknown error"))
    return resp.get("users", [])


def profile_info(username):
    """JSON profile block (P2/G4) for your app's own Profile page — the data
    `profile_html` renders, as data: {username, email, first, last, class,
    has_password, google_linked, account_url, embed_url}.

    `embed_url` is the CHROMELESS central change-password page. Open it in a small
    POPUP (window.open, ~420x560) — NOT an iframe: the central session cookie is
    SameSite=Lax, which browsers do not send inside a cross-site iframe, so an
    iframe always renders signed-out. On success the popup postMessages
    {type:'bw-account', event:'password-changed'} to window.opener (targetOrigin =
    your registered host) and closes itself; listen for it to refresh your UI.
    The password never touches your app.

    Scoped like user_access: only works for a user who has actually signed into
    your app (NOT_YOUR_USER otherwise; `srv-gw app-link-user` is the owner's
    backfill for pre-existing users).
    """
    resp = _post_json("/app/profile-info", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })
    if not resp.get("success"):
        raise BWAuthError("profile-info failed: "
                          + (resp.get("error_code") or "unknown error"))
    return resp


def user_access(username):
    """Fetch this user's cross-app access list (P5) — everywhere they have BW-Auth
    app access, per the apps' reports: [{app, host, description, level,
    instances:[{id,label,level}]}]. For your profile area's "your other Bowden
    Works apps" view. Scoped: only works for a user YOUR app has reported.

    Returns the list. Raises BWAuthError on a bad secret / unreported user.
    """
    resp = _post_json("/app/user-access", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })
    if not resp.get("success"):
        raise BWAuthError("user-access failed: "
                          + (resp.get("error_code") or "unknown error"))
    return resp.get("access", [])


def profile_html(username, return_url=None):
    """A ready-made "Your Bowden Works account" block for your app's profile page
    (P5). Renders: manage-account links (change password, forgot password,
    Google link — all on auth.bowden.works, where the user is already SSO'd; the
    password never transits your app) plus their cross-app access list via
    user_access(). Style with your own CSS (plain elements, `bw-profile` class).

    Args:
        username:   the signed-in BW username.
        return_url: your app URL to offer as "back" from the account page
                    (rendered there as a link when passed).
    """
    import html as _html
    base = auth_base()
    acct = base + "/account"
    if return_url:
        acct += "?return=" + _urllib_quote(return_url)
    rows = ""
    try:
        for a in user_access(username):
            label = _html.escape(a.get("app") or "")
            host = (a.get("host") or "").strip()
            if host:
                label = (f'<a href="https://{_html.escape(host)}/">{label}</a>')
            lvl = _html.escape(a.get("level") or "")
            inst = ""
            if a.get("instances"):
                inst = ('<br><small>' + ", ".join(
                    _html.escape(i.get("label") or i.get("id") or "")
                    + (f' ({_html.escape(i["level"])})' if i.get("level") else "")
                    for i in a["instances"][:20]) + '</small>')
            rows += f'<li>{label} — <b>{lvl}</b>{inst}</li>'
    except BWAuthError:
        pass    # profile block must render even if the access read fails
    access = f'<p>Your apps:</p><ul>{rows}</ul>' if rows else ""
    return (
        '<div class="bw-profile">'
        f'<p>Signed in with your Bowden Works account '
        f'(<b>{_html.escape(username)}</b>) — one login across every BW app.</p>'
        f'{access}'
        f'<p><a href="{_html.escape(acct)}">Manage your account</a> — change your '
        'password or link Google sign-in (applies everywhere at once).</p>'
        '</div>')


def _urllib_quote(v):
    import urllib.parse
    return urllib.parse.quote(v or "", safe="")


def report_presence(username, ip=None):
    """Tell the BW hub that `username` is CURRENTLY active in your app, so the hub's
    who's-online shows their NAME instead of an anonymous IP.

    Why this exists: on a Pattern B app id-auth is NOT in your login loop (you run
    your own login), so it only ever sees anonymous traffic and can label a visitor
    by IP at best. This heartbeat is how id-auth learns who's really here. While your
    app is reporting, id-auth stops recording anonymous IP visits for your host and
    shows the reported names instead (it falls back to IPs if you go quiet).

    Call it on a HEARTBEAT for each signed-in user — e.g. straight from your
    `/api/me` handler whenever the caller is authenticated (the SPA polls /api/me, so
    that keeps presence fresh), or a dedicated ping every ~60s. It's reported against
    YOUR app's host (derived from app_domain()). Requires presence/history tracking to
    be enabled on the host (owner: hub toggle or `srv-gw id-site-set`).

    Fire-and-forget: presence is best-effort telemetry, so any failure is swallowed —
    a hub blip must never break your app or slow the request.

    Args:
        username: the BW account username (your app_accounts primary key).
        ip:       optional — the user's client IP, shown next to their name in the hub.
    """
    host = urllib.parse.urlparse(app_domain()).hostname
    payload = {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "host": host,
    }
    if ip:
        payload["ip"] = ip
    try:
        _post_json("/app/report-presence", payload)
    except BWAuthError:
        pass  # best-effort telemetry — never surface a presence hiccup to the app


def report_impersonation(real_user, target_user, mode="readonly", active=True):
    """Tell the BW hub that `real_user` is currently VIEWING AS `target_user` in your
    app (or has stopped, active=False), so the owner can see impersonation across all
    apps at a glance. Pairs with the bw_view_as drop-in — call it right after
    bw_view_as.start()/stop(), and re-call on your heartbeat while impersonating so it
    stays live (id-auth ages it out after a couple of minutes if you go quiet).

    Impersonation is a high-privilege action; this is the owner's oversight signal.
    Fire-and-forget: a hub hiccup never blocks or breaks your app.

    Args:
        real_user:   the actually-signed-in BW username (the impersonator).
        target_user: the BW username being viewed-as.
        mode:        'readonly' or 'act' (shown in the hub).
        active:      True on start / heartbeat, False on stop.
    """
    host = urllib.parse.urlparse(app_domain()).hostname
    try:
        _post_json("/app/report-impersonation", {
            "client_id": client_id(),
            "client_secret": client_secret(),
            "host": host,
            "real": real_user,
            "target": target_user,
            "mode": mode,
            "active": bool(active),
        })
    except BWAuthError:
        pass  # best-effort oversight telemetry — never surface a hiccup to the app


# --------------------------------------------------------------------------
# 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.

    Your app's /logout should FIRST clear its own session cookie, then redirect the
    browser here so the shared BW (SSO) session is revoked too — otherwise the user
    is still signed in to BW and would be silently re-authed on the next visit.

    Args:
        next_url: where id-auth should send the browser after signing out.
                  Defaults to app_domain() (your homepage). Must be a site id-auth
                  recognises as a safe redirect (any served host qualifies).

    Returns:
        e.g. https://auth.bowden.works/logout?redirect=https%3A%2F%2Fmyapp.example.com
    """
    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
    a display-only name the guest typed — NOT an identity). Real accounts come
    through as their plain lowercase username. On a PASSWORD-gated domain you use
    this to decide whether to show the "Sign in with BW account" upgrade button
    (guest) or to just establish the app session (real account).
    """
    u = (x_auth_user or "").strip().lower()
    return u == "" or u == "guest" or u.startswith("guest:")


# --------------------------------------------------------------------------
# Tiny self-check when run directly: prove the signer/verifier round-trips using
# the SAME construction as the gateway (see gateway.py _sign_app_token / _b64url /
# handle_app_mint_token). This does NOT contact the network and needs no real
# config — it uses a throwaway secret to demonstrate the crypto matches.
# --------------------------------------------------------------------------

def _selfcheck():
    secret = "test-secret-not-a-real-one"
    cid = "demoapp"
    now = int(time.time())
    claims = {
        "aud": cid, "sub": "adi",
        "username": "adi", "email": "adi@example.com",
        "first": "Adi", "last": "Example",
        "iat": now, "exp": now + TOKEN_TTL,
    }
    # Reproduce the gateway's exact signing (gateway.py lines 7754/7759/7915).
    value = base64.urlsafe_b64encode(
        json.dumps(claims, separators=(",", ":")).encode()
    ).decode().rstrip("=")
    sig = hmac.new(secret.encode(), value.encode(), hashlib.sha256).hexdigest()
    token = f"{value}|{sig}"

    # Verify with the module's verifier, overriding config via env for the check.
    os.environ[ENV_CLIENT_ID] = cid
    os.environ[ENV_CLIENT_SECRET] = secret
    got = verify_id_token(token)
    assert got["username"] == "adi" and got["aud"] == cid, got
    # A wrong secret must fail.
    os.environ[ENV_CLIENT_SECRET] = "wrong"
    try:
        verify_id_token(token)
    except BWAuthError:
        pass
    else:
        raise AssertionError("verify_id_token accepted a bad signature")

    # SPA silent-probe gating (no network, no config).
    assert should_probe("public", False, {}) is True
    assert should_probe("public", True, {}) is False                     # already signed in
    assert should_probe("account", False, {}) is False                   # gated domain
    assert should_probe("password", False, {}) is False                  # gated domain
    assert should_probe("public", False, {OPTOUT_COOKIE: "1"}) is False  # opted out
    assert should_probe("public", False, {PROBED_COOKIE: "1"}) is False  # already probed
    assert me_payload(False, "public", {})["should_probe"] is True
    assert me_payload(True, "public", {}, {"username": "adi"})["username"] == "adi"
    print("bw_auth self-check OK: sign/verify round-trips, rejects bad secret, "
          "SPA probe-gating correct")


if __name__ == "__main__":
    _selfcheck()
