"""Tests for `mgf.common.testing._perf` — TS-28 perf attribution (writer)."""

from __future__ import annotations

import json
import time
from typing import TYPE_CHECKING

from mgf.common.testing import (
    PERF_SCHEMA_VERSION,
    PerfFunction,
    PerfSummary,
    perf_span,
)

if TYPE_CHECKING:
    from pathlib import Path
from mgf.common.testing._perf import (
    MAX_SPANS,
    _safe_stem,
    _SpanCollector,
    collect_spans,
    functions_from_profile,
    write_perf_artifact,
)


class TestPerfSpanCheap:
    def test_span_is_noop_when_inactive(self) -> None:
        # Outside collect_spans(), perf_span just yields — no collector,
        # no record, no error. (This is the production-path cost: one
        # ContextVar read.)
        with perf_span("anything"):
            pass  # must not raise

    def test_spans_recorded_when_active(self) -> None:
        with collect_spans() as collector, perf_span("outer"), perf_span("inner"):
            pass
        labels = [s.label for s in collector.spans]
        depths = {s.label: s.depth for s in collector.spans}
        # Inner finishes first (append on __exit__), then outer.
        assert labels == ["inner", "outer"]
        assert depths == {"inner": 1, "outer": 0}
        assert all(s.ms >= 0.0 for s in collector.spans)

    def test_collector_restored_after_block(self) -> None:
        from mgf.common.testing import _perf

        assert _perf._active.get() is None
        with collect_spans():
            assert _perf._active.get() is not None
        assert _perf._active.get() is None

    def test_span_overflow_is_bounded(self) -> None:
        with collect_spans() as collector:
            for i in range(MAX_SPANS + 50):
                with perf_span(f"s{i}"):
                    pass
        assert len(collector.spans) == MAX_SPANS
        assert collector._overflowed is True


class TestFunctionsFromProfile:
    def test_extracts_and_sorts_by_cumtime(self) -> None:
        import cProfile

        def busy() -> int:
            total = 0
            for _ in range(20000):
                total += 1
            return total

        prof = cProfile.Profile()
        prof.enable()
        busy()
        prof.disable()

        funcs = functions_from_profile(prof, top_n=10)
        assert funcs, "expected at least one profiled function"
        assert all(isinstance(f, PerfFunction) for f in funcs)
        # Sorted by cumulative time, descending.
        cums = [f.cumtime_ms for f in funcs]
        assert cums == sorted(cums, reverse=True)
        # 'busy' should be attributed somewhere in the frames.
        assert any("busy" in f.func for f in funcs)

    def test_top_n_caps_rows(self) -> None:
        import cProfile

        prof = cProfile.Profile()
        prof.enable()
        sum(range(1000))
        prof.disable()
        assert len(functions_from_profile(prof, top_n=2)) <= 2

    def test_fail_open_on_garbage(self) -> None:
        # A non-profile object must not raise — returns ().
        assert functions_from_profile(object(), top_n=5) == ()  # type: ignore[arg-type]


class TestPerfSummarySerialization:
    def test_round_trips_through_json(self) -> None:
        summary = PerfSummary(
            test_id="tests/unit/foo.py::test_bar",
            total_ms=12.345,
            functions=(PerfFunction("a/b.py:10(f)", 3, 1.111, 2.222),),
            spans=(),
        )
        payload = json.loads(summary.to_json())
        assert payload["schema_version"] == PERF_SCHEMA_VERSION
        assert payload["test_id"] == "tests/unit/foo.py::test_bar"
        assert payload["total_ms"] == 12.345
        assert payload["functions"][0]["func"] == "a/b.py:10(f)"
        assert payload["functions"][0]["cumtime_ms"] == 2.222


class TestWriteArtifact:
    def test_writes_artifact_with_test_id_inside(self, tmp_path: Path) -> None:
        summary = PerfSummary(test_id="tests/unit/x.py::test_y", total_ms=1.0)
        dest = write_perf_artifact(tmp_path, summary)
        assert dest is not None
        assert dest.parent == tmp_path
        # test_id stored IN the file so the supervisor indexes by content.
        data = json.loads(dest.read_text(encoding="utf-8"))
        assert data["test_id"] == "tests/unit/x.py::test_y"

    def test_creates_missing_dir(self, tmp_path: Path) -> None:
        nested = tmp_path / "a" / "b" / "perf"
        dest = write_perf_artifact(nested, PerfSummary("t::t", 1.0))
        assert dest is not None
        assert nested.is_dir()

    def test_fail_open_returns_none(self, tmp_path: Path) -> None:
        # Point at a path whose parent is a FILE → mkdir fails → None.
        f = tmp_path / "afile"
        f.write_text("x")
        assert write_perf_artifact(f / "sub", PerfSummary("t::t", 1.0)) is None

    def test_safe_stem_collision_resistant(self) -> None:
        a = _safe_stem("tests/unit/foo.py::test_a")
        b = _safe_stem("tests/unit/foo.py::test_b")
        assert a != b
        # filesystem-safe
        assert all(c.isalnum() or c in "-_." for c in a)


class TestSpanCollectorThreadIsolation:
    def test_nested_activation_restores_outer(self) -> None:
        from mgf.common.testing import _perf

        with collect_spans() as outer:
            with perf_span("o"):
                with collect_spans() as inner, perf_span("i"):
                    pass
                # inner captured "i"; outer must be active again here
                assert _perf._active.get() is outer
            assert [s.label for s in inner.spans] == ["i"]
        assert [s.label for s in outer.spans] == ["o"]

    def test_collector_default_state(self) -> None:
        c = _SpanCollector()
        assert c.spans == []
        assert c.depth == 0

    def test_perf_span_measures_elapsed(self) -> None:
        with collect_spans() as collector, perf_span("sleep"):
            time.sleep(0.01)
        assert collector.spans[0].ms >= 9.0  # ~10ms, allow scheduling slack
