"""Per-test observed-duration store + outcome history + recency score.

A small JSON file at `.gates/durations.json` keyed by test_id. Each
entry carries the rolling history (last 5 runs) — enough to feed
auto-tier (tiers.auto_tier) and the budget-aware selector
(selection.value_per_cost).

Schema (per test_id):
```
{
  "history": [
    {"ts_iso": "...", "outcome": "passed", "duration_ms": 123, "run_id": "..."},
    ...   (max 5)
  ]
}
```

Atomic writes (temp + rename) so a kill mid-write can't corrupt.
Missing file → empty store (fresh repo / no auto-tier yet).
"""

from __future__ import annotations

import contextlib
import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any

HISTORY_KEEP = 5


class DurationStore:
    def __init__(self, path: Path) -> None:
        self.path = path
        self._data: dict[str, dict[str, Any]] = {}
        self._load()

    def _load(self) -> None:
        if not self.path.exists():
            return
        try:
            self._data = json.loads(self.path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            self._data = {}

    def record(
        self,
        test_id: str,
        outcome: str,
        duration_ms: int,
        run_id: str,
    ) -> None:
        entry = self._data.setdefault(test_id, {"history": []})
        history: list[dict[str, Any]] = entry.setdefault("history", [])
        history.append(
            {
                "ts_iso": _now_iso(),
                "outcome": outcome,
                "duration_ms": duration_ms,
                "run_id": run_id,
            }
        )
        if len(history) > HISTORY_KEEP:
            entry["history"] = history[-HISTORY_KEEP:]

    def estimated_duration_s(self, test_id: str) -> float | None:
        """Mean of recorded durations in seconds, or None when no
        data yet. Mean — not median — because the tail of slow runs
        IS the budget-relevant data; an underestimate would let a
        slow test spill past the budget."""
        entry = self._data.get(test_id)
        if not entry:
            return None
        history = entry.get("history", [])
        if not history:
            return None
        durs = [h["duration_ms"] / 1000.0 for h in history if "duration_ms" in h]
        if not durs:
            return None
        mean: float = sum(durs) / len(durs)
        return mean

    def durations_for_auto_tier(self, test_id: str) -> list[float]:
        """All recent durations in seconds — the input to
        tiers.auto_tier."""
        entry = self._data.get(test_id)
        if not entry:
            return []
        return [h["duration_ms"] / 1000.0 for h in entry.get("history", [])]

    def recency_score(self, test_id: str) -> float:
        """How recently did this test fail? Higher = sooner. Used
        by the budget-aware selector as the "value" term so we
        surface known-broken tests first."""
        entry = self._data.get(test_id)
        if not entry:
            return 0.0
        history = entry.get("history", [])
        if not history:
            return 0.0
        # Walk newest-first; each step back halves the score.
        score = 0.0
        weight = 1.0
        for h in reversed(history):
            if h.get("outcome") in ("failed", "error", "hung", "crashed"):
                score += weight
            weight *= 0.5
        return score

    def save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp_path = tempfile.mkstemp(
            prefix=".durations-",
            suffix=".json.tmp",
            dir=str(self.path.parent),
        )
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(self._data, f, indent=2, sort_keys=True)
                f.flush()
                os.fsync(f.fileno())
            Path(tmp_path).replace(self.path)
        except OSError:
            with contextlib.suppress(OSError):
                Path(tmp_path).unlink()

    def auto_tier_overrides(self, base_tiers: dict[str, int] | None = None) -> dict[str, int]:
        """Tests whose observed-mean has bumped them above their
        marker tier. Read by selection.build_candidates.

        ``base_tiers`` maps test_id → marker-derived tier
        (``tiers.tier_for_markers``); the caller has it because the
        one-spawn collection returns real markers (M2 Wave-2). The
        auto-bump then starts from the REAL floor — an
        ``integration`` test (tier-2) can be promoted to tier-3.
        Passing ``None`` (legacy callers / tests) falls back to
        :data:`tiers.DEFAULT_TIER` per test, which caps promotions
        at DEFAULT_TIER+1 exactly as the pre-Wave-2 behaviour did.
        """
        from . import tiers as tier_mod

        base_tiers = base_tiers or {}
        out: dict[str, int] = {}
        for test_id, entry in self._data.items():
            history = entry.get("history", [])
            if len(history) < 5:
                continue
            durs = [h["duration_ms"] / 1000.0 for h in history]
            base = base_tiers.get(test_id, tier_mod.DEFAULT_TIER)
            new_tier, _ = tier_mod.auto_tier(base, durs)
            if new_tier > base:
                out[test_id] = new_tier
        return out


def _now_iso() -> str:
    t = time.gmtime()
    return f"{t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d}T{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}Z"
