"""Read + render per-test perf attribution artifacts (TS-28).

This is the **reader** half of the federation perf-attribution contract. The
*writer* is mgf-common's pytest plugin: when the supervisor runs pytest with
``MGF_PERF=1`` + ``MGF_PERF_DIR=<dir>`` (set by ``mtest --perf``), the plugin
captures, per test, a ``cProfile`` function breakdown plus any ``perf_span``
section timings, and writes one JSON artifact per test into ``<dir>``.

This module never imports mgf-common (TS-23: the supervisor is stdlib-only and
must run even when mgf-common is broken). It only knows the JSON wire shape::

    {
      "schema_version": "1.0.0",
      "test_id": "tests/unit/foo.py::test_bar",
      "total_ms": 12.3,
      "functions": [{"func", "ncalls", "tottime_ms", "cumtime_ms"}, ...],
      "spans":     [{"label", "ms", "depth"}, ...]
    }

The ``test_id`` is carried *inside* each artifact, so we index by content, not
by reconstructing the writer's filename scheme. Every read path is fail-open:
a missing dir, an unreadable file, or a malformed record degrades to "no perf
data", never an exception into the run.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from pathlib import Path


@dataclass
class PerfStore:
    """Incremental index of per-test perf artifacts under ``perf_dir``.

    :meth:`refresh` is cheap to call repeatedly (e.g. once per chunk): it reads
    only artifacts it has not seen yet.


    Example::

        store = PerfStore(Path(".gates/perf"))
    """

    perf_dir: Path
    by_test: dict[str, dict[str, Any]] = field(default_factory=dict)
    _seen: set[str] = field(default_factory=set)

    def refresh(self) -> None:
        """Ingest any new ``*.json`` artifacts. Fail-open per file."""
        if not self.perf_dir.is_dir():
            return
        for p in sorted(self.perf_dir.glob("*.json")):
            if p.name in self._seen:
                continue
            self._seen.add(p.name)
            try:
                data = json.loads(p.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                continue
            test_id = data.get("test_id")
            if isinstance(test_id, str) and isinstance(data, dict):
                self.by_test[test_id] = data

    def get(self, test_id: str) -> dict[str, Any] | None:
        return self.by_test.get(test_id)

    def __bool__(self) -> bool:
        return bool(self.by_test)


def _as_float(value: Any) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return 0.0


def _as_int(value: Any) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0


def render_perf_section(perf: dict[str, Any] | None, top: int = 10) -> str:
    """Render one test's perf record for a TS-27 failure bundle / report.

    Example::

        render_perf_section(record.perf, top=5)  # -> a markdown table
    """
    if not perf:
        return "(no perf data — re-run with `mtest --perf`)"
    lines: list[str] = []
    total = perf.get("total_ms")
    if total is not None:
        lines.append(f"total call-phase time: {_as_float(total):.2f} ms")

    spans = perf.get("spans") or []
    if spans:
        lines.append("")
        lines.append("sections (perf_span, finish order):")
        for s in spans:
            indent = "  " * (_as_int(s.get("depth", 0)) + 1)
            lines.append(f"{indent}{s.get('label', '?')}: {_as_float(s.get('ms')):.2f} ms")

    funcs = perf.get("functions") or []
    if funcs:
        shown = funcs[: max(0, top)]
        lines.append("")
        lines.append(f"top {len(shown)} functions by cumulative time:")
        lines.append(f"  {'cum_ms':>9}  {'self_ms':>9}  {'ncalls':>7}  function")
        for f in shown:
            lines.append(
                f"  {_as_float(f.get('cumtime_ms')):9.2f}  "
                f"{_as_float(f.get('tottime_ms')):9.2f}  "
                f"{_as_int(f.get('ncalls')):7d}  {f.get('func', '?')}"
            )
    return "\n".join(lines) if lines else "(empty perf record)"


def aggregate_self_time(store: PerfStore) -> list[tuple[str, float]]:
    """Sum self-time (tottime) per function across all tests → run hotspots.

    Self-time (not cumulative) is summed so a function isn't double-counted
    via its callers: the result is "where did the run actually spend CPU".


    Example::

        aggregate_self_time(store)  # -> [(test_id, self_ms), ...]
    """
    agg: dict[str, float] = {}
    for perf in store.by_test.values():
        for f in perf.get("functions") or []:
            name = f.get("func")
            if isinstance(name, str):
                agg[name] = agg.get(name, 0.0) + _as_float(f.get("tottime_ms"))
    return sorted(agg.items(), key=lambda kv: (-kv[1], kv[0]))


def slowest_tests(store: PerfStore, top: int = 10) -> list[tuple[str, float]]:
    """The ``top`` slowest tests by captured call-phase ``total_ms``.

    Example::

        slowest_tests(store, top=10)  # -> [(test_id, total_ms), ...]
    """
    rows = [(tid, _as_float(perf.get("total_ms"))) for tid, perf in store.by_test.items()]
    rows.sort(key=lambda kv: (-kv[1], kv[0]))
    return rows[: max(0, top)]


def render_run_summary(store: PerfStore, top: int = 15) -> str:
    """Run-level perf rollup: slowest tests + hottest functions.

    Example::

        print(render_run_summary(store, top=15))
    """
    if not store:
        return ""
    out: list[str] = ["# perf summary (TS-28)", ""]
    tests = slowest_tests(store, top)
    if tests:
        out.append(f"## slowest {len(tests)} tests (call-phase wall-time)")
        for tid, ms in tests:
            out.append(f"- {ms:9.2f} ms  `{tid}`")
        out.append("")
    funcs = aggregate_self_time(store)[:top]
    if funcs:
        out.append(f"## hottest {len(funcs)} functions (self-time across run)")
        for name, ms in funcs:
            out.append(f"- {ms:9.2f} ms  `{name}`")
        out.append("")
    return "\n".join(out)


__all__ = [
    "PerfStore",
    "aggregate_self_time",
    "render_perf_section",
    "render_run_summary",
    "slowest_tests",
]
