"""Transport-agnostic Svix-shape HMAC webhook verifier.

The verifier core takes a ``Mapping[str, str]`` of headers and a
``bytes`` body — no FastAPI / Starlette / framework dependency.
The framework adapter (``verify_request``) lives separately so the
core is testable in isolation and reusable from non-FastAPI code
paths (CLI, batch jobs, alternative HTTP frameworks).

The signing scheme is Svix's: ``HMAC-SHA256(<id>.<ts>.<body>)`` keyed
on the shared secret, base64-encoded, prefixed ``v1,`` per signature
version, multiple versions space-separated for key rotation. Clerk
ships Svix verbatim; Stripe / GitHub / Slack use related-but-distinct
shapes (combined headers, hex encoding, alternate prefixes) and are
NOT covered at v0.26 — see PAPER-26 retrospective.

As of mgf-common 0.49 the **crypto itself** is delegated to the shared
:func:`mgf.common.webhooks.verify_svix_hmac` primitive (PAPER-26): the
identical Svix dance was hand-rolled here and in ``mgf-auth`` and had
drifted three ways (whitespace split, the non-UTF-8-body guard, and
``whsec_`` base64 decoding). This module keeps its transport-agnostic
wrapper, the configurable ``signature_prefix``, and — per federation
decision **D3** — the FastAPI-domain :class:`HmacVerificationError`
(message + EH-08 ``reason``) it raises; only the header/timestamp/HMAC
compare moves into the primitive. The :class:`WebhookHeaderSchema` and
:data:`SVIX_SCHEMA` here are re-exports of the primitive's (same shape).
"""

from __future__ import annotations

import time
from typing import TYPE_CHECKING

from mgf.common.webhooks import (
    SVIX_SCHEMA,
    WebhookHeaderSchema,
    WebhookHmacError,
    verify_svix_hmac,
)

from mgf.fastapi.exceptions import HmacVerificationError

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping

__all__ = [
    "SVIX_SCHEMA",
    "HmacWebhookVerifier",
    "WebhookHeaderSchema",
]

# The canonical Svix signature-version prefix that the shared primitive
# verifies. A verifier configured with a different ``signature_prefix`` has
# its candidates normalised to this shape before delegating (see ``verify``).
_SVIX_SIGNATURE_PREFIX = "v1,"


class HmacWebhookVerifier:
    """Verify a Svix-shape HMAC-signed webhook.

    The verifier is constructed once per webhook secret and reused
    across requests. It is stateless and thread-safe; concurrent
    callers MAY share a single instance.

    :param secret: The shared secret. ``bytes`` is preferred; ``str``
        is encoded as UTF-8 for convenience. For Svix/Clerk, the
        ``whsec_…`` prefix is part of the secret and SHOULD be passed
        through verbatim (do not strip) — the primitive base64-decodes
        the post-prefix body into the real HMAC key per the Svix spec.
    :param schema: Header-name schema. Defaults to :data:`SVIX_SCHEMA`.
    :param replay_window_seconds: Maximum age (in seconds) of a webhook
        timestamp to accept. Default 300 (5 minutes), matching Svix's
        published recommendation.
    :param signature_prefix: The version prefix. Svix uses ``"v1,"``
        (the comma is part of the prefix; the base64 sig follows).
        Multi-version sigs are space-separated; the verifier accepts
        any one matching this prefix.
    :param now: Override the timestamp source. Tests pass a fixed
        ``lambda: 1614265330``; production uses the default
        :func:`time.time`. Returns float seconds-since-epoch.
    """

    __slots__ = (
        "_now",
        "_replay_window_seconds",
        "_schema",
        "_secret",
        "_signature_prefix",
    )

    def __init__(
        self,
        *,
        secret: bytes | str,
        schema: WebhookHeaderSchema = SVIX_SCHEMA,
        replay_window_seconds: int = 300,
        signature_prefix: str = "v1,",
        now: Callable[[], float] | None = None,
    ) -> None:
        # SH-02: a too-short `signature_prefix` makes the version
        # namespace ambiguous with the signature bytes themselves —
        # e.g., `signature_prefix="v"` matches every candidate that
        # starts with "v", defeating the version-namespace + key-
        # rotation shape. Require at least 2 chars (the default
        # `"v1,"` is 3). The minimum is conservative; the Svix
        # convention always has at least a version + delimiter.
        if not signature_prefix or len(signature_prefix) < 2:
            raise ValueError(
                f"signature_prefix must be at least 2 characters to "
                f"avoid ambiguity with signature bytes "
                f"(got {signature_prefix!r}). The Svix convention is "
                f"a version label + comma, e.g. 'v1,'. (SH-02.)"
            )
        self._secret = secret.encode("utf-8") if isinstance(secret, str) else secret
        self._schema = schema
        self._replay_window_seconds = replay_window_seconds
        self._signature_prefix = signature_prefix
        self._now = now if now is not None else time.time

    def verify(
        self,
        headers: Mapping[str, str],
        body: bytes,
    ) -> tuple[str, int]:
        """Verify the signature on ``body`` against the headers.

        :returns: ``(event_id, timestamp)`` on success — the verified
            header values, useful for callers that want to log the
            event id or de-dupe.
        :raises HmacVerificationError: on missing header, unparseable
            timestamp, replay-window miss, or signature mismatch (a
            non-UTF-8 body — fail-closed at the primitive — surfaces as
            a signature mismatch too). The message gives the proximate
            cause (no secret material).
        """
        # Delegate the crypto to the shared primitive. The primitive verifies
        # the canonical Svix ``v1,`` shape; for a custom ``signature_prefix``
        # rewrite the matching candidates to that shape first (the HMAC digest
        # is prefix-independent), keeping the verifier's configurable-prefix
        # contract while the compare still runs in the primitive.
        prepared: Mapping[str, str]
        if self._signature_prefix == _SVIX_SIGNATURE_PREFIX:
            prepared = headers
        else:
            # Lowercase the keys (as the primitive does) before rewriting so a
            # differently-cased original signature key can't shadow ours.
            lowered = {k.lower(): v for k, v in headers.items()}
            sig = lowered.get(self._schema.signature_header)
            if sig is not None:
                lowered[self._schema.signature_header] = " ".join(
                    _SVIX_SIGNATURE_PREFIX + token[len(self._signature_prefix) :]
                    for token in sig.split()
                    if token.startswith(self._signature_prefix)
                )
            prepared = lowered

        try:
            return verify_svix_hmac(
                prepared,
                body,
                self._secret,
                schema=self._schema,
                replay_window_seconds=self._replay_window_seconds,
                now=self._now,
            )
        except WebhookHmacError as exc:
            # D3: translate the framework-neutral primitive failure into the
            # FastAPI-domain error, preserving the verifier's historical
            # message + EH-08 machine-readable reason.
            raise self._to_domain_error(exc) from exc

    def _to_domain_error(self, exc: WebhookHmacError) -> HmacVerificationError:
        """Map a primitive :class:`WebhookHmacError` onto the historical
        :class:`HmacVerificationError` message + EH-08 ``reason`` code."""
        reason = exc.reason
        if reason.endswith("header missing"):
            header = reason.removesuffix(" header missing")
            return HmacVerificationError(f"missing header {header!r}", reason="missing_header")
        if "is not an integer" in reason:
            return HmacVerificationError(
                f"header {self._schema.timestamp_header!r} is not an integer",
                reason="bad_timestamp",
            )
        if reason.startswith("timestamp outside replay window"):
            return HmacVerificationError(
                f"timestamp outside replay window ({self._replay_window_seconds}s)",
                reason="replay_window",
            )
        # HMAC mismatch, or a non-UTF-8 body (0.49's fail-closed guard): from
        # the caller's perspective both are a failed signature check.
        return HmacVerificationError("signature mismatch", reason="signature_mismatch")
