"""Tests for ``mgf.apiprobe.reporting`` writers."""

from __future__ import annotations

import json
from datetime import UTC, datetime

import pytest

from mgf.apiprobe.core.finding import Finding, RootCause, Severity
from mgf.apiprobe.core.report import Report
from mgf.apiprobe.reporting.base import ReportWriter
from mgf.apiprobe.reporting.human import HumanReportWriter
from mgf.apiprobe.reporting.json_writer import JsonReportWriter


def _f(
    *,
    code: str = "x.y",
    severity: Severity = Severity.WARN,
    root_cause: RootCause = RootCause.PROVIDER_QUALITY,
    message: str = "msg.",
    remediation: str = "",
    probe_id: str | None = None,
    threat_refs: tuple[str, ...] = (),
    tags: tuple[str, ...] = (),
    evidence: dict[str, object] | None = None,
) -> Finding:
    return Finding(
        code=code,
        severity=severity,
        root_cause=root_cause,
        message=message,
        evidence=evidence or {},
        remediation=remediation,
        probe_id=probe_id,
        threat_refs=threat_refs,
        tags=tags,
    )


def _report(findings: tuple[Finding, ...] = ()) -> Report:
    return Report(
        findings=findings,
        started_at=datetime(2026, 4, 29, 12, 0, 0, tzinfo=UTC),
        finished_at=datetime(2026, 4, 29, 12, 0, 1, tzinfo=UTC),
        probe_count=2,
        transport_errors=0,
    )


# ---------------------------------------------------------------------------
# ReportWriter ABC
# ---------------------------------------------------------------------------


class TestReportWriterABC:
    def test_cannot_instantiate_directly(self) -> None:
        with pytest.raises(TypeError):
            ReportWriter()  # type: ignore[abstract]

    @pytest.mark.parametrize("cls", [HumanReportWriter, JsonReportWriter])
    def test_concrete_writers_subclass_abc(self, cls: type) -> None:
        assert issubclass(cls, ReportWriter)


# ---------------------------------------------------------------------------
# HumanReportWriter
# ---------------------------------------------------------------------------


class TestHumanReportWriter:
    def test_empty_report(self) -> None:
        out = HumanReportWriter().write(_report())
        assert "2 probe(s)" in out
        assert "1.00s" in out
        assert "0 finding(s)" in out

    def test_includes_transport_errors_when_present(self) -> None:
        report = Report(
            findings=(),
            started_at=datetime(2026, 4, 29, 12, 0, 0, tzinfo=UTC),
            finished_at=datetime(2026, 4, 29, 12, 0, 1, tzinfo=UTC),
            probe_count=3,
            transport_errors=2,
        )
        out = HumanReportWriter().write(report)
        assert "2 transport error(s)" in out

    def test_omits_transport_errors_when_zero(self) -> None:
        out = HumanReportWriter().write(_report())
        assert "transport error" not in out

    def test_renders_findings_grouped_by_severity(self) -> None:
        out = HumanReportWriter().write(
            _report(
                (
                    _f(severity=Severity.CRITICAL, code="c1", message="critical 1."),
                    _f(severity=Severity.WARN, code="w1", message="warn 1."),
                    _f(severity=Severity.WARN, code="w2", message="warn 2."),
                    _f(severity=Severity.INFO, code="i1", message="info 1."),
                )
            )
        )
        # CRITICAL section first.
        crit_idx = out.index("CRITICAL")
        warn_idx = out.index("WARN")
        info_idx = out.index("INFO")
        assert crit_idx < warn_idx < info_idx
        assert "c1" in out
        assert "w1" in out
        assert "w2" in out
        assert "i1" in out

    def test_finding_includes_probe_id(self) -> None:
        out = HumanReportWriter().write(_report((_f(probe_id="my-probe"),)))
        assert "(probe: my-probe)" in out

    def test_finding_omits_probe_id_section_when_none(self) -> None:
        out = HumanReportWriter().write(_report((_f(probe_id=None),)))
        assert "(probe:" not in out

    def test_finding_includes_remediation(self) -> None:
        out = HumanReportWriter().write(_report((_f(remediation="rotate the key."),)))
        assert "rotate the key." in out
        assert "->" in out

    def test_finding_omits_remediation_section_when_empty(self) -> None:
        out = HumanReportWriter().write(_report((_f(remediation=""),)))
        assert "->" not in out

    def test_finding_includes_threat_refs(self) -> None:
        out = HumanReportWriter().write(_report((_f(threat_refs=("OWASP-API-9", "CWE-200")),)))
        assert "threat_refs:" in out
        assert "OWASP-API-9" in out
        assert "CWE-200" in out

    def test_root_cause_breakdown_at_end(self) -> None:
        out = HumanReportWriter().write(
            _report(
                (
                    _f(root_cause=RootCause.PROVIDER_5XX),
                    _f(root_cause=RootCause.PROVIDER_5XX),
                    _f(root_cause=RootCause.NETWORK),
                )
            )
        )
        assert "Root cause breakdown:" in out
        assert "PROVIDER_5XX: 2" in out
        assert "NETWORK: 1" in out

    def test_root_cause_breakdown_omitted_when_no_findings(self) -> None:
        out = HumanReportWriter().write(_report())
        assert "Root cause breakdown" not in out

    def test_rolls_up_identical_findings_across_probes(self) -> None:
        """PAPER-03 C — the same finding across N probes renders as one block
        (count + probe list), not N identical blocks."""
        out = HumanReportWriter().write(
            _report(
                (
                    _f(code="obs.request-id", message="Missing X-Request-Id.", probe_id="p1"),
                    _f(code="obs.request-id", message="Missing X-Request-Id.", probe_id="p2"),
                    _f(code="obs.request-id", message="Missing X-Request-Id.", probe_id="p3"),
                )
            )
        )
        assert "obs.request-id (3 probes: p1, p2, p3)" in out
        # The shared message renders exactly once, not three times.
        assert out.count("Missing X-Request-Id.") == 1
        # The section header still counts all three findings.
        assert "WARN — 3" in out

    def test_rollup_keeps_distinct_messages_separate(self) -> None:
        """Same code but different messages are NOT merged."""
        out = HumanReportWriter().write(
            _report(
                (
                    _f(code="c.x", message="detail A.", probe_id="p1"),
                    _f(code="c.x", message="detail B.", probe_id="p2"),
                )
            )
        )
        assert "detail A." in out
        assert "detail B." in out
        assert "probes:" not in out  # not merged: two single-probe blocks
        assert "(probe: p1)" in out
        assert "(probe: p2)" in out

    def test_rollup_caps_the_probe_list(self) -> None:
        """A finding shared by more than the inline cap collapses the tail."""
        findings = tuple(_f(code="c.x", message="m.", probe_id=f"p{i}") for i in range(11))
        out = HumanReportWriter().write(_report(findings))
        assert "11 probes:" in out
        assert "p0" in out
        assert "p7" in out
        assert "+3 more" in out  # 11 probes, 8 shown inline, 3 collapsed

    def test_rollup_without_probe_ids_shows_count_only(self) -> None:
        """Identical findings with no probe_id roll up by count, no probe list."""
        out = HumanReportWriter().write(
            _report(
                (
                    _f(code="c.x", message="m.", probe_id=None),
                    _f(code="c.x", message="m.", probe_id=None),
                )
            )
        )
        assert "c.x (x2)" in out
        assert "(probes:" not in out


# ---------------------------------------------------------------------------
# JsonReportWriter
# ---------------------------------------------------------------------------


class TestJsonReportWriter:
    def test_empty_report_shape(self) -> None:
        d = JsonReportWriter().write(_report())
        # Required top-level keys present.
        assert set(d.keys()) == {
            "started_at",
            "finished_at",
            "duration_seconds",
            "probe_count",
            "transport_errors",
            "severity_breakdown",
            "root_cause_breakdown",
            "findings",
        }

    def test_iso8601_z_timestamps(self) -> None:
        d = JsonReportWriter().write(_report())
        assert d["started_at"] == "2026-04-29T12:00:00Z"
        assert d["finished_at"] == "2026-04-29T12:00:01Z"

    def test_severity_breakdown_uses_enum_values(self) -> None:
        d = JsonReportWriter().write(
            _report((_f(severity=Severity.WARN), _f(severity=Severity.CRITICAL)))
        )
        # Lowercase string keys, not enum names.
        assert d["severity_breakdown"] == {"info": 0, "warn": 1, "critical": 1}

    def test_finding_shape(self) -> None:
        finding = _f(
            code="x.y",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_5XX,
            message="msg.",
            evidence={"actual": 500},
            remediation="rem.",
            probe_id="p1",
            threat_refs=("CWE-200",),
            tags=("read",),
        )
        d = JsonReportWriter().write(_report((finding,)))
        assert len(d["findings"]) == 1
        f = d["findings"][0]
        assert f == {
            "code": "x.y",
            "severity": "critical",
            "root_cause": "provider_5xx",
            "message": "msg.",
            "evidence": {"actual": 500},
            "remediation": "rem.",
            "probe_id": "p1",
            "threat_refs": ["CWE-200"],
            "tags": ["read"],
            "since": None,
        }

    def test_round_trips_json(self) -> None:
        d = JsonReportWriter().write(
            _report(
                (
                    _f(severity=Severity.CRITICAL, evidence={"x": 1}),
                    _f(severity=Severity.WARN),
                )
            )
        )
        # Confirm json.dumps doesn't choke on the result.
        encoded = json.dumps(d)
        decoded = json.loads(encoded)
        assert decoded["probe_count"] == 2
        assert len(decoded["findings"]) == 2
