"""Tests for ``mgf.apiprobe.core.finding``."""

from __future__ import annotations

import dataclasses

import pytest

from mgf.apiprobe.core.finding import (
    Finding,
    RootCause,
    Severity,
    severity_at_least,
)


class TestSeverity:
    def test_string_values_are_stable(self) -> None:
        # These string values are public — JSON output relies on them.
        assert Severity.INFO.value == "info"
        assert Severity.WARN.value == "warn"
        assert Severity.CRITICAL.value == "critical"

    @pytest.mark.parametrize(
        ("actual", "threshold", "expected"),
        [
            (Severity.INFO, Severity.INFO, True),
            (Severity.WARN, Severity.INFO, True),
            (Severity.CRITICAL, Severity.INFO, True),
            (Severity.INFO, Severity.WARN, False),
            (Severity.WARN, Severity.WARN, True),
            (Severity.CRITICAL, Severity.WARN, True),
            (Severity.WARN, Severity.CRITICAL, False),
            (Severity.CRITICAL, Severity.CRITICAL, True),
        ],
    )
    def test_severity_at_least(
        self,
        actual: Severity,
        threshold: Severity,
        expected: bool,
    ) -> None:
        assert severity_at_least(actual, threshold) is expected


class TestRootCause:
    def test_all_design_taxonomy_present(self) -> None:
        # DESIGN.md §3.2.1 lists 14 categories; this test pins the
        # set so adding/removing one is a deliberate change.
        expected = {
            "network",
            "auth",
            "rate_limit",
            "provider_5xx",
            "provider_contract",
            "provider_quality",
            "provider_security",
            "provider_observability",
            "client_request_shape",
            "cassette_drift",
            "spec_drift",
            "suite_logic",
            "infrastructure",
            "unknown",
        }
        actual = {rc.value for rc in RootCause}
        assert actual == expected


class TestFinding:
    def test_minimal_construction(self) -> None:
        f = Finding(
            code="contract.status.unexpected",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_CONTRACT,
            message="Expected status 200, got 500.",
        )
        assert f.code == "contract.status.unexpected"
        assert f.severity is Severity.CRITICAL
        assert f.root_cause is RootCause.PROVIDER_CONTRACT
        # Defaults
        assert f.evidence == {}
        assert f.remediation == ""
        assert f.probe_id is None
        assert f.threat_refs == ()
        assert f.tags == ()
        assert f.since is None

    def test_full_construction(self) -> None:
        f = Finding(
            code="header.request-id.missing",
            severity=Severity.WARN,
            root_cause=RootCause.PROVIDER_OBSERVABILITY,
            message="Response missing X-Request-Id header.",
            evidence={"headers": {"content-type": "application/json"}},
            remediation="Provider should add X-Request-Id to every response.",
            probe_id="list-users",
            threat_refs=("OWASP-API-9",),
            tags=("READ", "PUBLIC"),
        )
        assert f.evidence["headers"]["content-type"] == "application/json"
        assert f.threat_refs == ("OWASP-API-9",)

    def test_is_frozen(self) -> None:
        f = Finding(
            code="x",
            severity=Severity.INFO,
            root_cause=RootCause.UNKNOWN,
            message="x.",
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            f.code = "y"  # type: ignore[misc]


class TestEvidenceScrubbing:
    """SH-01: Finding.evidence is scrubbed through mgf-common's Redactor
    at construction time. Bearer / JWT / PEM patterns + secret-shaped
    keys (Authorization, X-Api-Key, password, …) are masked before the
    finding lands in the report."""

    def test_secret_shaped_key_redacted(self) -> None:
        """Values under known secret-shaped keys are masked."""
        f = Finding(
            code="test.secret.in-evidence",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_SECURITY,
            message="A check captured a secret.",
            evidence={
                "authorization": "Bearer abc123-real-token",
                "x-api-key": "sk_live_realkey_67890",
                "url": "https://api.example.com/items",
            },
        )
        # Secret-shaped keys: redacted.
        assert "abc123-real-token" not in str(f.evidence["authorization"])
        assert "sk_live_realkey_67890" not in str(f.evidence["x-api-key"])
        # Non-secret context preserved.
        assert f.evidence["url"] == "https://api.example.com/items"

    def test_url_query_param_credentials_masked(self) -> None:
        """SH-01 (F-AP-001): a query-string API key in a captured URL is
        masked even when its value matches no token PATTERN — the
        Redactor alone would let `?api_key=customsecret` through."""
        f = Finding(
            code="test.url.querycreds",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_SECURITY,
            message="captured request URL",
            evidence={
                "url": "https://api.example.com/items?api_key=plainCUSTOMsecret&page=2",
                "note": "no scheme here, keep me",
            },
        )
        masked = str(f.evidence["url"])
        assert "plainCUSTOMsecret" not in masked
        assert "api_key=REDACTED" in masked  # key kept for debuggability
        assert f.evidence["note"] == "no scheme here, keep me"

    def test_url_userinfo_masked(self) -> None:
        """Userinfo (user:pass@host) in a captured URL is dropped."""
        f = Finding(
            code="test.url.userinfo",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_SECURITY,
            message="captured request URL with userinfo",
            evidence={"url": "https://alice:hunter2PW@api.example.com/x"},
        )
        masked = str(f.evidence["url"])
        assert "hunter2PW" not in masked
        assert "alice" not in masked
        assert masked == "https://api.example.com/x"

    def test_bearer_pattern_in_value_redacted(self) -> None:
        """Bearer tokens inside values get caught by the value-pattern
        scan even when the key isn't on the secret-keys list."""
        f = Finding(
            code="test.echo",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_SECURITY,
            message="Body echoed the bearer token.",
            evidence={
                "matched_value": "Bearer eyJhbGciOiJIUzI1NiJ9.deadbeef.signature",
                "expected": "absent",
            },
        )
        assert "deadbeef" not in str(f.evidence["matched_value"])
        assert f.evidence["expected"] == "absent"

    def test_nested_evidence_walked(self) -> None:
        """Nested dicts get walked; passwords deep inside still get
        redacted."""
        f = Finding(
            code="test.nested",
            severity=Severity.WARN,
            root_cause=RootCause.PROVIDER_SECURITY,
            message="Nested secret.",
            evidence={
                "request": {
                    "headers": {
                        "Authorization": "Bearer real-secret-here",
                        "X-Trace-Id": "trace-12345",
                    },
                },
            },
        )
        nested = f.evidence["request"]["headers"]
        assert "real-secret-here" not in str(nested["Authorization"])
        # Non-secret header preserved.
        assert nested["X-Trace-Id"] == "trace-12345"

    def test_evidence_without_secrets_unchanged(self) -> None:
        """The common case: most findings have non-sensitive evidence;
        the scrub must NOT distort them."""
        f = Finding(
            code="contract.status.unexpected",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_CONTRACT,
            message="Expected 200, got 500.",
            evidence={"expected": 200, "actual": 500, "url": "/items"},
        )
        assert f.evidence == {"expected": 200, "actual": 500, "url": "/items"}

    def test_idempotent_under_double_scrub(self) -> None:
        """Construction-time scrub is idempotent — re-scrubbing the
        already-redacted dict produces the same result."""
        f1 = Finding(
            code="t.idem",
            severity=Severity.INFO,
            root_cause=RootCause.UNKNOWN,
            message="x.",
            evidence={"authorization": "Bearer secret"},
        )
        # Build a second Finding seeded with the first's (already-scrubbed)
        # evidence. The result must equal f1's evidence.
        f2 = Finding(
            code="t.idem",
            severity=Severity.INFO,
            root_cause=RootCause.UNKNOWN,
            message="x.",
            evidence=dict(f1.evidence),
        )
        assert f2.evidence == f1.evidence

    def test_default_empty_evidence_unchanged(self) -> None:
        """Findings without explicit evidence default to {} — must still
        round-trip cleanly through the scrub."""
        f = Finding(
            code="t.empty",
            severity=Severity.INFO,
            root_cause=RootCause.UNKNOWN,
            message="x.",
        )
        assert f.evidence == {}
