"""Tests for ``mgf.fastapi.webhooks`` (closes PAPER-26 in mgf-common v0.26.0)."""

from __future__ import annotations

import base64
import hashlib
import hmac
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock

import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from mgf.common.exceptions import HttpUnauthorizedError
from mgf.common.webhooks import decode_svix_secret

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

from mgf.fastapi import ExceptionTranslationMiddleware
from mgf.fastapi.exceptions import HmacVerificationError
from mgf.fastapi.webhooks import (
    SVIX_SCHEMA,
    HmacWebhookVerifier,
    WebhookHeaderSchema,
    verify_request,
)

# ---------------------------------------------------------------------------
# Test fixtures: a deterministic signed payload
# ---------------------------------------------------------------------------

SECRET = b"whsec_test_secret_value"
EVENT_ID = "msg_2abc123def456"
TIMESTAMP = 1700000000  # 2023-11-14T22:13:20Z
BODY = b'{"event":"user.created","data":{"id":42}}'


def _sign(
    body: bytes, *, event_id: str = EVENT_ID, ts: int = TIMESTAMP, secret: bytes = SECRET
) -> str:
    """Compute the Svix-shape v1 signature for a body. Returns ``v1,<base64>``."""
    signed = f"{event_id}.{ts}.".encode() + body
    # Svix signs with the base64-decoded `whsec_` key (decode_svix_secret), not
    # the raw secret string — match the verifier (delegates to the shared
    # mgf.common.webhooks primitive as of mgf-common 0.49).
    digest = hmac.new(decode_svix_secret(secret), signed, hashlib.sha256).digest()
    return "v1," + base64.b64encode(digest).decode("ascii")


def _headers(
    *, event_id: str = EVENT_ID, ts: int = TIMESTAMP, sig: str | None = None
) -> dict[str, str]:
    """Build the three Svix headers; ``sig`` defaults to a valid one for ``BODY``."""
    return {
        "svix-id": event_id,
        "svix-timestamp": str(ts),
        "svix-signature": sig if sig is not None else _sign(BODY, event_id=event_id, ts=ts),
    }


def _verifier(*, replay_window: int = 300, now_ts: int = TIMESTAMP) -> HmacWebhookVerifier:
    return HmacWebhookVerifier(
        secret=SECRET,
        replay_window_seconds=replay_window,
        now=lambda: float(now_ts),
    )


# ---------------------------------------------------------------------------
# WebhookHeaderSchema
# ---------------------------------------------------------------------------


class TestWebhookHeaderSchema:
    def test_svix_preset_is_lowercase(self) -> None:
        assert SVIX_SCHEMA.id_header == "svix-id"
        assert SVIX_SCHEMA.timestamp_header == "svix-timestamp"
        assert SVIX_SCHEMA.signature_header == "svix-signature"

    def test_schema_lowercases_header_names(self) -> None:
        # Caller passes mixed case; canonical form is lowercased.
        s = WebhookHeaderSchema(
            id_header="X-Hub-Delivery",
            timestamp_header="X-Hub-Timestamp",
            signature_header="X-Hub-Signature",
        )
        assert s.id_header == "x-hub-delivery"
        assert s.timestamp_header == "x-hub-timestamp"
        assert s.signature_header == "x-hub-signature"

    def test_schema_is_frozen(self) -> None:
        from dataclasses import FrozenInstanceError

        with pytest.raises(FrozenInstanceError):
            SVIX_SCHEMA.id_header = "tampered"  # type: ignore[misc]


# ---------------------------------------------------------------------------
# Verifier — happy path + structural exception type
# ---------------------------------------------------------------------------


class TestVerifierHappyPath:
    def test_known_good_vector_returns_id_and_ts(self) -> None:
        v = _verifier()
        event_id, ts = v.verify(_headers(), BODY)
        assert event_id == EVENT_ID
        assert ts == TIMESTAMP

    def test_secret_str_is_utf8_encoded(self) -> None:
        # Constructor accepts str; behaviour matches bytes form.
        v_bytes = HmacWebhookVerifier(secret=SECRET, now=lambda: float(TIMESTAMP))
        v_str = HmacWebhookVerifier(secret=SECRET.decode("utf-8"), now=lambda: float(TIMESTAMP))
        assert v_bytes.verify(_headers(), BODY) == v_str.verify(_headers(), BODY)

    def test_case_insensitive_header_lookup(self) -> None:
        # Mixed-case headers (as common in proxies / framework code) still match.
        v = _verifier()
        sig = _sign(BODY)
        h_mixed = {
            "Svix-Id": EVENT_ID,
            "SVIX-TIMESTAMP": str(TIMESTAMP),
            "svix-Signature": sig,
        }
        event_id, _ts = v.verify(h_mixed, BODY)
        assert event_id == EVENT_ID

    def test_multi_version_signature_first_match_wins(self) -> None:
        # Key rotation: Svix sends ``v1,old_sig v1,new_sig`` space-separated.
        # Verifier accepts the body if ANY one matches the active secret.
        sig = _sign(BODY)
        wrong = "v1," + base64.b64encode(b"\x00" * 32).decode("ascii")
        h = _headers(sig=f"{wrong} {sig}")
        v = _verifier()
        event_id, _ts = v.verify(h, BODY)
        assert event_id == EVENT_ID

    def test_signatures_with_other_versions_ignored(self) -> None:
        # A ``v0,...`` (legacy) signature next to a valid ``v1,...`` is fine —
        # the verifier only inspects entries matching the configured prefix.
        sig = _sign(BODY)
        legacy = "v0,deadbeef"
        h = _headers(sig=f"{legacy} {sig}")
        v = _verifier()
        event_id, _ts = v.verify(h, BODY)
        assert event_id == EVENT_ID


# ---------------------------------------------------------------------------
# Verifier — failure modes (each must raise HmacVerificationError)
# ---------------------------------------------------------------------------


class TestVerifierFailures:
    def test_missing_id_header(self) -> None:
        v = _verifier()
        h = _headers()
        del h["svix-id"]
        with pytest.raises(HmacVerificationError, match="missing header 'svix-id'"):
            v.verify(h, BODY)

    def test_missing_timestamp_header(self) -> None:
        v = _verifier()
        h = _headers()
        del h["svix-timestamp"]
        with pytest.raises(HmacVerificationError, match="missing header 'svix-timestamp'"):
            v.verify(h, BODY)

    def test_missing_signature_header(self) -> None:
        v = _verifier()
        h = _headers()
        del h["svix-signature"]
        with pytest.raises(HmacVerificationError, match="missing header 'svix-signature'"):
            v.verify(h, BODY)

    def test_unparseable_timestamp(self) -> None:
        v = _verifier()
        h = _headers()
        h["svix-timestamp"] = "not-a-number"
        with pytest.raises(HmacVerificationError, match="not an integer"):
            v.verify(h, BODY)

    def test_stale_timestamp_outside_replay_window(self) -> None:
        # Sig was made at TIMESTAMP; check time is 10 minutes later.
        v = _verifier(replay_window=300, now_ts=TIMESTAMP + 600)
        with pytest.raises(HmacVerificationError, match="replay window"):
            v.verify(_headers(), BODY)

    def test_future_timestamp_outside_replay_window(self) -> None:
        # Future-skewed timestamp (clock drift / forged) is also rejected.
        v = _verifier(replay_window=300, now_ts=TIMESTAMP - 600)
        with pytest.raises(HmacVerificationError, match="replay window"):
            v.verify(_headers(), BODY)

    def test_tampered_body(self) -> None:
        v = _verifier()
        # Original body is signed; we present the signature against a different body.
        with pytest.raises(HmacVerificationError, match="signature mismatch"):
            v.verify(_headers(), BODY + b"X")

    def test_wrong_secret(self) -> None:
        # Signature was made with SECRET; verifier built with a different one.
        v = HmacWebhookVerifier(secret=b"wrong_secret", now=lambda: float(TIMESTAMP))
        with pytest.raises(HmacVerificationError, match="signature mismatch"):
            v.verify(_headers(), BODY)

    def test_signature_with_wrong_prefix_only(self) -> None:
        # Signature header carries only a non-v1 candidate (e.g., a future
        # version like v2). Verifier finds nothing matching its prefix → mismatch.
        h = _headers(sig="v2," + base64.b64encode(b"\x00" * 32).decode("ascii"))
        v = _verifier()
        with pytest.raises(HmacVerificationError, match="signature mismatch"):
            v.verify(h, BODY)

    def test_signature_no_prefix(self) -> None:
        # Plain base64 with no ``v1,`` prefix — current Svix shape requires it.
        sig = _sign(BODY).removeprefix("v1,")
        h = _headers(sig=sig)
        v = _verifier()
        with pytest.raises(HmacVerificationError, match="signature mismatch"):
            v.verify(h, BODY)

    def test_error_inherits_http_unauthorized(self) -> None:
        # Closed-loop check: HmacVerificationError is-a HttpUnauthorizedError
        # so PAPER-24's ``http_status`` resolution maps to 401.
        v = _verifier()
        try:
            v.verify({}, BODY)
        except HmacVerificationError as exc:
            assert isinstance(exc, HttpUnauthorizedError)
            assert exc.http_status == 401
        else:
            pytest.fail("expected HmacVerificationError")


# ---------------------------------------------------------------------------
# Custom schema (not Svix)
# ---------------------------------------------------------------------------


class TestCustomSchema:
    def test_alternate_header_names(self) -> None:
        # Hypothetical provider with different header names.
        custom = WebhookHeaderSchema(
            id_header="x-event-id",
            timestamp_header="x-event-ts",
            signature_header="x-event-sig",
        )
        v = HmacWebhookVerifier(
            secret=SECRET,
            schema=custom,
            now=lambda: float(TIMESTAMP),
        )
        sig = _sign(BODY)
        h = {
            "x-event-id": EVENT_ID,
            "x-event-ts": str(TIMESTAMP),
            "x-event-sig": sig,
        }
        event_id, _ts = v.verify(h, BODY)
        assert event_id == EVENT_ID

    def test_custom_signature_prefix(self) -> None:
        # Some providers use ``v1=`` (Stripe-flavour) instead of ``v1,``.
        v = HmacWebhookVerifier(
            secret=SECRET,
            signature_prefix="v1=",
            now=lambda: float(TIMESTAMP),
        )
        # Build the sig with the custom prefix.
        signed = f"{EVENT_ID}.{TIMESTAMP}.".encode() + BODY
        digest = hmac.new(decode_svix_secret(SECRET), signed, hashlib.sha256).digest()
        sig = "v1=" + base64.b64encode(digest).decode("ascii")
        h = _headers(sig=sig)
        event_id, _ts = v.verify(h, BODY)
        assert event_id == EVENT_ID


# ---------------------------------------------------------------------------
# FastAPI Request adapter (verify_request)
# ---------------------------------------------------------------------------


class TestVerifyRequestAdapter:
    def _build_app(self) -> FastAPI:
        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)
        verifier = _verifier()

        @app.post("/webhooks/test")
        async def handler(request: Request) -> dict[str, str]:
            event_id, ts = await verify_request(request, verifier)
            return {"event_id": event_id, "ts": str(ts)}

        return app

    def test_valid_signature_returns_200(self) -> None:
        client = TestClient(self._build_app())
        response = client.post("/webhooks/test", content=BODY, headers=_headers())
        assert response.status_code == 200
        assert response.json() == {"event_id": EVENT_ID, "ts": str(TIMESTAMP)}

    def test_missing_signature_header_returns_401(self) -> None:
        # ExceptionTranslationMiddleware + HTTP-01 + PAPER-24 compose:
        # HmacVerificationError → HttpUnauthorizedError → 401 without a status_map entry.
        client = TestClient(self._build_app())
        h = _headers()
        del h["svix-signature"]
        response = client.post("/webhooks/test", content=BODY, headers=h)
        assert response.status_code == 401

    def test_tampered_body_returns_401(self) -> None:
        client = TestClient(self._build_app())
        response = client.post("/webhooks/test", content=BODY + b"X", headers=_headers())
        assert response.status_code == 401

    def test_stale_timestamp_returns_401(self) -> None:
        # Build with a verifier whose ``now`` is well outside the window.
        app = FastAPI()
        app.add_middleware(ExceptionTranslationMiddleware)
        v = _verifier(now_ts=TIMESTAMP + 600)

        @app.post("/webhooks/test")
        async def handler(request: Request) -> dict[str, str]:
            event_id, _ts = await verify_request(request, v)
            return {"event_id": event_id}

        client = TestClient(app)
        response = client.post("/webhooks/test", content=BODY, headers=_headers())
        assert response.status_code == 401


class TestSignaturePrefixValidation:
    """SH-02: signature_prefix must be at least 2 chars at construction.

    A too-short prefix (e.g. "v") makes the version namespace ambiguous
    with the signature bytes — every candidate starting with "v"
    matches, defeating the version-namespace + key-rotation shape.
    """

    def test_empty_prefix_rejected(self) -> None:
        with pytest.raises(ValueError, match="signature_prefix"):
            HmacWebhookVerifier(secret=SECRET, signature_prefix="")

    def test_single_char_prefix_rejected(self) -> None:
        with pytest.raises(ValueError, match="at least 2"):
            HmacWebhookVerifier(secret=SECRET, signature_prefix="v")

    def test_two_char_prefix_accepted(self) -> None:
        """Boundary — exactly 2 chars is the minimum and must work."""
        v = HmacWebhookVerifier(secret=SECRET, signature_prefix="v1")
        assert v is not None

    def test_default_prefix_passes(self) -> None:
        """Sanity — the default `v1,` (3 chars) doesn't trip the guard."""
        v = HmacWebhookVerifier(secret=SECRET)
        assert v is not None


class TestSignatureHeaderTokenisation:
    """RA-02: HmacWebhookVerifier.verify splits the signature header
    on ANY whitespace (not just single space) — observed-in-the-wild
    proxies replace spaces with tabs, double-space, or trim edges.
    `str.split()` (no arg) handles all of these uniformly + drops
    empty strings."""

    def test_tab_separated_candidates_parse(self) -> None:
        """A tab between two version-prefixed signatures still parses
        the FIRST candidate correctly."""
        # Sign body with the real secret; build a sig_header where
        # the real signature is separated from a bogus one by a tab.
        body = b"test-body"
        v = _verifier()
        # Use the verifier's own sign-shape to build a known-good sig.
        msg = f"{EVENT_ID}.{TIMESTAMP}.".encode() + body
        digest = hmac.new(decode_svix_secret(SECRET), msg, hashlib.sha256).digest()
        good_sig = "v1," + base64.b64encode(digest).decode("ascii")

        # Tab between candidates.
        sig_header = f"{good_sig}\tv1,bogus_signature_bytes_here_padding"
        headers = {
            "svix-id": EVENT_ID,
            "svix-timestamp": str(TIMESTAMP),
            "svix-signature": sig_header,
        }
        event_id, ts = v.verify(headers, body)
        assert event_id == EVENT_ID
        assert ts == TIMESTAMP

    def test_multiple_spaces_between_candidates(self) -> None:
        body = b"test-body"
        v = _verifier()
        msg = f"{EVENT_ID}.{TIMESTAMP}.".encode() + body
        digest = hmac.new(decode_svix_secret(SECRET), msg, hashlib.sha256).digest()
        good_sig = "v1," + base64.b64encode(digest).decode("ascii")

        # Three spaces between candidates.
        sig_header = f"{good_sig}   v1,bogus_signature_bytes_padding"
        headers = {
            "svix-id": EVENT_ID,
            "svix-timestamp": str(TIMESTAMP),
            "svix-signature": sig_header,
        }
        event_id, _ = v.verify(headers, body)
        assert event_id == EVENT_ID

    def test_trailing_whitespace_trimmed(self) -> None:
        body = b"test-body"
        v = _verifier()
        msg = f"{EVENT_ID}.{TIMESTAMP}.".encode() + body
        digest = hmac.new(decode_svix_secret(SECRET), msg, hashlib.sha256).digest()
        good_sig = "v1," + base64.b64encode(digest).decode("ascii")

        # Trailing space — would have produced an empty candidate
        # under the old `split(" ")` shape; now it just drops.
        sig_header = f"{good_sig} "
        headers = {
            "svix-id": EVENT_ID,
            "svix-timestamp": str(TIMESTAMP),
            "svix-signature": sig_header,
        }
        event_id, _ = v.verify(headers, body)
        assert event_id == EVENT_ID


async def _stream_of(*chunks: bytes) -> AsyncIterator[bytes]:
    """Async generator standing in for ``Request.stream()`` in unit tests."""
    for chunk in chunks:
        yield chunk


def _fake_request(*, body_chunks: tuple[bytes, ...], headers: dict[str, str]) -> Any:
    """A minimal ``Request`` double: streams ``body_chunks`` and exposes ``headers``."""
    request = MagicMock()
    request.headers = headers
    request.stream = lambda: _stream_of(*body_chunks)
    return request


class TestBodyDoubleReadDefence:
    """RA-04: verify_request detects the body-already-consumed case
    and raises an actionable HmacVerificationError naming the cause."""

    @pytest.mark.asyncio
    async def test_empty_body_with_content_length_raises_diagnostic(
        self,
    ) -> None:
        # A Request whose body stream is empty but whose Content-Length
        # header indicates non-zero. Simulates an upstream middleware that
        # consumed the stream before verify_request ran.
        request = _fake_request(body_chunks=(), headers={"content-length": "42"})

        v = _verifier()
        with pytest.raises(HmacVerificationError, match="upstream middleware") as exc_info:
            await verify_request(request, v)
        assert exc_info.value.reason == "body_consumed"

    @pytest.mark.asyncio
    async def test_empty_body_with_zero_content_length_passes_through(
        self,
    ) -> None:
        """When the body IS legitimately empty (Content-Length: 0),
        verify_request hands off to the verifier normally — the
        verifier will then raise its own signature-mismatch error
        (or accept, depending on the signature)."""
        request = _fake_request(body_chunks=(), headers={"content-length": "0"})

        v = _verifier()
        # Verifier raises HmacVerificationError on missing svix-id, but
        # NOT the RA-04 diagnostic — the upstream-consumed check passes.
        with pytest.raises(HmacVerificationError) as exc_info:
            await verify_request(request, v)
        # Confirm we got past the RA-04 diagnostic.
        assert "upstream middleware" not in str(exc_info.value), (
            "RA-04: the diagnostic fired on a legitimately-empty body. "
            "Should only fire when Content-Length suggests there were "
            "bytes to read but we got nothing back."
        )

    @pytest.mark.asyncio
    async def test_empty_body_no_content_length_passes_through(
        self,
    ) -> None:
        """No Content-Length header (rare but legal) doesn't trip the
        diagnostic — we don't know if the body was supposed to have
        bytes, so don't accuse upstream."""
        request = _fake_request(body_chunks=(), headers={})

        v = _verifier()
        with pytest.raises(HmacVerificationError) as exc_info:
            await verify_request(request, v)
        assert "upstream middleware" not in str(exc_info.value)


class TestBodySizeCap:
    """SC-08: verify_request caps the webhook body BEFORE buffering/HMAC.

    The body is the only attacker-controlled, pre-auth input on a webhook
    route, so an unbounded body is a pre-auth memory/CPU DoS.
    """

    @pytest.mark.asyncio
    async def test_oversized_content_length_rejected_before_streaming(self) -> None:
        # A declared Content-Length over the cap is rejected up front —
        # the body stream is never even touched (stream() would raise if
        # called, proving we short-circuited).
        def _boom() -> AsyncIterator[bytes]:
            raise AssertionError("stream() must not be read when Content-Length over cap")

        request = MagicMock()
        request.headers = {"content-length": "2048"}
        request.stream = _boom

        v = _verifier()
        with pytest.raises(HmacVerificationError, match="Content-Length") as exc_info:
            await verify_request(request, v, max_body_bytes=1024)
        assert exc_info.value.reason == "body_too_large"

    @pytest.mark.asyncio
    async def test_oversized_stream_rejected_when_content_length_absent(self) -> None:
        # No (or lying) Content-Length: the stream itself is capped, so an
        # oversized body is rejected mid-stream rather than fully buffered.
        big = b"x" * 600
        request = _fake_request(body_chunks=(big, big), headers={})

        v = _verifier()
        with pytest.raises(HmacVerificationError, match="exceeds") as exc_info:
            await verify_request(request, v, max_body_bytes=1024)
        assert exc_info.value.reason == "body_too_large"

    @pytest.mark.asyncio
    async def test_body_at_cap_passes_through_to_verifier(self) -> None:
        # A body exactly at the cap is NOT rejected for size — it reaches
        # the verifier (which then rejects it for the bad signature).
        body = b"x" * 1024
        request = _fake_request(
            body_chunks=(body,),
            headers={"content-length": "1024", **_headers()},
        )

        v = _verifier()
        with pytest.raises(HmacVerificationError) as exc_info:
            await verify_request(request, v, max_body_bytes=1024)
        assert exc_info.value.reason != "body_too_large"

    @pytest.mark.asyncio
    async def test_non_positive_cap_rejected(self) -> None:
        request = _fake_request(body_chunks=(BODY,), headers=_headers())
        v = _verifier()
        with pytest.raises(ValueError, match="max_body_bytes must be positive"):
            await verify_request(request, v, max_body_bytes=0)


class TestVerificationFailedAuditLog:
    """LG-15: every rejected webhook leaves an audit trail."""

    @pytest.mark.asyncio
    async def test_forged_webhook_emits_audit_event(self, caplog: pytest.LogCaptureFixture) -> None:
        # A tampered body → signature_mismatch; the failure is logged once
        # with the stable audit event + machine-readable reason.
        request = _fake_request(body_chunks=(BODY + b"X",), headers=_headers())
        v = _verifier()
        with caplog.at_level(logging.WARNING), pytest.raises(HmacVerificationError):
            await verify_request(request, v)

        records = [
            r
            for r in caplog.records
            if getattr(r, "event", None) == "mgf.fastapi.webhook.verification_failed"
        ]
        assert len(records) == 1
        assert records[0].reason == "signature_mismatch"  # type: ignore[attr-defined]
        assert records[0].audit is True  # type: ignore[attr-defined]
