"""Tests for `mgf.common.testing._schema` — TS-24 record schema."""

from __future__ import annotations

import json
from dataclasses import FrozenInstanceError

import pytest

from mgf.common.testing import (
    DEFAULT_SEVERITY,
    SCHEMA_VERSION,
    Outcome,
    Severity,
    TestRecord,
)


class TestSchemaVersionConstant:
    def test_schema_version_pinned(self) -> None:
        # Bump deliberately when fields change; AP-04 deprecation
        # cycle applies. This test is the speed bump.
        assert SCHEMA_VERSION == "1.1.0"


class TestOutcomeEnum:
    @pytest.mark.parametrize(
        "name,value",
        [
            ("PASSED", "passed"),
            ("FAILED", "failed"),
            ("ERROR", "error"),
            ("SKIPPED", "skipped"),
            ("HUNG", "hung"),
            ("CRASHED", "crashed"),
            ("QUARANTINED", "quarantined"),
        ],
    )
    def test_outcome_value(self, name: str, value: str) -> None:
        assert getattr(Outcome, name).value == value

    def test_outcome_is_str_enum(self) -> None:
        # Outcomes serialise as plain strings in JSONL — the str
        # mixin gives us `json.dumps(o)` returning `"passed"`
        # rather than `"Outcome.PASSED"`.
        assert json.dumps(Outcome.PASSED) == '"passed"'


class TestSeverityEnum:
    @pytest.mark.parametrize(
        "name,value",
        [("OK", "OK"), ("WARN", "WARN"), ("FAIL", "FAIL"), ("SKIP", "SKIP")],
    )
    def test_severity_value(self, name: str, value: str) -> None:
        assert getattr(Severity, name).value == value


class TestDefaultSeverityMap:
    def test_every_outcome_has_default_severity(self) -> None:
        for outcome in Outcome:
            assert outcome in DEFAULT_SEVERITY, f"missing default severity for {outcome}"

    def test_passed_is_ok(self) -> None:
        assert DEFAULT_SEVERITY[Outcome.PASSED] is Severity.OK

    def test_failed_is_fail(self) -> None:
        assert DEFAULT_SEVERITY[Outcome.FAILED] is Severity.FAIL

    def test_hung_is_fail(self) -> None:
        # Hung MUST severity-as-fail — operators expect to see hangs
        # rendered red, not yellow.
        assert DEFAULT_SEVERITY[Outcome.HUNG] is Severity.FAIL

    def test_quarantined_is_warn(self) -> None:
        # Quarantined is "expected to be flaky, skipping" — WARN,
        # not FAIL.
        assert DEFAULT_SEVERITY[Outcome.QUARANTINED] is Severity.WARN


def _passing_record() -> TestRecord:
    return TestRecord(
        test_id="tests/unit/foo.py::test_bar",
        run_id="run-20260526T011104278Z",
        ts_iso="2026-05-26T01:11:37.906Z",
        outcome=Outcome.PASSED,
        severity=Severity.OK,
        duration_ms=12,
        tier=1,
        markers=["contract"],
    )


def _failed_record() -> TestRecord:
    return TestRecord(
        test_id="tests/unit/foo.py::test_baz",
        run_id="run-20260526T011104278Z",
        ts_iso="2026-05-26T01:11:37.907Z",
        outcome=Outcome.FAILED,
        severity=Severity.FAIL,
        duration_ms=180,
        tier=1,
        markers=["contract", "regression"],
        code="AssertionError",
        summary="assert response.status == 200, got 500",
        detail="Traceback (most recent call last):\n  ...",
        why="Broker is alive (pid 12345, wchan futex_wait); dmesg clean.",
        remediation="See docs/testing/DIAGNOSING_HANGS.md §4.",
        captured_stdout="GET /api/v1/users\nresp 500\n",
        captured_stderr="ERROR    requests:adapter:..\n",
    )


class TestTestRecordToDict:
    def test_passed_record_serialises_with_nulls_preserved(self) -> None:
        rec = _passing_record()
        d = rec.to_dict()
        # Required fields present
        assert d["test_id"] == "tests/unit/foo.py::test_bar"
        assert d["outcome"] == "passed"
        assert d["severity"] == "OK"
        assert d["duration_ms"] == 12
        assert d["tier"] == 1
        assert d["markers"] == ["contract"]
        # Nullable failure fields ARE present (as nulls), not omitted —
        # downstream parsers expect a stable column set.
        assert d["code"] is None
        assert d["summary"] is None
        assert d["detail"] is None
        assert d["why"] is None
        assert d["remediation"] is None
        # Captured I/O defaults to empty string (not null)
        assert d["captured_stdout"] == ""
        assert d["captured_stderr"] == ""
        # Schema versioning
        assert d["schema_version"] == SCHEMA_VERSION

    def test_failed_record_carries_full_doctor_finding_parallel(self) -> None:
        rec = _failed_record()
        d = rec.to_dict()
        # The five DoctorFinding-mirrored fields populate.
        assert d["code"] == "AssertionError"
        assert d["summary"].startswith("assert response")
        assert d["detail"].startswith("Traceback")
        assert d["why"].startswith("Broker is alive")
        assert d["remediation"].startswith("See docs/testing/")


class TestTestRecordJsonl:
    def test_jsonl_line_is_single_line(self) -> None:
        rec = _failed_record()
        line = rec.to_jsonl_line()
        # Exactly one trailing newline, no embedded newlines (so the
        # file format is grep-friendly).
        assert line.endswith("\n")
        assert line.count("\n") == 1

    def test_jsonl_round_trip(self) -> None:
        rec = _failed_record()
        decoded = TestRecord.from_jsonl_line(rec.to_jsonl_line())
        assert decoded == rec

    def test_from_dict_parses_wire_strings_into_enums(self) -> None:
        wire = _passing_record().to_dict()
        # Force the wire form: outcome + severity as strings (which is
        # already what to_dict produces, but pin it explicitly).
        assert isinstance(wire["outcome"], str)
        assert isinstance(wire["severity"], str)
        decoded = TestRecord.from_dict(wire)
        assert decoded.outcome is Outcome.PASSED
        assert decoded.severity is Severity.OK

    def test_from_dict_raises_on_invalid_outcome(self) -> None:
        wire = _passing_record().to_dict()
        wire["outcome"] = "exploded"  # not a valid Outcome
        with pytest.raises(ValueError):
            TestRecord.from_dict(wire)

    def test_from_dict_raises_on_missing_test_id(self) -> None:
        wire = _passing_record().to_dict()
        del wire["test_id"]
        with pytest.raises(KeyError):
            TestRecord.from_dict(wire)

    def test_frozen_immutable(self) -> None:
        rec = _passing_record()
        with pytest.raises(FrozenInstanceError):
            rec.test_id = "other"  # type: ignore[misc]

    def test_jsonl_line_decodes_as_one_json_object(self) -> None:
        rec = _failed_record()
        line = rec.to_jsonl_line()
        # Whole line minus trailing newline parses as one JSON object.
        parsed = json.loads(line.rstrip("\n"))
        assert isinstance(parsed, dict)
        assert parsed["test_id"] == rec.test_id
