"""Structured per-test record + JSONL writer.

The TestRecord shape deliberately mirrors the federation
DoctorFinding vocabulary (canonical implementation today:
`mgf.hrb.diagnostics.DoctorFinding` — the one parent; note that
`mgf.common.testing`'s docstrings historically cited a
`mgf.common.observability.DoctorFinding` that does not exist):
federation projects already learned that shape for operator
diagnostics, so reusing it for test outcomes means there's one
structured-observation vocabulary to learn, not two.

Writes are append-only and best-effort atomic. The writer
opens the JSONL file with O_APPEND + O_CREAT + O_WRONLY and writes
one line at a time. A killed supervisor leaves a partial last
line at worst; the parser tolerates trailing-junk lines.

Schema versioning: `SCHEMA_VERSION` is bumped when the JSON shape
changes. Older parsers see the version and refuse to interpret —
better than silently misreading a renamed field.
"""

from __future__ import annotations

import dataclasses
import json
import os
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator
    from pathlib import Path
    from typing import Any

SCHEMA_VERSION = "1.1.0"  # 1.1.0 added the nullable `perf` field (TS-28).

# Failure-bundle section names + header format (TS-27), VENDORED
# byte-for-byte from `mgf.common.testing.SECTION_ORDER` /
# `SECTION_HEADER_FMT`. Per TS-23 the supervisor is stdlib-only at
# runtime, so it cannot import the public schema — the alignment
# test (tests/unit/test_alignment_with_mgf_common.py) byte-pins the
# two copies instead. The bundle writer (supervisor.py) MUST emit
# every canonical section, in this order, with this exact header
# line — downstream tooling greps for `=== <section> ===` markers.
SECTION_ORDER: tuple[str, ...] = (
    "TEST",
    "OUTCOME",
    "TRACEBACK",
    "STDOUT",
    "STDERR",
    "PROCESS STATE",
    "DMESG TAIL",
    "ENV",
    "HISTORY",
    "NEXT STEPS",
)

SECTION_HEADER_FMT = "=== {name} ==="


# Outcome string enum (not a Python enum; JSONL stores strings).
# The set is closed; the parser rejects unknown values to catch
# schema drift early.
OUTCOMES = frozenset(
    {
        "passed",
        "failed",
        "error",
        "skipped",
        "hung",
        "crashed",
        "quarantined",
    }
)

# Severity strings — the closed set for test records, aligned with
# mgf.common.testing.Severity (the alignment tests pin the sets AND
# the outcome→severity mapping). The vocabulary descends from the
# DoctorFinding severities (OK / WARN / FAIL); DoctorFinding's
# DEFERRED ("check known but not runnable") has no test-outcome
# meaning and is deliberately NOT part of this set — SKIP is the
# test-suite-specific fourth severity instead.
SEVERITIES = frozenset({"OK", "WARN", "FAIL", "SKIP"})


@dataclass(frozen=True, slots=True)
class TestRecord:
    """One row in the run's JSONL log. Shape mirrors DoctorFinding
    so consumers learn one vocabulary for "a structured observation
    about something that happened."

    `code` is a short, stable, machine-pattern-matchable failure
    tag — `AssertionError`, `TimeoutError`, `ConnectionRefusedError`,
    `WALL_CLOCK_HUNG`, `PROCESS_CRASHED`. The same tag across runs
    is the signal that "this is the same bug."

    `summary` is the one-line headline (the thing you'd put in a
    chat message). `detail` is the multi-line body (traceback, full
    captured output). `why` is captured context (broker liveness,
    /proc state, recent history). `remediation` is the known fix
    when one exists; None when the supervisor can't suggest one.


    Example::

        rec = TestRecord(test_id="tests/test_x.py::test_ok", outcome="passed",
                         severity="OK", duration_ms=12, tier=0)
        line = rec.to_json()
    """

    test_id: str
    outcome: str
    severity: str
    duration_ms: int
    tier: int
    markers: tuple[str, ...] = field(default_factory=tuple)
    code: str | None = None
    summary: str | None = None
    detail: str | None = None
    why: str | None = None
    remediation: str | None = None
    captured_stdout: str = ""
    captured_stderr: str = ""
    # Per-test perf attribution (TS-28), or None when capture was off.
    # Shape: {"total_ms", "functions": [...], "spans": [...]} — written
    # by mgf-common's pytest plugin, read back by `perf.py`.
    perf: dict[str, Any] | None = None
    run_id: str = ""
    ts_iso: str = ""
    schema_version: str = SCHEMA_VERSION

    # Pytest collection guard — `TestRecord` matches pytest's
    # T-prefix-is-a-test-class heuristic; the flag says "plain
    # dataclass, not a test class". Mirrors mgf.common.testing.
    __test__ = False

    def __post_init__(self) -> None:
        if self.outcome not in OUTCOMES:
            raise ValueError(f"unknown outcome {self.outcome!r}")
        if self.severity not in SEVERITIES:
            raise ValueError(f"unknown severity {self.severity!r}")

    def to_json(self) -> str:
        """Serialise to a single JSON line (newline-terminated by
        the writer, not by this method)."""
        payload = dataclasses.asdict(self)
        # Tuples → lists for JSON.
        payload["markers"] = list(self.markers)
        return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))


def severity_for_outcome(outcome: str) -> str:
    """Default severity per outcome. Callers can override (e.g. a
    test that fails but is documented as a known flake gets WARN
    instead of FAIL).

    The mapping is pinned against
    ``mgf.common.testing.DEFAULT_SEVERITY`` by the alignment tests.
    ``quarantined`` is WARN, not SKIP (M2 Wave-2 unification —
    records.py previously said SKIP while the public schema said
    WARN): a quarantined test is excluded from the gate by flake
    state, which needs attention; an explicit pytest skip is by
    design.

    Example::

        severity_for_outcome("failed")       # -> 'FAIL'
        severity_for_outcome("quarantined")  # -> 'WARN'
    """
    if outcome == "passed":
        return "OK"
    if outcome == "skipped":
        return "SKIP"
    if outcome == "quarantined":
        return "WARN"
    return "FAIL"


def now_iso() -> str:
    """Wall-clock ISO-8601 UTC timestamp. Used for record ts_iso
    and run_id derivation. `time.time` + `gmtime` avoids the
    `datetime` import for stdlib parity.

    Example::

        now_iso()  # -> '2026-06-07T12:00:00.123Z'
    """
    t = time.gmtime()
    fractional = int((time.time() - int(time.time())) * 1000)
    return (
        f"{t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d}T"
        f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}.{fractional:03d}Z"
    )


class JsonlWriter:
    """Append-only JSONL writer. Best-effort atomic per-line append
    via O_APPEND. Caller is responsible for fsync if durability
    against power-loss matters; tests don't, so we don't.


    Example::

        writer = JsonlWriter(Path(".gates/last-run/run.jsonl"))
    """

    def __init__(self, path: Path) -> None:
        self.path = path
        self.path.parent.mkdir(parents=True, exist_ok=True)
        # Don't open the file persistently — we want each write to
        # be self-contained (open → write → close) so a crash
        # between writes doesn't leak fd state.

    def append(self, record: TestRecord) -> None:
        """Append one record. Catches and swallows IOError so a
        full disk doesn't bubble up into the test runner's
        process — the supervisor's job is to KEEP RUNNING."""
        line = record.to_json() + "\n"
        try:
            fd = os.open(
                str(self.path),
                os.O_WRONLY | os.O_CREAT | os.O_APPEND,
                0o644,
            )
            try:
                os.write(fd, line.encode("utf-8"))
            finally:
                os.close(fd)
        except OSError as e:
            # Last-resort: print to stderr so the contributor at
            # least sees something. The supervisor itself stays alive.
            import sys

            print(
                f"mtest: failed to append JSONL record for {record.test_id}: {e}",
                file=sys.stderr,
            )


def read_jsonl(path: Path) -> Iterator[dict[str, Any]]:
    """Read a JSONL file, tolerating a partial trailing line
    (common when a supervisor was killed mid-write). Skips lines
    that don't parse + emits a warning to stderr for each.

    Example::

        for record in read_jsonl(Path(".gates/last-run/run.jsonl")):
            print(record["test_id"], record["outcome"])
    """
    if not path.exists():
        return
    import sys

    with path.open("r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                yield json.loads(line)
            except json.JSONDecodeError as e:
                print(
                    f"mtest: skipping malformed line {path}:{lineno}: {e}",
                    file=sys.stderr,
                )


__all__ = [
    "OUTCOMES",
    "SCHEMA_VERSION",
    "SECTION_HEADER_FMT",
    "SECTION_ORDER",
    "SEVERITIES",
    "JsonlWriter",
    "TestRecord",
    "now_iso",
    "read_jsonl",
    "severity_for_outcome",
]
