"""Flake quarantine — auto-skip persistently-failing tests, evict
when they recover.

Policy (MGF-TS-25):
  * Sliding window of the last 5 outcomes per test.
  * 3+ failures in the window → quarantine.
  * 3 consecutive passes (with --quarantine-attempt) → evict.
  * Quarantined tests skipped on default + --tier N + --budget runs;
    `--quarantine-attempt` includes them; `--no-skip-quarantined`
    includes them without resetting the eviction counter.

State lives in `.gates/quarantine.json` as a dict[test_id, entry].
Persisted atomically (write-tmp + rename) so a kill mid-write
can't corrupt. Missing file → no quarantined tests; that's the
fresh-repo state.

Federation reuse: copy verbatim. Policy constants
(WINDOW_SIZE / FAILURES_TO_QUARANTINE / PASSES_TO_EVICT) are
documented as the MGF-TS-25 defaults; siblings that need
different numbers override locally and document why.
"""

from __future__ import annotations

import contextlib
import json
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

WINDOW_SIZE = 5
FAILURES_TO_QUARANTINE = 3
PASSES_TO_EVICT = 3
FAILURE_OUTCOMES = frozenset({"failed", "error", "hung", "crashed"})
PASS_OUTCOMES = frozenset({"passed"})


@dataclass
class QuarantineEntry:
    """One quarantined test's state.

    Example::

        QuarantineEntry(test_id="t.py::test_x", quarantined_at=now_iso())
    """

    test_id: str
    quarantined_at: str
    last_outcomes: list[str] = field(default_factory=list)
    consecutive_passes: int = 0
    needs_for_eviction: int = PASSES_TO_EVICT
    last_failure_summary: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "test_id": self.test_id,
            "quarantined_at": self.quarantined_at,
            "last_outcomes": list(self.last_outcomes),
            "consecutive_passes": self.consecutive_passes,
            "needs_for_eviction": self.needs_for_eviction,
            "last_failure_summary": self.last_failure_summary,
        }

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> QuarantineEntry:
        return cls(
            test_id=d["test_id"],
            quarantined_at=d["quarantined_at"],
            last_outcomes=list(d.get("last_outcomes", [])),
            consecutive_passes=int(d.get("consecutive_passes", 0)),
            needs_for_eviction=int(d.get("needs_for_eviction", PASSES_TO_EVICT)),
            last_failure_summary=d.get("last_failure_summary"),
        )


class QuarantineStore:
    """Persistent quarantine state. Loaded eagerly, saved on each
    `save()` call (the supervisor calls save() after every run).

    Example::

        store = QuarantineStore(Path(".gates/quarantine.json"))
        store.update_outcome("t.py::test_x", "failed", "boom", now_iso())
        store.is_quarantined("t.py::test_x")  # tracked once flaky
        store.save()
    """

    def __init__(self, path: Path) -> None:
        self.path = path
        self._entries: dict[str, QuarantineEntry] = {}
        self._load()

    def _load(self) -> None:
        if not self.path.exists():
            return
        try:
            raw = json.loads(self.path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            # Corrupted state: start fresh rather than wedging.
            # Worst case we lose flake memory, not test results.
            self._entries = {}
            return
        for test_id, d in raw.items():
            try:
                self._entries[test_id] = QuarantineEntry.from_dict({"test_id": test_id, **d})
            except (KeyError, TypeError, ValueError):
                continue  # skip malformed entries

    def save(self) -> None:
        """Atomic write: temp file in the same dir + rename."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            test_id: {k: v for k, v in e.to_dict().items() if k != "test_id"}
            for test_id, e in self._entries.items()
        }
        fd, tmp_path = tempfile.mkstemp(
            prefix=".quarantine-",
            suffix=".json.tmp",
            dir=str(self.path.parent),
        )
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(payload, f, indent=2, sort_keys=True)
                f.flush()
                os.fsync(f.fileno())
            Path(tmp_path).replace(self.path)
        except OSError:
            # Best-effort: leave the old state in place rather than
            # half-overwriting it.
            with contextlib.suppress(OSError):
                Path(tmp_path).unlink()

    def is_quarantined(self, test_id: str) -> bool:
        # Membership in the store is NOT quarantine. `update_outcome` inserts a *tracking*
        # entry on the FIRST failure with `quarantined_at=""`, and only stamps
        # `quarantined_at` once the test crosses the documented 3-of-5 threshold. Returning
        # bare membership meant a test that failed ONCE was reported quarantined and skipped
        # on the next run — so a deterministic, 100%-reproducible broken test turned a red run
        # GREEN on re-run, and "just run it again" is the most natural human action there is.
        # Quarantine is a *policy state*, not "have we seen this test fail". Consult the stamp.
        entry = self._entries.get(test_id)
        return bool(entry and entry.quarantined_at)

    def all(self) -> dict[str, QuarantineEntry]:
        return dict(self._entries)

    def update_outcome(
        self,
        test_id: str,
        outcome: str,
        failure_summary: str | None,
        now_iso: str,
    ) -> None:
        """Record one observed outcome for `test_id` and apply the
        quarantine/eviction rules.

        Returns None — callers query via `is_quarantined()` or
        `all()` afterwards.
        """
        entry = self._entries.get(test_id)
        if entry is None:
            # Not quarantined. Track history in a transient way only
            # for tests that we're WATCHING for quarantine (those
            # with at least one recent failure). The Duration store
            # carries the full history; the quarantine store only
            # remembers tests currently or recently flaky. This
            # bounds the file size on healthy projects.
            if outcome in FAILURE_OUTCOMES:
                entry = QuarantineEntry(
                    test_id=test_id,
                    quarantined_at="",  # not yet
                    last_outcomes=[outcome],
                    last_failure_summary=failure_summary,
                )
                # Check if this single failure tips the test into
                # quarantine — it can't on its own (need 3 of last 5),
                # but we need to keep the entry to accumulate the
                # window.
                self._entries[test_id] = entry
            return

        # Existing entry: slide the window.
        entry.last_outcomes.append(outcome)
        if len(entry.last_outcomes) > WINDOW_SIZE:
            entry.last_outcomes = entry.last_outcomes[-WINDOW_SIZE:]

        # Refresh the latest failure summary if this was a failure.
        if outcome in FAILURE_OUTCOMES and failure_summary:
            entry.last_failure_summary = failure_summary

        # Eviction path: if currently quarantined and this is a pass.
        if entry.quarantined_at and outcome in PASS_OUTCOMES:
            entry.consecutive_passes += 1
            if entry.consecutive_passes >= entry.needs_for_eviction:
                del self._entries[test_id]
                return

        # Eviction setback: any non-pass while quarantined resets
        # the consecutive_passes counter.
        if entry.quarantined_at and outcome not in PASS_OUTCOMES:
            entry.consecutive_passes = 0

        # Quarantine threshold check (only meaningful if not already
        # quarantined).
        if not entry.quarantined_at:
            fails_in_window = sum(1 for o in entry.last_outcomes if o in FAILURE_OUTCOMES)
            has_passed = any(o in PASS_OUTCOMES for o in entry.last_outcomes)
            # A test must have PASSED at least once in the window to be quarantined.
            # TS-25 quarantines *flaky* tests — and "flaky" means it sometimes passes. A
            # test that has NEVER passed in the window is not flaky, it is BROKEN, and
            # quarantining a broken test skips it to green (exit 0) — the exact "a red run
            # goes green on re-run" hole. A broken test must stay RED, running every time,
            # until it either passes (proving flakiness) or gets fixed.
            if fails_in_window >= FAILURES_TO_QUARANTINE and has_passed:
                entry.quarantined_at = now_iso
                entry.consecutive_passes = 0
            elif not any(o in FAILURE_OUTCOMES for o in entry.last_outcomes):
                # All passes in the window AND not quarantined yet: drop
                # the entry — the test is healthy, no reason to track.
                del self._entries[test_id]

    def force_quarantine(self, test_id: str, reason: str, now_iso: str) -> None:
        """Manual quarantine — used by `--quarantine-add` for tests
        a maintainer knows are flaky before auto-detection accumulates
        signal."""
        entry = self._entries.get(test_id) or QuarantineEntry(
            test_id=test_id,
            quarantined_at=now_iso,
        )
        entry.quarantined_at = now_iso
        entry.last_failure_summary = reason
        entry.consecutive_passes = 0
        self._entries[test_id] = entry

    def force_evict(self, test_id: str) -> bool:
        """Manual eviction — used by `--quarantine-remove` for tests
        a maintainer just fixed. Returns True if the entry was
        present, False otherwise."""
        return self._entries.pop(test_id, None) is not None


__all__ = [
    "FAILURES_TO_QUARANTINE",
    "FAILURE_OUTCOMES",
    "PASSES_TO_EVICT",
    "PASS_OUTCOMES",
    "WINDOW_SIZE",
    "QuarantineEntry",
    "QuarantineStore",
]
