"""Clerk concrete :class:`SessionVerifier` (per ADR-004 + ADR-016).

Single responsibility: **session JWT verification** via PyJWT. Clerk
session tokens are RS256-signed by an RSA key pair owned by Clerk; the
public key is published per Clerk instance and configured into
PlasmaMapper via ``PLASMA_AUTH_CLERK_JWT_PUBLIC_KEY`` (PEM, multi-line).

The verifier is **stateless**. Identity resolution (Clerk
``user_***`` / ``org_***`` → PlasmaMapper ``user_id`` / ``tenant_id``
UUID) is delegated to a :data:`ClerkPrincipalResolver` callable
injected at construction. Two natural implementations:

  * **DB-resolver** (production): looks up the mirrored row in
    ``users`` / ``tenants`` via the Clerk webhook handler's populated
    ``external_id`` column.
  * **Test-resolver**: returns whatever the test seeds.

Keeping the resolver out of this module means the verifier has no DB
dependency — it composes with whatever identity strategy a consumer
chooses, including the Phase-8 Authentik fallback whose identity-
resolution shape may differ.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Generic, TypeVar

import jwt as pyjwt

from mgf.auth._errors import InvalidSessionError

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Mapping

__all__ = ["ClerkClaims", "ClerkPrincipalResolver", "ClerkSessionVerifier"]

# The consumer's session-principal type: the injected resolver produces
# it and verify_session returns it. mgf-auth stays agnostic to its shape
# (an app keeps its own role-typed principal).
P = TypeVar("P")

LOG = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class ClerkClaims:
    """Validated session JWT claims from Clerk.

    Returned by ``ClerkSessionVerifier``'s internal JWT verification;
    consumed by the injected :data:`ClerkPrincipalResolver` to
    produce a :class:`~plasmamapper.domain.SessionPrincipal`. The
    resolver maps Clerk's ``user_***`` / ``org_***`` identifiers
    into PlasmaMapper's UUID-shaped identifiers.

    Field reference (matching Clerk's documented JWT claim set):

      * ``sub`` — Clerk user id, e.g., ``user_2abc...``.
      * ``org_id`` — Clerk organisation id when the user has selected
        an organisation in the session; ``None`` when the user is
        outside any organisation context (rare in PlasmaMapper because
        every tenant maps 1:1 to a Clerk org).
      * ``org_role`` — Clerk organisation-membership role
        (``"owner"`` / ``"operator"`` / ``"manager"`` / ``"driver"``
        / ``"support"`` after our role-mapping configuration in Clerk).
      * ``email`` — primary email; resolver uses for `users.email`.
      * ``locale`` — IETF BCP 47 tag from Clerk's user metadata
        (``en-US`` default; ``ar-IQ`` for Iraqi Arabic).
      * ``issued_at`` / ``expires_at`` — ``iat`` / ``exp`` claims
        translated to ``datetime`` (UTC, second-precision).
      * ``sid`` — Clerk session id (``sess_***``). Required for R-06's
        session-revocation gate; left as ``str | None`` (default
        ``None``) so a Clerk JWT without it — legacy tokens, custom
        JWT templates that strip the claim — still verifies, but
        loses revocation tracking. The :class:`RevocationGate`
        passes principals with ``clerk_session_id is None`` through
        unchecked (no row to look up), matching the
        ``MockSessionVerifier`` behaviour.
    """

    sub: str
    org_id: str | None
    org_role: str | None
    email: str
    locale: str
    issued_at: datetime
    expires_at: datetime
    sid: str | None = None


# Type alias documented as a public name. Consumers (and the future
# DB-resolver factory) should import this rather than rebuilding the
# Callable signature each time.
ClerkPrincipalResolver = "Callable[[ClerkClaims], Awaitable[P]]"


class ClerkSessionVerifier(Generic[P]):
    """Production-shape :class:`SessionVerifier` backed by Clerk.

    Conforms structurally to the
    :class:`~plasmamapper.providers.auth.SessionVerifier` Protocol; the
    Protocol is ``runtime_checkable`` so ``isinstance(verifier,
    SessionVerifier)`` returns True.

    Constructor is keyword-only; concrete kwargs:

      * ``jwt_public_key`` — PEM-encoded RSA public key, multi-line
        string. Source: Clerk dashboard → JWT keys.
      * ``issuer`` — JWT ``iss`` claim, e.g.,
        ``https://clerk.acme.example``. Source: same dashboard page.
      * ``principal_resolver`` — async callable that maps
        :class:`ClerkClaims` to
        :class:`~plasmamapper.domain.SessionPrincipal`. Production
        wires this to a DB-lookup against ``users.external_id``;
        tests stub it.
    """

    def __init__(
        self,
        *,
        jwt_public_key: str,
        issuer: str,
        principal_resolver: Callable[[ClerkClaims], Awaitable[P]],
    ) -> None:
        self._public_key = jwt_public_key
        self._issuer = issuer
        self._principal_resolver = principal_resolver

    # ---- SessionVerifier Protocol implementation -------------------

    async def verify_session(self, token: str) -> P:
        if not token:
            raise InvalidSessionError("missing token")

        try:
            decoded = pyjwt.decode(
                token,
                self._public_key,
                algorithms=["RS256"],
                issuer=self._issuer,
                options={"require": ["exp", "iat", "sub"]},
            )
        except pyjwt.ExpiredSignatureError as exc:
            raise InvalidSessionError("expired") from exc
        except pyjwt.InvalidIssuerError as exc:
            raise InvalidSessionError("issuer mismatch") from exc
        except pyjwt.InvalidSignatureError as exc:
            raise InvalidSessionError("signature mismatch") from exc
        except pyjwt.InvalidTokenError as exc:
            # Catches every other PyJWT subclass (missing claims,
            # algorithm mismatch, malformed token, etc.). Don't leak
            # the detailed message past forensic logging.
            LOG.warning(
                "clerk jwt rejected: %s",
                exc,
                extra={
                    "event": "mgf.auth.session_rejected",  # LG-15: <subject>.<action>
                    "reason": type(
                        exc
                    ).__name__,  # LG-16: WHY as a queryable field, not in the message
                    # LG-17 — group by failure shape (the PyJWT subclass
                    # name), not by the formatted message. A burst of
                    # `ExpiredSignatureError` and a burst of
                    # `InvalidAlgorithmError` are different issues even
                    # though they share this log line.
                    "fingerprint": f"auth.clerk_jwt_rejected:{type(exc).__name__}",
                },
            )
            raise InvalidSessionError("invalid token") from exc

        claims = self._parse_claims(decoded)
        return await self._principal_resolver(claims)

    # ---- internals -------------------------------------------------

    def _parse_claims(self, raw: Mapping[str, object]) -> ClerkClaims:
        try:
            sub = _expect_str(raw, "sub")
            email = _expect_str(raw, "email")
            iat = _expect_int(raw, "iat")
            exp = _expect_int(raw, "exp")
        except _ClaimError as exc:
            raise InvalidSessionError(str(exc)) from exc

        org_id_raw = raw.get("org_id")
        org_id = org_id_raw if isinstance(org_id_raw, str) else None
        org_role_raw = raw.get("org_role")
        org_role = org_role_raw if isinstance(org_role_raw, str) else None
        locale_raw = raw.get("locale")
        locale = locale_raw if isinstance(locale_raw, str) else "en-US"
        sid_raw = raw.get("sid")
        sid = sid_raw if isinstance(sid_raw, str) else None

        return ClerkClaims(
            sub=sub,
            org_id=org_id,
            org_role=org_role,
            email=email,
            locale=locale,
            issued_at=datetime.fromtimestamp(iat, tz=UTC),
            expires_at=datetime.fromtimestamp(exp, tz=UTC),
            sid=sid,
        )


# ---- helpers --------------------------------------------------------


class _ClaimError(ValueError):
    """Internal sentinel for missing / wrong-typed JWT claims."""


def _expect_str(raw: Mapping[str, object], key: str) -> str:
    value = raw.get(key)
    if not isinstance(value, str):
        msg = f"claim {key!r} missing or not a string"
        raise _ClaimError(msg)
    return value


def _expect_int(raw: Mapping[str, object], key: str) -> int:
    value = raw.get(key)
    if not isinstance(value, int):
        msg = f"claim {key!r} missing or not an integer"
        raise _ClaimError(msg)
    return value
