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

These tests use stub :class:`Transport` implementations defined inline
— no httpx, no respx. The httpx-backed transport gets its own tests
in Phase 1.6b.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar

import pytest

from mgf.apiprobe.checks.contract import StatusUnexpected
from mgf.apiprobe.checks.observability import MissingRequestId
from mgf.apiprobe.config import ApiprobeSettings
from mgf.apiprobe.core.check import Check
from mgf.apiprobe.core.finding import Finding, RootCause, Severity
from mgf.apiprobe.core.probe import Probe
from mgf.apiprobe.core.response import Response
from mgf.apiprobe.core.suite import Suite
from mgf.apiprobe.core.tag import Tag
from mgf.apiprobe.core.transport import Transport
from mgf.apiprobe.exceptions import TransportError

if TYPE_CHECKING:
    from collections.abc import Iterable

    from mgf.apiprobe.core.context import Context


# ---------------------------------------------------------------------------
# Stub transports.
# ---------------------------------------------------------------------------


class StubTransport(Transport):
    """Returns a pre-canned response for every probe."""

    def __init__(
        self,
        *,
        status: int = 200,
        body: bytes = b"",
        headers: dict[str, str] | None = None,
        elapsed_ms: float = 12.5,
    ) -> None:
        self.status = status
        self.body = body
        self.headers = headers or {}
        self.elapsed_ms = elapsed_ms
        self.calls: list[Probe] = []

    async def send(self, probe: Probe, ctx: Context) -> Response:
        self.calls.append(probe)
        return Response(
            url=probe.absolute_url(),
            status=self.status,
            headers=self.headers,
            body=self.body,
            elapsed_ms=self.elapsed_ms,
        )


class FailingTransport(Transport):
    """Raises TransportError for every probe."""

    async def send(self, probe: Probe, ctx: Context) -> Response:
        raise TransportError(url=probe.absolute_url(), cause="DNS NXDOMAIN")


class CountingCheck(Check):
    """Records every (probe, response) it sees and yields no finding."""

    code: ClassVar[str] = "test.counter"
    severity_default: ClassVar[Severity] = Severity.INFO
    root_cause_default: ClassVar[RootCause] = RootCause.UNKNOWN

    def __init__(self) -> None:
        self.calls: list[tuple[str | None, int]] = []

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        self.calls.append((probe.probe_id, response.status))
        return ()


# ---------------------------------------------------------------------------
# Tests.
# ---------------------------------------------------------------------------


class TestSuiteConstruction:
    def test_defaults(self) -> None:
        suite = Suite(probes=[], checks=[])
        assert suite.probes == ()
        assert suite.checks == ()
        assert isinstance(suite.settings, ApiprobeSettings)
        assert suite.severity_overrides == {}

    def test_explicit_settings(self) -> None:
        s = ApiprobeSettings(max_concurrency=2)
        suite = Suite(probes=[], checks=[], settings=s)
        assert suite.settings is s


@pytest.mark.asyncio
class TestSuiteRun:
    async def test_empty_suite_produces_empty_report(self) -> None:
        suite = Suite(probes=[], checks=[])
        report = await suite.run(transport=StubTransport())
        assert report.findings == ()
        assert report.probe_count == 0
        assert report.transport_errors == 0

    async def test_single_probe_no_findings(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(probes=[probe], checks=[StatusUnexpected()])
        transport = StubTransport(status=200)
        report = await suite.run(transport=transport)
        assert report.findings == ()
        assert report.probe_count == 1
        assert len(transport.calls) == 1

    async def test_single_probe_with_finding(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(probes=[probe], checks=[StatusUnexpected()])
        report = await suite.run(transport=StubTransport(status=500))
        assert len(report.findings) == 1
        assert report.findings[0].code == "contract.status.unexpected"
        assert report.findings[0].probe_id == "p1"

    async def test_multiple_probes_run_concurrently(self) -> None:
        probes = [Probe.get(f"https://x/{i}").id(f"p{i}") for i in range(5)]
        check = CountingCheck()
        suite = Suite(probes=probes, checks=[check])
        report = await suite.run(transport=StubTransport())
        assert report.probe_count == 5
        assert len(check.calls) == 5
        assert {c[0] for c in check.calls} == {f"p{i}" for i in range(5)}

    async def test_concurrency_capped_by_settings(self) -> None:
        # Hard to assert concurrency directly without timing flakiness;
        # confirm at least that max_concurrency=1 still produces a
        # complete report (sequential execution).
        probes = [Probe.get(f"https://x/{i}").id(f"p{i}") for i in range(3)]
        suite = Suite(
            probes=probes,
            checks=[CountingCheck()],
            settings=ApiprobeSettings(max_concurrency=1),
        )
        report = await suite.run(transport=StubTransport())
        assert report.probe_count == 3


@pytest.mark.asyncio
class TestTransportErrorHandling:
    async def test_transport_error_yields_synthetic_finding(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        suite = Suite(probes=[probe], checks=[StatusUnexpected()])
        report = await suite.run(transport=FailingTransport())
        assert len(report.findings) == 1
        f = report.findings[0]
        assert f.code == "transport.network.failure"
        assert f.severity is Severity.CRITICAL
        assert f.root_cause is RootCause.NETWORK
        assert f.probe_id == "p1"
        assert "DNS NXDOMAIN" in f.evidence["cause"]

    async def test_transport_error_skips_check_chain(self) -> None:
        # Without a Response, checks can't run. The synthetic Finding
        # is the only output.
        probe = Probe.get("https://x/y").id("p1")
        check = CountingCheck()
        suite = Suite(probes=[probe], checks=[check])
        report = await suite.run(transport=FailingTransport())
        assert len(report.findings) == 1
        assert check.calls == []  # Check NOT called.

    async def test_transport_errors_counted(self) -> None:
        probes = [Probe.get(f"https://x/{i}").id(f"p{i}") for i in range(3)]
        suite = Suite(probes=probes, checks=[])
        report = await suite.run(transport=FailingTransport())
        assert report.transport_errors == 3


@pytest.mark.asyncio
class TestSeverityOverrides:
    async def test_demote_critical_to_warn(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            severity_overrides={"contract.status.unexpected": Severity.WARN},
        )
        report = await suite.run(transport=StubTransport(status=500))
        assert len(report.findings) == 1
        assert report.findings[0].severity is Severity.WARN

    async def test_no_override_leaves_default(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(probes=[probe], checks=[StatusUnexpected()])
        report = await suite.run(transport=StubTransport(status=500))
        assert report.findings[0].severity is Severity.CRITICAL

    async def test_override_for_unrelated_code_no_effect(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            severity_overrides={"some.other.code": Severity.WARN},
        )
        report = await suite.run(transport=StubTransport(status=500))
        assert report.findings[0].severity is Severity.CRITICAL


@pytest.mark.asyncio
class TestTagFiltering:
    async def test_only_tags(self) -> None:
        p_read = Probe.get("https://x/r").id("read").with_tags(Tag.READ)
        p_write = Probe.post("https://x/w").id("write").with_tags(Tag.WRITE)
        suite = Suite(probes=[p_read, p_write], checks=[CountingCheck()])
        report = await suite.run(transport=StubTransport(), only_tags={"read"})
        assert report.probe_count == 1

    async def test_exclude_tags(self) -> None:
        p_read = Probe.get("https://x/r").id("read").with_tags(Tag.READ)
        p_write = Probe.post("https://x/w").id("write").with_tags(Tag.WRITE, Tag.UNSAFE)
        suite = Suite(probes=[p_read, p_write], checks=[CountingCheck()])
        report = await suite.run(transport=StubTransport(), exclude_tags={"unsafe"})
        assert report.probe_count == 1

    async def test_exclude_overrides_only(self) -> None:
        # A probe matching only_tags can still be excluded.
        p = Probe.get("https://x/y").id("p1").with_tags(Tag.READ, Tag.UNSAFE)
        suite = Suite(probes=[p], checks=[CountingCheck()])
        report = await suite.run(
            transport=StubTransport(),
            only_tags={"read"},
            exclude_tags={"unsafe"},
        )
        assert report.probe_count == 0

    async def test_no_filter_runs_all(self) -> None:
        probes = [Probe.get(f"https://x/{i}").id(f"p{i}") for i in range(3)]
        suite = Suite(probes=probes, checks=[CountingCheck()])
        report = await suite.run(transport=StubTransport())
        assert report.probe_count == 3


@pytest.mark.asyncio
class TestCheckChain:
    async def test_multiple_checks_all_run(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200).expect_header("X-Request-Id")
        # 500 response with no X-Request-Id → both checks fire.
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected(), MissingRequestId()],
        )
        report = await suite.run(transport=StubTransport(status=500))
        codes = {f.code for f in report.findings}
        assert "contract.status.unexpected" in codes
        assert "observability.headers.missing-request-id" in codes


# ---------------------------------------------------------------------------
# RA-01: defensive wrap around _run_one_probe — a check that raises
# does NOT cancel sibling probes; produces a synthetic finding instead.
# ---------------------------------------------------------------------------


class ExplodingCheck(Check):
    """Check that raises every time. Used to verify RA-01's wrap."""

    code: ClassVar[str] = "test.exploding"
    severity_default: ClassVar[Severity] = Severity.CRITICAL
    root_cause_default: ClassVar[RootCause] = RootCause.UNKNOWN

    def run(self, probe, response, ctx):
        raise RuntimeError("boom from check")
        yield  # unreachable; here so the method is a generator


@pytest.mark.asyncio
class TestRaisingCheckIsolation:
    """RA-01: a check that raises produces a synthetic Finding for the
    affected probe; SIBLING probes complete normally."""

    async def test_sibling_probes_still_run(self) -> None:
        probes = [Probe.get(f"https://x/y/{i}").id(f"p{i}").expect_status(200) for i in range(3)]
        suite = Suite(
            probes=probes,
            checks=[ExplodingCheck()],
        )
        report = await suite.run(transport=StubTransport(status=200))

        # All three probes appear in the report — none lost.
        probe_ids_in_findings = {f.probe_id for f in report.findings if f.probe_id}
        assert probe_ids_in_findings == {"p0", "p1", "p2"}
        # Each probe has the suite.probe.exception finding.
        exception_findings = [f for f in report.findings if f.code == "suite.probe.exception"]
        assert len(exception_findings) == 3

    async def test_synthetic_finding_carries_traceback_hint(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        suite = Suite(probes=[probe], checks=[ExplodingCheck()])
        report = await suite.run(transport=StubTransport(status=200))

        exc_finding = next(f for f in report.findings if f.code == "suite.probe.exception")
        assert exc_finding.severity is Severity.CRITICAL
        assert exc_finding.root_cause is RootCause.SUITE_LOGIC
        assert "RuntimeError" in exc_finding.message
        assert "boom from check" in exc_finding.message
        # Remediation points at the WARNING log line with exc_info.
        assert "RA-01" in exc_finding.remediation


# ---------------------------------------------------------------------------
# RA-02: probe_id + check.code uniqueness enforced at suite-build time.
# ---------------------------------------------------------------------------


class TestProbeIdUniqueness:
    """RA-02: duplicate probe_id raises SuiteLogicError at construction."""

    def test_duplicate_probe_id_raises(self) -> None:
        from mgf.apiprobe.exceptions import SuiteLogicError

        p1 = Probe.get("https://x/a").id("dup").expect_status(200)
        p2 = Probe.get("https://x/b").id("dup").expect_status(200)

        with pytest.raises(SuiteLogicError, match="probe_id collision"):
            Suite(probes=[p1, p2], checks=[])

    def test_none_probe_id_does_not_collide(self) -> None:
        """Probes without an explicit id (probe_id=None) don't trip the
        uniqueness check — the Suite auto-generates a fallback id at
        run time, so None isn't a collision the operator controls."""
        p1 = Probe.get("https://x/a")
        p2 = Probe.get("https://x/b")
        # Should not raise.
        suite = Suite(probes=[p1, p2], checks=[])
        assert len(suite.probes) == 2

    def test_distinct_ids_pass(self) -> None:
        p1 = Probe.get("https://x/a").id("p1")
        p2 = Probe.get("https://x/b").id("p2")
        suite = Suite(probes=[p1, p2], checks=[])
        assert len(suite.probes) == 2


class TestCheckCodeUniqueness:
    """RA-02: duplicate check.code raises SuiteLogicError at construction."""

    def test_duplicate_check_code_raises(self) -> None:
        from mgf.apiprobe.exceptions import SuiteLogicError

        # Two StatusUnexpected instances share the same .code class attr.
        with pytest.raises(SuiteLogicError, match="check code collision"):
            Suite(
                probes=[Probe.get("https://x/y").id("p1")],
                checks=[StatusUnexpected(), StatusUnexpected()],
            )

    def test_distinct_check_codes_pass(self) -> None:
        suite = Suite(
            probes=[Probe.get("https://x/y").id("p1")],
            checks=[StatusUnexpected(), MissingRequestId()],
        )
        assert len(suite.checks) == 2


# ---------------------------------------------------------------------------
# RA-03: severity-override audit log fires when an override is applied.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestSeverityOverrideAuditLog:
    """RA-03: every applied severity override emits a WARNING log line
    with audit=True extra. The override behaviour itself is unchanged;
    the log is the audit trail."""

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

        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            # Demote the CRITICAL contract finding to INFO.
            severity_overrides={"contract.status.unexpected": Severity.INFO},
        )

        with caplog.at_level(logging.WARNING, logger="mgf.apiprobe.suite"):
            await suite.run(transport=StubTransport(status=500))

        relevant = [r for r in caplog.records if "severity override applied" in r.getMessage()]
        assert relevant, "RA-03: severity-override audit log did not fire."
        rec = relevant[0]
        assert rec.audit is True
        assert rec.event == "mgf_apiprobe.severity_override"
        assert rec.finding_code == "contract.status.unexpected"
        assert rec.severity_original == "critical"
        assert rec.severity_overridden == "info"

    async def test_no_override_no_audit_log(self, caplog) -> None:
        """Sanity: when no override matches the finding code, NO audit
        log fires. Operators don't see noise for every finding."""
        import logging

        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            # Override targets a different code — never fires.
            severity_overrides={"some.other.code": Severity.INFO},
        )

        with caplog.at_level(logging.WARNING, logger="mgf.apiprobe.suite"):
            await suite.run(transport=StubTransport(status=500))

        relevant = [r for r in caplog.records if "severity override applied" in r.getMessage()]
        assert not relevant, "RA-03: audit log fired even though no override matched."


# ---------------------------------------------------------------------------
# SH-06: TLS-disable surfaced as a Finding.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestTlsDisabledFinding:
    """SH-06: when ``ApiprobeSettings(verify_tls=False)`` is in effect,
    the Suite emits a synthetic WARN Finding at the front of the report
    so operators have a visible signal that TLS-protection claims in
    the report are unverifiable."""

    async def test_verify_tls_false_emits_finding(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            settings=ApiprobeSettings(verify_tls=False),
        )
        report = await suite.run(transport=StubTransport(status=200))

        tls_findings = [f for f in report.findings if f.code == "harness.config.tls-disabled"]
        assert len(tls_findings) == 1, (
            "SH-06: expected exactly one tls-disabled finding when "
            f"verify_tls=False; got {len(tls_findings)}."
        )
        finding = tls_findings[0]
        assert finding.severity == Severity.WARN
        assert finding.root_cause == RootCause.INFRASTRUCTURE
        assert finding.evidence == {"verify_tls": "false"}
        # First finding in the report so JSON readers see it before
        # any per-probe finding.
        assert report.findings[0].code == "harness.config.tls-disabled"

    async def test_verify_tls_true_no_finding(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(
            probes=[probe],
            checks=[StatusUnexpected()],
            settings=ApiprobeSettings(verify_tls=True),
        )
        report = await suite.run(transport=StubTransport(status=200))

        tls_findings = [f for f in report.findings if f.code == "harness.config.tls-disabled"]
        assert not tls_findings, (
            "SH-06: tls-disabled finding fired even though verify_tls=True (the default)."
        )


class _YieldThenRaiseCheck(Check):
    """Yields a real finding, THEN raises — models a check bug that
    surfaces after detecting a genuine violation (F-AP-002)."""

    code: ClassVar[str] = "test.yield-then-raise"
    severity_default: ClassVar[Severity] = Severity.INFO
    root_cause_default: ClassVar[RootCause] = RootCause.UNKNOWN

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        yield Finding(
            code="test.real.violation",
            severity=Severity.CRITICAL,
            root_cause=RootCause.UNKNOWN,
            message="a real contract violation detected before the crash",
            probe_id=probe.probe_id,
        )
        raise RuntimeError("check bug AFTER yielding a real finding")


@pytest.mark.asyncio
class TestPerCheckIsolation:
    """F-AP-002: a check that raises mid-iteration must not lose the
    findings it already produced, and must not skip sibling checks."""

    async def test_yielded_findings_survive_and_siblings_run(self) -> None:
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        sibling = CountingCheck()
        suite = Suite(
            probes=[probe],
            checks=[_YieldThenRaiseCheck(), sibling],
        )
        report = await suite.run(transport=StubTransport(status=200))

        codes = {f.code for f in report.findings}
        # The real violation yielded before the crash is RETAINED.
        assert "test.real.violation" in codes
        # The crash is surfaced (keeps the established suite.probe.exception
        # code/contract, now emitted per-check).
        assert "suite.probe.exception" in codes
        # The sibling check still ran (not skipped by the crash).
        assert sibling.calls == [("p1", 200)]
        # The error finding names the offending check + is CRITICAL.
        err = next(f for f in report.findings if f.code == "suite.probe.exception")
        assert err.severity is Severity.CRITICAL
        assert err.evidence["check_code"] == "test.yield-then-raise"


@pytest.mark.asyncio
class TestOperationLogging:
    """LG-11/15: ``Suite.run`` logs operation start/end at INFO with a stable
    ``operation`` field, and the completion record carries a stable ``event``."""

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

        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        suite = Suite(probes=[probe], checks=[StatusUnexpected()])
        with caplog.at_level(logging.INFO, logger="mgf.apiprobe.suite"):
            await suite.run(transport=StubTransport(status=200))

        ops = [r for r in caplog.records if getattr(r, "operation", None) == "apiprobe.suite.run"]
        assert len(ops) >= 2, "LG-11: expected operation start + end INFO records"
        assert any(getattr(r, "event", None) == "apiprobe.suite.completed" for r in ops), (
            "LG-15: completion record must carry the stable event field"
        )


@pytest.mark.asyncio
class TestRetry:
    """EH-11: transient ``TransportError``s are retried with backoff up to the budget."""

    async def test_retry_succeeds_after_transient_failures(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setattr("mgf.apiprobe.core.suite._retry_backoff", lambda attempt: 0.0)
        calls = {"n": 0}

        class Flaky(StubTransport):
            async def send(self, probe: Probe, ctx: Context) -> Response:
                calls["n"] += 1
                if calls["n"] < 3:
                    raise TransportError(url=probe.absolute_url(), cause="boom")
                return await super().send(probe, ctx)

        probe = Probe.get("https://x/y").id("p1").expect_status(200).retry(2)
        report = await Suite(probes=[probe], checks=[StatusUnexpected()]).run(transport=Flaky())
        assert calls["n"] == 3  # 1 initial + 2 retries → success
        assert not any(f.code == "transport.network.failure" for f in report.findings)

    async def test_retry_exhausts_budget_then_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr("mgf.apiprobe.core.suite._retry_backoff", lambda attempt: 0.0)
        calls = {"n": 0}

        class AlwaysFail(StubTransport):
            async def send(self, probe: Probe, ctx: Context) -> Response:
                calls["n"] += 1
                raise TransportError(url=probe.absolute_url(), cause="down")

        probe = Probe.get("https://x/y").id("p1").retry(2)
        report = await Suite(probes=[probe], checks=[StatusUnexpected()]).run(
            transport=AlwaysFail()
        )
        assert calls["n"] == 3  # 1 + 2 retries, then gives up
        assert any(f.code == "transport.network.failure" for f in report.findings)


@pytest.mark.asyncio
class TestCancellation:
    """DP-09: a cancelled CancellationToken skips probes not yet started."""

    async def test_cancelled_token_skips_probes(self) -> None:
        from mgf.common.progress import CancellationToken

        tok = CancellationToken()
        tok.cancel()  # cancel before the run starts
        transport = StubTransport(status=200)
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        report = await Suite(probes=[probe], checks=[StatusUnexpected()]).run(
            transport=transport, cancel=tok
        )
        assert transport.calls == []  # DP-09: no probe was sent
        assert not report.findings  # nothing ran → no findings

    async def test_uncancelled_token_runs_normally(self) -> None:
        from mgf.common.progress import CancellationToken

        transport = StubTransport(status=200)
        probe = Probe.get("https://x/y").id("p1").expect_status(200)
        await Suite(probes=[probe], checks=[StatusUnexpected()]).run(
            transport=transport, cancel=CancellationToken()
        )
        assert len(transport.calls) == 1  # not cancelled → the probe ran
