"""JSONL record schema for the federation test supervisor (TS-24).

A single :class:`TestRecord` per test outcome, one JSON object per
line in ``.gates/last-run/run.jsonl``. The schema deliberately
mirrors the federation DoctorFinding vocabulary — the one parent is
:class:`mgf.hrb.diagnostics.DoctorFinding` (pre-Wave-2 these
docstrings cited a ``mgf.common.observability.DoctorFinding`` that
never existed) — so downstream tooling (a future Magogi dashboard,
Slack relay, status badges) can use one structured-observation
parser across every federation project.

Schema versioning is mandatory (per TS-24). The current schema is
``"1.1.0"`` (``1.1.0`` added the nullable ``perf`` field for TS-28
perf attribution); AP-04 deprecation cycle applies if a field is
renamed or removed.

Consumers reading ``run.jsonl`` MUST go through :class:`TestRecord`
— DO NOT hand-roll a parser. ``TestRecord.from_jsonl_line`` is the
canonical reader; ``TestRecord.to_jsonl_line`` writes the
canonical wire form.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from enum import StrEnum
from typing import cast

SCHEMA_VERSION = "1.1.0"
"""The current JSONL record schema version. Bumped per AP-04 if
fields are renamed / removed. ``1.1.0`` added the nullable ``perf``
field (TS-28); ``1.0.0`` was the original TS-24 shape."""


class Outcome(StrEnum):
    """The seven possible per-test outcomes the supervisor records.

    ``passed`` / ``failed`` / ``error`` / ``skipped`` are pytest's
    standard four. ``hung`` / ``crashed`` / ``quarantined`` are the
    supervisor's additions:

    - ``hung`` — exceeded the per-test or per-chunk wall-clock
      timeout. Supervisor sent SIGTERM, then SIGKILL after grace.
    - ``crashed`` — pytest subprocess exited with a signal (SIGSEGV
      etc.) or non-pytest-standard exit code. The supervisor
      records this where plain pytest would silently die.
    - ``quarantined`` — skipped because the test is currently in
      ``.gates/quarantine.json``. See TS-25.
    """

    PASSED = "passed"
    FAILED = "failed"
    ERROR = "error"
    SKIPPED = "skipped"
    HUNG = "hung"
    CRASHED = "crashed"
    QUARANTINED = "quarantined"


class Severity(StrEnum):
    """Severity matches the federation DoctorFinding severities
    (:class:`mgf.hrb.diagnostics.DoctorFinding` — the one parent) so
    a single triage UI can render both. DoctorFinding's ``DEFERRED``
    ("check known but not runnable") has no test-outcome meaning and
    deliberately does NOT exist here; ``SKIP`` is the test-specific
    member instead.

    ``OK`` — passed; ``WARN`` — quarantined (excluded from the gate
    by flake state — needs attention, not a pass); ``FAIL`` —
    failed / error / hung / crashed; ``SKIP`` — explicit skip
    (pytest skip/skipif — intentional, by design). See
    :data:`DEFAULT_SEVERITY` for the canonical outcome → severity
    mapping.
    """

    OK = "OK"
    WARN = "WARN"
    FAIL = "FAIL"
    SKIP = "SKIP"


# ``outcome → default severity`` mapping. The supervisor MAY
# override per-test (e.g., a known-flaky skip might surface as
# WARN rather than SKIP), but this is the canonical default a
# consumer can rely on when reading historical records.
DEFAULT_SEVERITY: dict[Outcome, Severity] = {
    Outcome.PASSED: Severity.OK,
    Outcome.SKIPPED: Severity.SKIP,
    Outcome.QUARANTINED: Severity.WARN,
    Outcome.FAILED: Severity.FAIL,
    Outcome.ERROR: Severity.FAIL,
    Outcome.HUNG: Severity.FAIL,
    Outcome.CRASHED: Severity.FAIL,
}


@dataclass(frozen=True)
class TestRecord:
    """One per-test outcome, as written to ``.gates/last-run/run.jsonl``.

    Frozen for safety — readers SHOULD NOT mutate; the supervisor
    writes once and the file is append-only. Schema fields are
    grouped into:

    - **Identity:** ``test_id``, ``run_id``, ``ts_iso``,
      ``schema_version``.
    - **Outcome:** ``outcome``, ``severity``, ``duration_ms``,
      ``tier``, ``markers``.
    - **DoctorFinding parallel:** ``code``, ``summary``,
      ``detail``, ``why``, ``remediation``.
    - **Captured I/O:** ``captured_stdout``, ``captured_stderr``
      (last 4 KB each).
    """

    # Identity
    test_id: str
    """The pytest test id, e.g. ``"tests/foo.py::TestBar::test_baz"``."""

    run_id: str
    """An opaque per-supervisor-invocation id, e.g.
    ``"run-20260526T011104278Z"``."""

    ts_iso: str
    """ISO-8601 UTC timestamp when this record was written, e.g.
    ``"2026-05-26T01:11:37.906Z"``. Per rule TM-02."""

    # Outcome
    outcome: Outcome
    """One of the seven :class:`Outcome` values."""

    severity: Severity
    """One of the four :class:`Severity` values (see :data:`DEFAULT_SEVERITY`)."""

    duration_ms: int
    """Wall-clock duration of the test in milliseconds. ``0`` for
    tests that did not run (skipped, quarantined)."""

    tier: int
    """The effective tier (0/1/2/3) per :func:`effective_tier`.
    Recorded for the supervisor's auto-tier heuristic and for
    downstream tier-mix reporting."""

    markers: list[str] = field(default_factory=list)
    """All pytest markers applied to this test (e.g.
    ``["contract", "regression"]``). Order is the pytest collection
    order."""

    # DoctorFinding parallel (all nullable — empty for passed tests)
    code: str | None = None
    """A machine-readable failure-class code:
    ``"AssertionError"``, ``"WALL_CLOCK_HUNG"``,
    ``"PROCESS_CRASHED"``, etc. ``None`` for passed."""

    summary: str | None = None
    """One-line human-readable headline. ``None`` for passed."""

    detail: str | None = None
    """Multi-line traceback or detailed context. ``None`` for
    passed."""

    why: str | None = None
    """Captured environmental context that explains the failure
    (broker liveness, /proc state, env fingerprint). ``None`` for
    passed."""

    remediation: str | None = None
    """Known fix or pointer to a runbook (e.g., DIAGNOSING_HANGS.md
    section). ``None`` if the failure mode is novel."""

    # Captured I/O
    captured_stdout: str = ""
    """Last 4 KB of the test's stdout."""

    captured_stderr: str = ""
    """Last 4 KB of the test's stderr."""

    # Perf attribution (TS-28) — populated only under `mtest --perf`
    perf: dict[str, object] | None = None
    """Per-test perf attribution, or ``None`` when capture was off.
    Shape mirrors :meth:`mgf.common.testing.PerfSummary.to_dict`:
    ``{"total_ms", "functions": [...], "spans": [...]}``. The
    function rows come from a per-test ``cProfile``; the spans from
    :func:`perf_span` section timers. See TS-28."""

    # Schema versioning
    schema_version: str = SCHEMA_VERSION
    """The schema version this record was written under. Frozen
    here at module-import time so consumers reading older records
    can detect drift."""

    # Pytest collection guard — `TestRecord` matches pytest's
    # T-prefix-is-a-test-class heuristic. The flag tells pytest
    # this is a plain dataclass, not a test class to collect.
    __test__ = False

    def to_dict(self) -> dict[str, object]:
        """Serialise to the wire dict shape used in ``run.jsonl``."""
        return {
            "test_id": self.test_id,
            "run_id": self.run_id,
            "ts_iso": self.ts_iso,
            "outcome": self.outcome.value,
            "severity": self.severity.value,
            "duration_ms": self.duration_ms,
            "tier": self.tier,
            "markers": list(self.markers),
            "code": self.code,
            "summary": self.summary,
            "detail": self.detail,
            "why": self.why,
            "remediation": self.remediation,
            "captured_stdout": self.captured_stdout,
            "captured_stderr": self.captured_stderr,
            "perf": self.perf,
            "schema_version": self.schema_version,
        }

    def to_jsonl_line(self) -> str:
        """Serialise to one JSONL line (single-line JSON + trailing newline)."""
        return json.dumps(self.to_dict(), separators=(",", ":")) + "\n"

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> TestRecord:
        """Parse from the wire dict shape.

        Raises :class:`KeyError` for missing required fields,
        :class:`ValueError` for invalid enum values. Consumers
        reading ``run.jsonl`` SHOULD wrap this in their own
        try/except + log the skipped line.
        """
        return cls(
            test_id=str(data["test_id"]),
            run_id=str(data["run_id"]),
            ts_iso=str(data["ts_iso"]),
            outcome=Outcome(str(data["outcome"])),
            severity=Severity(str(data["severity"])),
            # data is dict[str, object]; the wire types are a JSON number
            # / number / array. Cast to a type int()/the comprehension
            # accept (no-op at runtime) so the coercion still applies but
            # mypy --strict is satisfied (F-018).
            duration_ms=int(cast("float | str", data["duration_ms"])),
            tier=int(cast("float | str", data["tier"])),
            markers=[str(m) for m in cast("list[object]", data.get("markers") or [])],
            code=_optional_str(data.get("code")),
            summary=_optional_str(data.get("summary")),
            detail=_optional_str(data.get("detail")),
            why=_optional_str(data.get("why")),
            remediation=_optional_str(data.get("remediation")),
            captured_stdout=str(data.get("captured_stdout", "")),
            captured_stderr=str(data.get("captured_stderr", "")),
            perf=_optional_dict(data.get("perf")),
            schema_version=str(data.get("schema_version", SCHEMA_VERSION)),
        )

    @classmethod
    def from_jsonl_line(cls, line: str) -> TestRecord:
        """Parse one JSONL line. Trailing newlines tolerated."""
        return cls.from_dict(json.loads(line))


def _optional_str(value: object) -> str | None:
    """Coerce a nullable wire field to ``str | None`` without
    accepting non-string truthy values silently."""
    if value is None:
        return None
    return str(value)


def _optional_dict(value: object) -> dict[str, object] | None:
    """Coerce a nullable wire field to ``dict | None``. A non-dict
    (corrupt record) degrades to ``None`` rather than raising — perf
    is auxiliary, never load-bearing for outcome triage."""
    return value if isinstance(value, dict) else None


__all__ = [
    "DEFAULT_SEVERITY",
    "SCHEMA_VERSION",
    "Outcome",
    "Severity",
    "TestRecord",
]
