"""Tests for ``mgf.fastapi.security``: IpAllowlist (PAPER-27) and bearer_session (PAPER-29)."""

from __future__ import annotations

from dataclasses import dataclass
from typing import ClassVar

import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from mgf.common.exceptions import HttpForbiddenError, HttpUnauthorizedError

from mgf.fastapi import ExceptionTranslationMiddleware
from mgf.fastapi.security import LOOPBACK_CIDRS, IpAllowlist, bearer_session

# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------


class TestConstruction:
    def test_default_cidrs_cover_loopback(self) -> None:
        # Default is loopback-only so tests work out-of-the-box; consumers
        # OVERRIDE for production.
        assert LOOPBACK_CIDRS == ("127.0.0.0/8", "::1/128")
        gate = IpAllowlist()
        assert gate is not None  # parses without raising

    def test_invalid_cidr_raises_at_construction(self) -> None:
        # Misconfiguration surfaces at construction, not first request.
        with pytest.raises(ValueError, match="invalid CIDR"):
            IpAllowlist(cidrs=["not-a-cidr"])

    def test_empty_cidrs_construction_succeeds(self) -> None:
        # Empty allowlist is a valid "endpoint disabled" state.
        gate = IpAllowlist(cidrs=[])
        assert gate is not None

    def test_trust_proxy_default_false(self) -> None:
        # Default off — explicit deploy decision required to honour
        # X-Forwarded-For. The proxy-trust footgun is real.
        gate = IpAllowlist()
        assert gate._trust_proxy is False  # type: ignore[reportPrivateUsage]


# ---------------------------------------------------------------------------
# FastAPI dependency form
# ---------------------------------------------------------------------------


def _build_app(gate: IpAllowlist) -> FastAPI:
    """Build a FastAPI app gated by ``gate`` on /admin."""
    app = FastAPI()
    app.add_middleware(ExceptionTranslationMiddleware)

    @app.get("/admin/ping", dependencies=[Depends(gate)])
    def ping() -> dict[str, bool]:
        return {"ok": True}

    return app


class TestFastApiDependencyForm:
    def test_loopback_via_xff_allows_when_trust_proxy(self) -> None:
        # FastAPI TestClient sets request.client.host = "testclient"
        # which doesn't parse as an IP — the gate correctly rejects
        # that as unparseable. To exercise the loopback default end-
        # to-end, use trust_proxy=True and an explicit XFF.
        gate = IpAllowlist(trust_proxy=True)  # default LOOPBACK_CIDRS
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "127.0.0.1"})
        assert response.status_code == 200

    def test_no_cidrs_denies_everything(self) -> None:
        gate = IpAllowlist(cidrs=[])
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping")
        assert response.status_code == 403

    def test_unparseable_client_host_returns_403(self) -> None:
        # FastAPI TestClient sets request.client.host = "testclient"
        # which doesn't parse as an IP. Without trust_proxy, the gate
        # falls into the unparseable branch.
        gate = IpAllowlist(cidrs=["10.0.0.0/8"])
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping")
        assert response.status_code == 403

    def test_ipv4_match_allows(self) -> None:
        # Use the trust_proxy path to inject a known IPv4 via X-Forwarded-For.
        gate = IpAllowlist(cidrs=["10.0.0.0/8"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "10.5.5.5"})
        assert response.status_code == 200

    def test_ipv4_mismatch_denies(self) -> None:
        gate = IpAllowlist(cidrs=["10.0.0.0/8"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "192.168.1.1"})
        assert response.status_code == 403

    def test_ipv6_match_allows(self) -> None:
        gate = IpAllowlist(cidrs=["fe80::/10"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "fe80::1"})
        assert response.status_code == 200

    def test_ipv4_in_ipv6_network_denies(self) -> None:
        # Cross-version: an IPv4 client against an IPv6 allowlist
        # MUST NOT match (even though IPv4-mapped-IPv6 ::ffff:10.5.5.5
        # is a thing — we explicitly skip cross-version compares).
        gate = IpAllowlist(cidrs=["::/0"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "10.5.5.5"})
        assert response.status_code == 403


# ---------------------------------------------------------------------------
# Proxy trust
# ---------------------------------------------------------------------------


class TestProxyTrust:
    def test_trust_proxy_false_ignores_xff(self) -> None:
        # Even with X-Forwarded-For set, default trust_proxy=False
        # ignores the header and reads request.client.host.
        gate = IpAllowlist(cidrs=["10.0.0.0/8"], trust_proxy=False)
        client = TestClient(_build_app(gate))
        # Spoof attempt: set XFF to an allowed IP. Default-deny should
        # still fire because trust_proxy is off.
        response = client.get("/admin/ping", headers={"X-Forwarded-For": "10.5.5.5"})
        assert response.status_code == 403

    def test_trust_proxy_true_takes_first_xff_entry(self) -> None:
        # X-Forwarded-For carries chained proxies as comma-separated.
        # The originating client is the FIRST entry.
        gate = IpAllowlist(cidrs=["10.0.0.0/8"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get(
            "/admin/ping",
            headers={"X-Forwarded-For": "10.5.5.5, 192.168.1.1, 192.168.1.2"},
        )
        assert response.status_code == 200

    def test_trust_proxy_true_no_xff_falls_back_to_client_host(self) -> None:
        # No XFF header — even with trust_proxy=True, we read client.host.
        gate = IpAllowlist(cidrs=["0.0.0.0/0", "::/0"], trust_proxy=True)
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping")  # no XFF
        # The TestClient's client.host doesn't parse, so this hits the
        # unparseable branch and 403s. That's the right shape — no XFF
        # means the gate has only the unverified client.host to work with.
        assert response.status_code == 403


# ---------------------------------------------------------------------------
# HmacVerificationError-style: 403 maps via HTTP-01 + ExceptionTranslationMiddleware
# ---------------------------------------------------------------------------


class TestExceptionMapping:
    def test_403_response_via_http_01_resolution(self) -> None:
        # IpAllowlist raises HttpForbiddenError (an HTTP-01 leaf with
        # http_status=403). ExceptionTranslationMiddleware reads the
        # http_status fallback (PAPER-24) and maps to 403 — no
        # status_map entry needed.
        gate = IpAllowlist(cidrs=[])
        client = TestClient(_build_app(gate))
        response = client.get("/admin/ping")
        assert response.status_code == 403
        body = response.json()
        assert body["error"]["type"] == "HttpForbiddenError"

    def test_raised_error_subclass(self) -> None:
        # The raise inside __call__ is HttpForbiddenError, not a generic
        # ValueError or RuntimeError.
        gate = IpAllowlist(cidrs=[])

        class _FakeRequest:
            client = None
            headers: ClassVar[dict[str, str]] = {}

        with pytest.raises(HttpForbiddenError):
            gate(_FakeRequest())  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# bearer_session (PAPER-29) — AuthVerifier protocol + factory
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class _FakeSession:
    """Stand-in for the consumer's principal type."""

    user_id: str
    email: str


class _FakeVerifier:
    """Bearer-token verifier used to drive bearer_session tests.

    The protocol shape is: ``async def verify_session(token) -> P``.
    This impl accepts ``token == "good"`` for the happy path and raises
    on anything else. Tests override the failure mode by setting
    ``failure`` to a different exception instance.
    """

    def __init__(self) -> None:
        self.calls: list[str] = []
        self.failure: Exception | None = None

    async def verify_session(self, token: str) -> _FakeSession:
        self.calls.append(token)
        if self.failure is not None:
            raise self.failure
        if token == "good":
            return _FakeSession(user_id="u1", email="alice@example.org")
        msg = f"unknown token: {token!r}"
        raise ValueError(msg)


def _build_session_app(
    verifier: _FakeVerifier | None,
    *,
    cookie_name: str | None = None,
    verifier_attr: str = "auth_provider",
) -> FastAPI:
    """Build a FastAPI app gated on /me by ``bearer_session()``.

    ``verifier=None`` simulates the misconfiguration case (no verifier
    on app.state).
    """
    app = FastAPI()
    app.add_middleware(ExceptionTranslationMiddleware)
    if verifier is not None:
        setattr(app.state, verifier_attr, verifier)

    dep = bearer_session(verifier_attr, cookie_name=cookie_name)

    @app.get("/me")
    async def me(session: _FakeSession = Depends(dep)) -> dict[str, str]:
        return {"user_id": session.user_id, "email": session.email}

    return app


class TestBearerSessionHappyPath:
    def test_valid_bearer_header_returns_principal(self) -> None:
        client = TestClient(_build_session_app(_FakeVerifier()))
        response = client.get("/me", headers={"Authorization": "Bearer good"})
        assert response.status_code == 200
        assert response.json() == {"user_id": "u1", "email": "alice@example.org"}

    def test_scheme_match_is_case_insensitive(self) -> None:
        # RFC 7235 says auth-schemes are case-insensitive. Common
        # capitalisations in the wild: "Bearer", "bearer", "BEARER".
        client = TestClient(_build_session_app(_FakeVerifier()))
        for prefix in ("Bearer", "bearer", "BEARER"):
            r = client.get("/me", headers={"Authorization": f"{prefix} good"})
            assert r.status_code == 200, f"failed for prefix {prefix!r}"

    def test_token_is_trimmed(self) -> None:
        # Leading/trailing whitespace in the token portion is stripped —
        # the verifier receives the bare value.
        verifier = _FakeVerifier()
        client = TestClient(_build_session_app(verifier))
        response = client.get("/me", headers={"Authorization": "Bearer  good  "})
        assert response.status_code == 200
        assert verifier.calls == ["good"]


class TestBearerSessionFailureModes:
    def test_missing_authorization_header(self) -> None:
        client = TestClient(_build_session_app(_FakeVerifier()))
        response = client.get("/me")
        assert response.status_code == 401
        assert response.json()["error"]["type"] == "HttpUnauthorizedError"

    def test_wrong_scheme(self) -> None:
        client = TestClient(_build_session_app(_FakeVerifier()))
        response = client.get("/me", headers={"Authorization": "Basic dXNlcjpwYXNz"})
        assert response.status_code == 401

    def test_empty_bearer_token(self) -> None:
        client = TestClient(_build_session_app(_FakeVerifier()))
        response = client.get("/me", headers={"Authorization": "Bearer "})
        assert response.status_code == 401

    def test_bare_bearer_with_no_value(self) -> None:
        client = TestClient(_build_session_app(_FakeVerifier()))
        response = client.get("/me", headers={"Authorization": "Bearer"})
        assert response.status_code == 401

    def test_verifier_raises_generic_exception_maps_to_401(self) -> None:
        verifier = _FakeVerifier()
        client = TestClient(_build_session_app(verifier))
        response = client.get("/me", headers={"Authorization": "Bearer expired"})
        assert response.status_code == 401
        # The verifier was called with the offending token even though
        # it raised — the dependency translates after the fact.
        assert verifier.calls == ["expired"]

    def test_verifier_raises_http_unauthorized_passes_through(self) -> None:
        # When the verifier raises HttpUnauthorizedError directly (with its
        # own message), the dependency lets it through unchanged so the
        # verifier's message reaches the client unchanged.
        verifier = _FakeVerifier()
        verifier.failure = HttpUnauthorizedError("token revoked by admin")
        client = TestClient(_build_session_app(verifier))
        response = client.get("/me", headers={"Authorization": "Bearer good"})
        assert response.status_code == 401
        body = response.json()
        assert body["error"]["type"] == "HttpUnauthorizedError"
        assert "token revoked by admin" in body["error"]["message"]

    def test_missing_verifier_on_app_state(self) -> None:
        # The deployment forgot to wire `app.state.auth_provider`. The
        # caller should NOT see "verifier missing" — that leaks deploy
        # detail. They see a generic 401; ops sees the loud log line.
        client = TestClient(_build_session_app(None))
        response = client.get("/me", headers={"Authorization": "Bearer good"})
        assert response.status_code == 401


class TestBearerSessionCookieFallback:
    # Cookies are set on the TestClient instance (httpx deprecated
    # per-request cookies=; the warning is escalated to error by
    # `filterwarnings = ["error"]` in pyproject.toml).

    def test_cookie_used_when_header_missing(self) -> None:
        verifier = _FakeVerifier()
        client = TestClient(
            _build_session_app(verifier, cookie_name="session"),
            cookies={"session": "good"},
        )
        response = client.get("/me")
        assert response.status_code == 200
        assert verifier.calls == ["good"]

    def test_header_takes_precedence_over_cookie(self) -> None:
        verifier = _FakeVerifier()
        client = TestClient(
            _build_session_app(verifier, cookie_name="session"),
            cookies={"session": "expired"},
        )
        response = client.get("/me", headers={"Authorization": "Bearer good"})
        assert response.status_code == 200
        # The header value wins; the cookie value is never seen.
        assert verifier.calls == ["good"]

    def test_cookie_ignored_when_cookie_name_not_set(self) -> None:
        verifier = _FakeVerifier()
        client = TestClient(
            _build_session_app(verifier),  # no cookie_name
            cookies={"session": "good"},
        )
        response = client.get("/me")
        assert response.status_code == 401
        # Verifier never called — no token was extracted.
        assert verifier.calls == []


class TestBearerTokenControlCharacterFilter:
    """RA-08 / SH-05: a token (header or cookie) containing CR / LF /
    NUL / other C0 controls is rejected; the verifier never sees it
    and the request is 401. Defence-in-depth against non-browser
    clients that don't honour the same character set rules."""

    def test_header_with_lf_rejected(self) -> None:
        # Exercise _extract_bearer_token directly — httpx blocks \r\n
        # at the client side, so testing through TestClient would
        # measure httpx's pre-flight check, not our defence.
        from unittest.mock import Mock

        from mgf.fastapi.security import _extract_bearer_token

        request = Mock()
        request.headers = {"authorization": "Bearer abc\ndef"}
        request.cookies = {}
        assert _extract_bearer_token(request, None) is None

    def test_cookie_with_nul_rejected(self) -> None:
        from unittest.mock import Mock

        from mgf.fastapi.security import _extract_bearer_token

        request = Mock()
        request.headers = {}
        request.cookies = {"session": "abc\x00def"}
        assert _extract_bearer_token(request, "session") is None

    def test_clean_token_passes(self) -> None:
        from unittest.mock import Mock

        from mgf.fastapi.security import _extract_bearer_token

        request = Mock()
        request.headers = {"authorization": "Bearer abc.def.ghi"}
        request.cookies = {}
        assert _extract_bearer_token(request, None) == "abc.def.ghi"


class TestBearerSessionCustomVerifierAttr:
    def test_custom_app_state_attribute(self) -> None:
        # When an app has multiple verifiers (admin vs user, different
        # tenants), each bearer_session() points at a different attr.
        verifier = _FakeVerifier()
        client = TestClient(_build_session_app(verifier, verifier_attr="admin_auth"))
        response = client.get("/me", headers={"Authorization": "Bearer good"})
        assert response.status_code == 200


class TestIpAllowlistTrustProxyAuditLog:
    """SH-03: IpAllowlist(trust_proxy=True) emits a startup-time
    WARNING audit log. trust_proxy=True is the canonical IP-spoofing
    footgun (correct only behind a reverse proxy that strips client-
    supplied X-Forwarded-For) — surfacing the flag in logs at app
    boot puts the operator-side awareness at the right surface.
    """

    def test_trust_proxy_true_emits_warning(self, caplog) -> None:
        import logging

        with caplog.at_level(logging.WARNING, logger="mgf.fastapi.security"):
            IpAllowlist(cidrs=["10.0.0.0/8"], trust_proxy=True)
        # The log line names the flag + the threat-model paper.
        relevant = [r for r in caplog.records if "trust_proxy" in r.getMessage()]
        assert relevant, (
            "SH-03: IpAllowlist(trust_proxy=True) did not emit the expected startup warning."
        )
        msg = relevant[0].getMessage()
        assert "PAPER-27" in msg
        assert "X-Forwarded-For" in msg

    def test_default_does_not_emit_warning(self, caplog) -> None:
        """Sanity: the safe default (trust_proxy=False) does NOT log
        the warning — otherwise operators would learn to ignore it."""
        import logging

        with caplog.at_level(logging.WARNING, logger="mgf.fastapi.security"):
            IpAllowlist(cidrs=["10.0.0.0/8"])  # default trust_proxy=False
        for rec in caplog.records:
            assert "trust_proxy" not in rec.getMessage(), (
                "SH-03: warning fired on the safe default — operators would learn to ignore it."
            )


class TestNormalizeClientHost:
    """RA-03: _normalize_client_host strips IPv6 brackets + port
    suffixes so values that real reverse proxies emit parse cleanly
    through ipaddress.ip_address()."""

    @pytest.mark.parametrize(
        ("raw", "expected"),
        [
            ("192.0.2.10", "192.0.2.10"),
            ("203.0.113.5:54321", "203.0.113.5"),
            ("[2001:db8::1]", "2001:db8::1"),
            ("[::1]:8080", "::1"),
            ("[::1]", "::1"),
            ("2001:db8::1", "2001:db8::1"),  # IPv6 plain — leave alone
            ("::1", "::1"),
            ("  192.0.2.10  ", "192.0.2.10"),  # surrounding whitespace
        ],
    )
    def test_shapes(self, raw: str, expected: str) -> None:
        from mgf.fastapi.security import _normalize_client_host

        assert _normalize_client_host(raw) == expected

    def test_empty_returns_none(self) -> None:
        from mgf.fastapi.security import _normalize_client_host

        assert _normalize_client_host("") is None


class TestIpAllowlistAcceptsPortAndBrackets:
    """RA-03: IpAllowlist now accepts the shapes real reverse proxies
    emit. Previously a `203.0.113.5:54321` source IP became a 403
    `client IP not in allowlist` because ipaddress.ip_address()
    couldn't parse the port suffix."""

    def test_ipv4_with_port_allowed_when_ip_is_in_cidr(self) -> None:
        # The ASGI `client` in TestClient is typically (127.0.0.1, port).
        # We need to inject a value with port suffix via X-Forwarded-For
        # so the trust_proxy=True branch handles it.
        gate = IpAllowlist(cidrs=["203.0.113.0/24"], trust_proxy=True)
        app = _build_app(gate)
        with TestClient(app) as client:
            response = client.get(
                "/admin/ping",
                headers={"X-Forwarded-For": "203.0.113.5:54321"},
            )
        assert response.status_code == 200

    def test_ipv6_with_brackets_allowed_when_ip_is_in_cidr(self) -> None:
        gate = IpAllowlist(cidrs=["::1/128"], trust_proxy=True)
        app = _build_app(gate)
        with TestClient(app) as client:
            response = client.get(
                "/admin/ping",
                headers={"X-Forwarded-For": "[::1]:8080"},
            )
        assert response.status_code == 200
