"""andrea_svc_auth.core — framework-agnostic auth core.

Two principal kinds, one combiner:
  * SERVICE  — internal automation/services present a shared API key in a header
               (default X-Agent-Key), checked in CONSTANT TIME against the
               configured key(s). This is the SOLE gate; network position grants
               nothing (no by-IP trust). Empty configured key => fail-closed.
  * USER     — browser/SSO callers present a Keycloak OIDC Bearer; validated
               RS256 against the realm JWKS (issuer/exp/aud), claims returned,
               and the RAW token retained for downstream pass-through.

FAIL CLOSED everywhere: no credential => deny (401); a presented-but-wrong API
key => deny (403); an invalid/expired Bearer => deny (401). Heavy deps (PyJWT,
requests) are imported lazily INSIDE OIDCValidator, so a client/api-key-only
consumer installs the bare package.
"""
from __future__ import annotations

import hmac
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Mapping, Optional, Union

DEFAULT_API_KEY_HEADER = "X-Agent-Key"


# ── principals & results ────────────────────────────────────────────────────
@dataclass
class Principal:
    """The authenticated identity. kind ∈ {"service","user"}."""
    kind: str
    id: str                                  # key-id (service) | sub (user)
    claims: dict = field(default_factory=dict)
    token: str = ""                          # raw bearer (user) — pass-through

    @property
    def is_authenticated(self) -> bool:
        return True

    def __str__(self) -> str:
        return f"{self.kind}:{self.id}"


@dataclass
class AuthResult:
    """principal set => allow; else deny with `reason`/`status` (fail-closed)."""
    principal: Optional[Principal]
    reason: str = ""
    status: int = 401

    @property
    def ok(self) -> bool:
        return self.principal is not None


# ── API-key gate ────────────────────────────────────────────────────────────
def verify_api_key(sent: str, configured: str) -> bool:
    """Constant-time compare. Fail-closed: an empty configured OR empty sent key
    is ALWAYS False (an unconfigured service never authenticates anyone)."""
    if not configured or not sent:
        return False
    return hmac.compare_digest(str(sent), str(configured))


def _keymap(service_keys: Union[str, Mapping[str, str], None]) -> Mapping[str, str]:
    """Accept a single secret str, a {key_id: secret} map, or None."""
    if not service_keys:
        return {}
    if isinstance(service_keys, str):
        return {"default": service_keys}
    return service_keys


# ── the combiner: SERVICE (api key) first, then USER (oidc) ──────────────────
def authenticate(
    header_get: Callable[[str], Optional[str]],
    *,
    service_keys: Union[str, Mapping[str, str], None] = None,
    api_key_header: str = DEFAULT_API_KEY_HEADER,
    oidc: "Optional[OIDCValidator]" = None,
) -> AuthResult:
    """Classify a request from its headers. `header_get(name)` returns the header
    value (or ''/None). Order: a presented API key MUST match (else deny — we do
    NOT silently downgrade a wrong key to anonymous); otherwise a Bearer is
    validated via `oidc`; otherwise deny (no credential)."""
    sent = (header_get(api_key_header) or "").strip()
    if sent:
        for kid, secret in _keymap(service_keys).items():
            if verify_api_key(sent, secret):
                return AuthResult(Principal("service", kid))
        return AuthResult(None, "invalid api key", 403)

    if oidc is not None:
        raw = (header_get("Authorization") or "")
        parts = raw.split()
        if len(parts) == 2 and parts[0] == "Bearer":
            try:
                claims = oidc.validate(parts[1])
            except Exception as exc:  # noqa: BLE001 — any validation failure denies
                return AuthResult(None, f"invalid bearer: {exc}", 401)
            sub = str(claims.get("sub") or "")
            if not sub:
                return AuthResult(None, "bearer missing sub", 401)
            username = str(claims.get("preferred_username") or claims.get("email") or sub)
            return AuthResult(Principal("user", sub,
                                        claims={**claims, "username": username},
                                        token=parts[1]))

    return AuthResult(None, "no credential", 401)


# ── Keycloak OIDC validator (RS256 + JWKS cache) ─────────────────────────────
# Ported verbatim-in-spirit from pod-gem viewer/auth.py + logsmith-cite cite/auth.py
# (same realm, same JWKS, same RS256 validation) — this is the de-duplication.
class OIDCValidator:
    """Validate a Keycloak OIDC access token: RS256 against the realm JWKS, with
    exp/iss (and optional aud) enforced. Thread-safe lazy JWKS cache with TTL +
    one forced refresh on kid-miss (key rotation). validate() returns the claims
    dict or raises. Lazy-imports PyJWT/requests so the bare package stays light."""

    def __init__(self, *, issuer: str, jwks_url: str, audience: Optional[str] = None,
                 jwks_ttl: float = 3600.0, http_timeout: float = 5.0,
                 algorithms=("RS256",)):
        self.issuer = issuer
        self.jwks_url = jwks_url
        self.audience = audience
        self.jwks_ttl = jwks_ttl
        self.http_timeout = http_timeout
        self.algorithms = list(algorithms)
        self._lock = threading.Lock()
        self._jwks = None
        self._fetched_at = 0.0

    # -- JWKS cache --
    def _refresh(self):
        import requests  # lazy
        resp = requests.get(self.jwks_url, timeout=self.http_timeout)
        resp.raise_for_status()
        jwks = resp.json()
        with self._lock:
            self._jwks = jwks
            self._fetched_at = time.time()

    def _lookup(self, kid):
        import jwt  # lazy
        with self._lock:
            jwks = self._jwks or {"keys": []}
        for k in jwks.get("keys", []):
            if k.get("kid") == kid:
                return jwt.algorithms.RSAAlgorithm.from_jwk(k)
        return None

    def _signing_key(self, token: str):
        import jwt  # lazy
        with self._lock:
            stale = (self._jwks is None) or (time.time() - self._fetched_at > self.jwks_ttl)
        if stale:
            self._refresh()
        kid = jwt.get_unverified_header(token).get("kid")
        key = self._lookup(kid)
        if key is None:                       # kid rotated since last fetch — refresh once
            self._refresh()
            key = self._lookup(kid)
        if key is None:
            raise ValueError("OIDC signing key not found for token kid")
        return key

    def validate(self, token: str) -> dict:
        import jwt  # lazy
        signing_key = self._signing_key(token)
        options = {"require": ["exp", "iss"]}
        kwargs = dict(algorithms=self.algorithms, issuer=self.issuer, options=options)
        if self.audience:
            kwargs["audience"] = self.audience
        else:
            options["verify_aud"] = False
        return jwt.decode(token, signing_key, **kwargs)
