"""Framework-neutral webhook **HMAC verification** primitive (PAPER-26).

The Svix/Clerk webhook signature dance was hand-rolled — identically — in
``mgf-auth`` (the Clerk credential verifier) and ``mgf-fastapi`` (the FastAPI
transport adapter), and the copies had already drifted three ways (whitespace
split, non-UTF-8 body guard, ``whsec_`` decoding). This is the single shared
home for the **crypto only**: no provider semantics, no framework types, stdlib
only. Each consumer keeps its own value-add and delegates the compare here:

- ``mgf-auth`` — the ``WebhookVerifier`` Protocol seam, ``whsec_``→``WebhookEvent``
  mapping, typed 401s. Never imports a web framework.
- ``mgf-fastapi`` — the ``Request``→headers/bytes lift, body-size cap (SC-08),
  audit logging (LG-15), and ``HmacVerificationError`` (per federation D3).

Both call :func:`verify_svix_hmac`; the domain/HTTP error translation stays in
the consumer (catch :class:`WebhookHmacError`, re-raise the domain error).
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping

__all__ = [
    "SVIX_SCHEMA",
    "WebhookHeaderSchema",
    "WebhookHmacError",
    "decode_svix_secret",
    "verify_svix_hmac",
]

_WHSEC_PREFIX = "whsec_"
_DEFAULT_REPLAY_WINDOW_SECONDS = 5 * 60
_VERSION_PREFIX = "v1,"


class WebhookHmacError(Exception):
    """Low-level webhook HMAC verification failure.

    Carries a machine-stable :attr:`reason` so consumers can map it to their
    own domain error (a 401, ``HmacVerificationError``, …) without string
    matching. This type is deliberately framework-neutral.
    """

    def __init__(self, reason: str) -> None:
        super().__init__(reason)
        self.reason = reason


@dataclass(frozen=True, slots=True)
class WebhookHeaderSchema:
    """Names of the three Svix-style headers a provider uses.

    Header names are matched **case-insensitively** against the input mapping;
    the values stored here are the canonical lowercase form.
    """

    id_header: str
    timestamp_header: str
    signature_header: str

    def __post_init__(self) -> None:
        object.__setattr__(self, "id_header", self.id_header.lower())
        object.__setattr__(self, "timestamp_header", self.timestamp_header.lower())
        object.__setattr__(self, "signature_header", self.signature_header.lower())


SVIX_SCHEMA = WebhookHeaderSchema(
    id_header="svix-id",
    timestamp_header="svix-timestamp",
    signature_header="svix-signature",
)
"""Svix-flavoured header names (used directly by Clerk). Other providers
(Stripe/GitHub/Slack) sign differently enough to need their own schema."""


def decode_svix_secret(secret: bytes | str) -> bytes:
    """Return the raw HMAC key from a Svix/Clerk webhook secret.

    Svix secrets are published as ``whsec_<base64>``; the HMAC key is the
    base64-decoded portion **after** the prefix (per the Svix spec + the
    official ``svix`` library). A secret whose post-prefix body is not valid
    base64 (dev/test secrets like ``whsec_test``) falls back to its raw bytes —
    harmless because real secrets are always base64 and both the verifier and
    any signing helper run this SAME function, so they stay consistent.
    """
    s = secret.decode("utf-8") if isinstance(secret, (bytes, bytearray)) else secret
    if s.startswith(_WHSEC_PREFIX):
        s = s[len(_WHSEC_PREFIX) :]
    try:
        return base64.b64decode(s, validate=True)
    except ValueError:  # binascii.Error subclasses ValueError → not base64
        return s.encode("utf-8")


def verify_svix_hmac(
    headers: Mapping[str, str],
    body: bytes,
    secret: bytes | str,
    *,
    schema: WebhookHeaderSchema = SVIX_SCHEMA,
    replay_window_seconds: int = _DEFAULT_REPLAY_WINDOW_SECONDS,
    now: Callable[[], float] = time.time,
) -> tuple[str, int]:
    """Verify a Svix-style webhook HMAC signature. Returns ``(msg_id, timestamp)``.

    The dance: require the three headers, reject a timestamp outside the replay
    window, compute ``HMAC-SHA256("{id}.{ts}.{body}")`` keyed by
    :func:`decode_svix_secret`, and constant-time compare against every
    space-separated ``v1,<sig>`` candidate (Svix sends a list to support key
    rotation). Raises :class:`WebhookHmacError` (with a stable ``reason``) on
    any failure — fail-closed; never returns on a mismatch. ``secret`` may be a
    ``whsec_…`` string (decoded here) or a raw key. ``now`` is injectable for
    tests.
    """
    lower = {k.lower(): v for k, v in headers.items()}
    msg_id = lower.get(schema.id_header)
    if msg_id is None:
        raise WebhookHmacError(f"{schema.id_header} header missing")
    ts_header = lower.get(schema.timestamp_header)
    if ts_header is None:
        raise WebhookHmacError(f"{schema.timestamp_header} header missing")
    sig_header = lower.get(schema.signature_header)
    if sig_header is None:
        raise WebhookHmacError(f"{schema.signature_header} header missing")

    try:
        ts = int(ts_header)
    except ValueError as exc:
        raise WebhookHmacError("timestamp is not an integer") from exc

    skew = abs(int(now()) - ts)
    if skew > replay_window_seconds:
        raise WebhookHmacError(f"timestamp outside replay window ({skew}s)")

    # Svix signs the RAW body bytes: `HMAC("{id}.{ts}.{body}")`. Prepend the
    # ascii prefix and append the body bytes directly — no decode/re-encode.
    # For UTF-8 JSON (every real webhook) this is byte-identical to decoding +
    # re-encoding, but it avoids two full passes over the body (a large-payload
    # hot path) and correctly handles arbitrary bytes.
    signed = f"{msg_id}.{ts}.".encode() + body
    digest = hmac.new(decode_svix_secret(secret), signed, hashlib.sha256).digest()
    expected = _VERSION_PREFIX + base64.b64encode(digest).decode("ascii")

    # Split on ANY whitespace (robust to multiple/odd separators) and match any
    # candidate in constant time — Svix rotates keys via a space-separated list.
    for candidate in sig_header.split():
        if hmac.compare_digest(candidate, expected):
            return msg_id, ts
    raise WebhookHmacError("HMAC mismatch")
