"""Deterministic auth-verifier mocks for tests (per ADR-016).

Split per concern to mirror the production
:class:`~plasmamapper.providers.auth.SessionVerifier` /
:class:`~plasmamapper.providers.auth.WebhookVerifier` seams:

  * :class:`MockSessionVerifier` — pre-seeded ``token → principal``
    sessions. Tests construct it, call :meth:`~MockSessionVerifier.add_session`
    for each deterministic principal they need, and pass it anywhere
    the production code expects a :class:`SessionVerifier`.

  * :class:`MockWebhookVerifier` — two-mode webhook verification:

      * **Default (no secret configured) — permissive.** Accepts any
        well-formed JSON body and treats the ``svix-id`` header (or a
        payload ``id`` field) as the dedup key — no HMAC check, useful
        for testing the mirror handler's logic in isolation.
      * **Signature-mode (secret configured via
        :meth:`~MockWebhookVerifier.configure_webhook_secret`) — real
        Svix HMAC.** Tests that want to exercise the verification path
        produce signatures with the same algorithm Svix uses
        (``HMAC-SHA256(<id>.<ts>.<body>)``) and the mock validates them.

Each mock is **a concrete class**, not a subclass of its Protocol —
the Protocols are structural so any class with matching signatures
conforms.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Generic, TypeVar

from mgf.common.webhooks import WebhookHmacError, verify_svix_hmac

from mgf.auth._errors import (
    InvalidSessionError,
    InvalidWebhookSignatureError,
)
from mgf.auth._events import WebhookEvent
from mgf.auth._svix import _decode_svix_secret

if TYPE_CHECKING:
    from collections.abc import Mapping

__all__ = ["MockSessionVerifier", "MockWebhookVerifier"]

# The consumer's session-principal type (see mgf.auth.clerk_session.P).
P = TypeVar("P")

# Svix's allowed clock skew for replay protection.
_SVIX_REPLAY_WINDOW_SECONDS = 5 * 60


class MockSessionVerifier(Generic[P]):
    """Deterministic in-process :class:`SessionVerifier` for tests.

    Generic over the consumer's principal type ``P``::

        verifier: MockSessionVerifier[MyPrincipal] = MockSessionVerifier()
        verifier.add_session("test-token-faraj", _faraj_principal())
        principal = await verifier.verify_session("test-token-faraj")
    """

    def __init__(self) -> None:
        self._sessions: dict[str, P] = {}

    # ---- session management ----------------------------------------

    def add_session(self, token: str, principal: P) -> None:
        """Pre-load a session token → principal mapping."""
        if not token:
            msg = "session token must be a non-empty string"
            raise ValueError(msg)
        self._sessions[token] = principal

    def remove_session(self, token: str) -> None:
        """Simulate a session being revoked.

        Subsequent :meth:`verify_session` calls with this token raise
        :class:`InvalidSessionError`. Calling on an unknown token is
        a no-op so test code can teardown idempotently.
        """
        self._sessions.pop(token, None)

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

    async def verify_session(self, token: str) -> P:
        if not token:
            raise InvalidSessionError("missing token")
        try:
            return self._sessions[token]
        except KeyError as exc:
            # Mirror production discipline: tests that lean on this
            # path rely on a stable, content-free message — the
            # token itself is sensitive and never logged.
            raise InvalidSessionError("unknown token") from exc


class MockWebhookVerifier:
    """Deterministic in-process :class:`WebhookVerifier` for tests.

    .. code-block:: python

        verifier = MockWebhookVerifier()
        verifier.configure_webhook_secret(b"whsec_...")
        body = json.dumps({...}).encode()
        headers = MockWebhookVerifier.sign(secret=b"whsec_...", body=body)
        event = await verifier.verify_webhook(headers, body)
    """

    def __init__(self) -> None:
        self._webhook_secret: bytes | str | None = None

    # ---- webhook configuration -------------------------------------

    def configure_webhook_secret(self, secret: bytes | str | None) -> None:
        """Switch into signature-mode (or back to permissive mode).

        ``None`` (the default) skips HMAC verification and accepts any
        well-formed JSON body — useful for testing the mirror
        handler's downstream logic in isolation. A non-None value
        enables full Svix HMAC verification for inputs that should
        prove the verification code path.
        """
        # Store the RAW secret (or None): verify_svix_hmac decodes the
        # ``whsec_`` form internally (exactly once). Pre-decoding here would
        # double-decode and break every signature.
        self._webhook_secret = secret

    @staticmethod
    def sign(
        *,
        secret: bytes,
        body: bytes,
        event_id: str = "msg_test",
        timestamp: int | None = None,
    ) -> dict[str, str]:
        """Produce the Svix headers for the given body + secret.

        Tests that opt into signature-mode (via
        :meth:`configure_webhook_secret`) call this to obtain a
        valid header set, then pass ``(headers, body)`` straight to
        :meth:`verify_webhook`. The output is a plain ``dict[str, str]``
        with lower-cased keys, matching the expected
        ``Mapping[str, str]`` shape on the seam.

        ``timestamp`` defaults to ``time.time()``. Use a stale
        timestamp deliberately to exercise the replay-window
        rejection path.
        """
        ts = int(timestamp if timestamp is not None else time.time())
        signed = f"{event_id}.{ts}.{body.decode('utf-8')}".encode()
        digest = hmac.new(_decode_svix_secret(secret), signed, hashlib.sha256).digest()
        sig_b64 = base64.b64encode(digest).decode("ascii")
        return {
            "svix-id": event_id,
            "svix-timestamp": str(ts),
            "svix-signature": f"v1,{sig_b64}",
        }

    # ---- WebhookVerifier Protocol implementation -------------------

    async def verify_webhook(self, headers: Mapping[str, str], body: bytes) -> WebhookEvent:
        # Caller normalises header keys to lower-case; defensive
        # lookup in case a test forgets.
        h = {k.lower(): v for k, v in headers.items()}

        if self._webhook_secret is not None:
            self._verify_svix_signature(h, body)

        try:
            payload = json.loads(body)
        except json.JSONDecodeError as exc:
            raise InvalidWebhookSignatureError("body is not valid JSON") from exc
        if not isinstance(payload, dict):
            raise InvalidWebhookSignatureError("body is not a JSON object")

        event_type_raw = payload.get("type")
        if not isinstance(event_type_raw, str):
            raise InvalidWebhookSignatureError("missing 'type' string in payload")

        event_id = h.get("svix-id") or _coerce_to_str(payload.get("id")) or "mock-event"

        # Cast for the WebhookEvent dataclass's payload type
        # (``dict[str, object]``); JSON loading guarantees object-typed
        # values, so this is an annotation-level downcast.
        event_payload: dict[str, object] = dict(payload)

        return WebhookEvent(
            event_type=event_type_raw,
            event_id=event_id,
            payload=event_payload,
            received_at=datetime.now(UTC),
        )

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

    def _verify_svix_signature(self, headers: Mapping[str, str], body: bytes) -> None:
        if self._webhook_secret is None:  # pragma: no cover — guarded by caller
            return
        # Same delegation as the production ClerkWebhookVerifier: the shared
        # primitive owns the dance, we own the typed-401 translation.
        try:
            verify_svix_hmac(
                headers,
                body,
                self._webhook_secret,
                replay_window_seconds=_SVIX_REPLAY_WINDOW_SECONDS,
            )
        except WebhookHmacError as exc:
            raise InvalidWebhookSignatureError(exc.reason) from exc


def _coerce_to_str(value: object) -> str | None:
    return value if isinstance(value, str) else None
