"""CLI entry point — `mgf-test-supervisor` (or the `mtest` alias).

The CLI is intentionally small. Heavy lifting lives in the other
modules. The job here:
  1. Parse args.
  2. Decide what to do (run / report / quarantine / self-check / resume).
  3. Wire the right modules together.
  4. Exit with a meaningful code.

Exit codes:
  0  — clean (all selected tests passed)
  1  — at least one test failed / errored / hung / crashed
  2  — supervisor itself failed before completing
  3  — usage error (bad flags)
  4  — self-check failed

Repo root discovery: the supervisor walks up from the CWD looking
for `pyproject.toml` — that's the consumer's project root, NOT
the library's install location (`site-packages/mgf/test_supervisor/`
is meaningless). The `.gates/` directory + pytest invocations
anchor at that consumer-side root.
"""

from __future__ import annotations

import argparse
import os
import sys
import time
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator

from . import __version__
from .quarantine import QuarantineStore
from .records import now_iso
from .selection import SelectionReport, build_candidates, select_tests
from .supervisor import RunConfig, Supervisor, collect_tests
from .tiers import parse_tier_range, tier_for_markers


def _rich_version() -> str:
    """OP-01 — rich version: package version + commit (best-effort) + runtime."""
    import platform
    import shutil
    import subprocess
    import sys

    sha = "unknown (released build)"
    git = shutil.which("git")
    if git:
        try:
            out = subprocess.run(
                [git, "rev-parse", "--short", "HEAD"],
                cwd=Path(__file__).resolve().parent,
                capture_output=True,
                text=True,
                timeout=2,
                check=False,
            )
            if out.returncode == 0 and out.stdout.strip():
                sha = out.stdout.strip()
        except (OSError, subprocess.SubprocessError):
            pass
    return (
        f"mgf-test-supervisor {__version__}\n"
        f"  commit:   {sha}\n"
        f"  python:   {platform.python_version()} ({platform.python_implementation()})\n"
        f"  platform: {platform.system()}-{platform.machine()}\n"
        f"  exec:     {sys.executable}"
    )


class _VersionAction(argparse.Action):
    """Lazy --version: build the rich string only when invoked (no git on every call)."""

    def __init__(self, option_strings, dest, **kwargs):  # type: ignore[no-untyped-def]
        super().__init__(option_strings, dest, nargs=0, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):  # type: ignore[no-untyped-def]
        print(_rich_version())
        parser.exit()


def _find_repo_root() -> Path:
    """Walk up from CWD looking for the project's pyproject.toml.

    Why CWD: the supervisor is a published library. It runs from
    inside the CONSUMER's project, not from its own install
    location. `Path(__file__).parents[N]` would point at site-
    packages — wrong. The consumer's project is what carries the
    pyproject [tool.pytest.ini_options] block + the .venv + the
    test tree we need to drive.

    Override: `MGF_TEST_SUPERVISOR_REPO_ROOT` (escape hatch for
    CI runners that invoke the supervisor from outside the project).
    """
    override = os.environ.get("MGF_TEST_SUPERVISOR_REPO_ROOT")
    if override:
        return Path(override).resolve()
    cwd = Path.cwd().resolve()
    for candidate in [cwd, *cwd.parents]:
        if (candidate / "pyproject.toml").exists():
            return candidate
    raise SystemExit(
        "mgf-test-supervisor: no pyproject.toml found in CWD or any "
        "ancestor. cd into your project directory before running, or "
        "set MGF_TEST_SUPERVISOR_REPO_ROOT=/path/to/project."
    )


def _find_pytest_cmd(repo_root: Path) -> list[str]:
    """Prefer the consumer's project venv (carries their test deps);
    fall back to whatever interpreter is running this CLI."""
    venv_py = repo_root / ".venv" / "bin" / "python"
    if venv_py.exists():
        return [str(venv_py), "-m", "pytest"]
    return [sys.executable, "-m", "pytest"]


@contextmanager
def _gates_lock(gates_dir: Path) -> Iterator[None]:
    """Advisory exclusive lock on ``.gates/`` so two concurrent ``mtest``
    runs on one checkout can't interleave writes to the JSONL / quarantine
    / duration stores and corrupt them (PAPER-95).

    POSIX-only (``fcntl``); on a platform without it the lock degrades to a
    no-op — the federation's CI is Linux/macOS.
    """
    try:
        import fcntl
    except ImportError:  # pragma: no cover - non-POSIX fallback
        yield
        return
    lock_path = gates_dir / ".lock"
    with lock_path.open("w") as fh:
        try:
            fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            raise SystemExit(
                f"mtest: another run holds {lock_path}. Concurrent runs "
                "corrupt shared .gates/ state — wait for the other run to "
                "finish, or use a separate checkout."
            ) from None
        try:
            yield
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)


def _handle_empty_selection(
    num_candidates: int, allowed_tiers: set[int], require_nonempty: bool
) -> int:
    """Decide exit behaviour when the selection is empty (PAPER-95).

    Empty because the project genuinely has no tests is already handled
    upstream (exit 2). This handles the dangerous case: tests EXIST but the
    tier/budget/quarantine filter excluded every one, so a gate wired here
    runs nothing. That must be loud — a green result that tested nothing is
    worse than a red one.

    Since M2 Wave-2, ``require_nonempty`` is the DEFAULT for runs
    that passed an explicit ``--tier`` (a CI gate wired as
    ``mtest --tier 0-2`` must never green on an empty selection);
    ``--allow-empty`` opts out.
    """
    if num_candidates == 0:
        print("mtest: nothing to run. Exiting clean.")
        return 0
    print(
        f"\n  ⚠️  WARNING: 0 of {num_candidates} collected tests matched "
        f"tier {sorted(allowed_tiers)}.\n"
        "      Unmarked tests are tier-1; tier-0 needs a `smoke` marker.\n"
        "      This gate executed NOTHING — a green result here is not a "
        "passing test run.",
        file=sys.stderr,
    )
    if require_nonempty:
        print(
            "mtest: --require-nonempty (the default for --tier runs) → "
            "exiting non-zero. (pass --allow-empty to permit an empty "
            "tier selection.)",
            file=sys.stderr,
        )
        return 2
    print(
        "      (pass --require-nonempty to make this a hard failure.)\n",
        file=sys.stderr,
    )
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)

    repo_root = _find_repo_root()
    # Gates dir: an `MGF_GATES_DIR` override lets concurrent CI steps point at
    # isolated `.gates/` dirs so they don't contend on the shared advisory lock
    # (defaults to the repo's `.gates/`).
    env_gates = os.environ.get("MGF_GATES_DIR")
    gates_dir = Path(env_gates) if env_gates else repo_root / ".gates"
    gates_dir.mkdir(parents=True, exist_ok=True)

    pytest_cmd = _find_pytest_cmd(repo_root)

    # Lock-free sub-commands. `report` / `quarantine` are read-only; and
    # `--self-check` runs each scenario in its OWN tmp `.gates` (self_check.py),
    # so it never writes the shared stores and MUST NOT take the shared lock —
    # otherwise parallel CI self-checks (the supervisor smoke test running across
    # 3 Python-version steps + the dedicated supervisor-self-check step) fail the
    # exclusive lock with "another run holds .gates/.lock". (PAPER-95 lock guards
    # only the real run, which writes durations/quarantine/state.)
    if args.subcommand == "report" or args.report:
        return _cmd_report(gates_dir)
    if args.subcommand == "quarantine" or args.quarantine:
        return _cmd_quarantine(gates_dir)
    if args.subcommand == "self-check" or args.self_check:
        return _cmd_self_check(repo_root, gates_dir, pytest_cmd, args.verbose)

    # Only the real run writes the shared `.gates/` stores — serialise it (PAPER-95).
    with _gates_lock(gates_dir):
        return _cmd_run(args, repo_root, gates_dir, pytest_cmd)


def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mtest",
        description=(
            "Magogi Foundation test supervisor — budget-aware, crash-survivable, "
            "resumable. Drives pytest from the outside. See "
            "docs/testing/SUPERVISOR_DESIGN.md for internals."
        ),
    )
    p.add_argument(
        "--version", action=_VersionAction, help="show version + commit + runtime info and exit"
    )
    p.add_argument(
        "--tier",
        default=None,
        help="Tier range to include (e.g. '0', '0-2', '1-3'). Default: 0-3. "
        "Passing --tier explicitly turns on --require-nonempty (a tiered "
        "gate must not green on an empty selection); --allow-empty opts out.",
    )
    p.add_argument(
        "--budget",
        default=None,
        help="Time budget like '30s' or '5m'. Greedy knapsack selection; "
        "tier-0 is always included. Default: no budget.",
    )
    p.add_argument(
        "--skip-quarantined",
        action="store_true",
        default=True,
        help="Skip currently-quarantined tests (default).",
    )
    p.add_argument(
        "--no-skip-quarantined",
        action="store_false",
        dest="skip_quarantined",
        help="Include quarantined tests this run (without affecting eviction).",
    )
    p.add_argument(
        "--quarantine-attempt",
        action="store_true",
        help="Include quarantined tests AND track passes toward eviction.",
    )
    p.add_argument(
        "--resume",
        action="store_true",
        help="Continue from the last interrupted run (.gates/state.json).",
    )
    p.add_argument(
        "--fail-fast",
        action="store_true",
        help="Stop after the first failed chunk (default: run all chunks).",
    )
    p.add_argument(
        "--require-nonempty",
        action="store_true",
        help="Exit non-zero if the tier/budget selection matches zero tests "
        "(guards against a silently-empty gate — see PAPER-95). DEFAULT "
        "whenever --tier is passed explicitly (M2 Wave-2); this flag forces "
        "it for un-tiered runs too.",
    )
    p.add_argument(
        "--allow-empty",
        action="store_true",
        help="Opt out of the --tier-run default --require-nonempty: an "
        "explicitly-tiered run whose selection is empty exits 0 (with the "
        "loud warning) instead of 2.",
    )
    p.add_argument(
        "--quarantine-report",
        action="store_true",
        help="Print a quarantine summary table after the run (the same "
        "entries `mtest --quarantine` shows, compact).",
    )
    p.add_argument("--verbose", "-v", action="store_true")
    p.add_argument(
        "--perf",
        action="store_true",
        help="Capture per-test perf attribution (TS-28): cProfile function "
        "breakdown + perf_span sections. Surfaced in run.jsonl, failure "
        "bundles, and .gates/last-run/perf-summary.md. Requires the "
        "mgf-common pytest plugin in the project under test.",
    )

    # Sub-commands implemented as flags for simplicity (subparsers
    # would force a verb on the bare `mtest` form).
    p.add_argument(
        "--self-check",
        action="store_true",
        help="Run the supervisor self-check (canned pass/fail/hang/crash).",
    )
    p.add_argument(
        "--report",
        action="store_true",
        help="Show the last run's summary.",
    )
    p.add_argument(
        "--quarantine",
        action="store_true",
        help="Show current quarantine state.",
    )

    # Hidden positional for forward-compat (sub-commands).
    p.add_argument("subcommand", nargs="?", default=None, help=argparse.SUPPRESS)
    return p


def _cmd_run(
    args: argparse.Namespace,
    repo_root: Path,
    gates_dir: Path,
    pytest_cmd: list[str],
) -> int:
    """The default action: collect → select → run → report."""
    # --tier default is a sentinel: an EXPLICIT --tier turns
    # --require-nonempty on by default (M2 Wave-2 — a tiered gate
    # that selects nothing must be red, not green); --allow-empty
    # opts out, --require-nonempty forces it for un-tiered runs.
    tier_given = args.tier is not None
    try:
        allowed_tiers = parse_tier_range(args.tier if tier_given else "0-3")
    except ValueError as e:
        print(f"mtest: {e}", file=sys.stderr)
        return 3
    require_nonempty = args.require_nonempty or (tier_given and not args.allow_empty)

    budget_s = _parse_budget(args.budget) if args.budget else None
    if args.budget and budget_s is None:
        print(f"mtest: cannot parse --budget {args.budget!r}", file=sys.stderr)
        return 3

    print("mtest: collecting tests…")
    t0 = time.monotonic()
    # ONE pytest spawn returns node-ids AND markers (the injected
    # marker-dump plugin) — pre-Wave-2 this took 1 + 13 per-marker
    # collect-only spawns (PLAN §2.3).
    test_ids_with_markers = collect_tests(pytest_cmd, repo_root)
    if not test_ids_with_markers:
        print(
            "mtest: no tests collected. Is the venv installed? `uv pip install -e .[dev]`",
            file=sys.stderr,
        )
        return 2
    markers = {tid: mk for tid, mk in test_ids_with_markers if mk}
    print(f"mtest: collected {len(test_ids_with_markers)} tests in {time.monotonic() - t0:.1f}s")

    from .durations import DurationStore

    durations = DurationStore(gates_dir / "durations.json")
    quarantine = QuarantineStore(gates_dir / "quarantine.json")

    duration_map = {
        tid: durations.estimated_duration_s(tid) or 0.0 for tid, _ in test_ids_with_markers
    }
    recency_map = {tid: durations.recency_score(tid) for tid, _ in test_ids_with_markers}
    # Auto-tier starts from each test's REAL marker-derived tier
    # (pre-Wave-2 it assumed tier-1 for everything, capping every
    # promotion at tier-2 and ignoring the marker floor).
    base_tiers = {tid: tier_for_markers(mk) for tid, mk in test_ids_with_markers}
    candidates = build_candidates(
        test_ids_with_markers,
        durations=duration_map,
        recency=recency_map,
        auto_tier_overrides=durations.auto_tier_overrides(base_tiers),
    )

    skip_q = args.skip_quarantined and not args.quarantine_attempt
    quarantined_ids = frozenset(quarantine.all().keys())
    report = select_tests(
        candidates,
        allowed_tiers=allowed_tiers,
        skip_quarantined=skip_q,
        quarantined_ids=quarantined_ids,
        budget_s=budget_s,
    )

    _print_selection(report, allowed_tiers, budget_s, len(quarantined_ids), skip_q)

    if args.resume:
        completed = _load_completed(gates_dir / "state.json")
        if completed:
            before = len(report.selected)
            report.selected = [t for t in report.selected if t not in completed]
            print(
                f"mtest: --resume — skipping {before - len(report.selected)} "
                f"already-completed tests from previous run"
            )

    if not report.selected:
        if args.resume:
            print("mtest: all selected tests already completed last run. Clean.")
            return 0
        rc = _handle_empty_selection(
            num_candidates=len(candidates),
            allowed_tiers=allowed_tiers,
            require_nonempty=require_nonempty,
        )
        if args.quarantine_report:
            _print_quarantine_table(quarantine)
        return rc

    cfg = RunConfig(
        repo_root=repo_root,
        gates_dir=gates_dir,
        test_ids=report.selected,
        test_markers=markers,
        run_id=_make_run_id(),
        pytest_cmd=pytest_cmd,
        fail_fast=args.fail_fast,
        verbose=args.verbose,
        perf=args.perf,
        # Quarantine honesty: the supervisor emits one `quarantined`
        # record (severity WARN) per selector-excluded test.
        quarantined_ids=tuple(report.skipped_quarantined),
    )
    sup = Supervisor(cfg)
    rc = sup.run()
    if args.quarantine_report:
        # Re-read the store — the run just updated + saved it.
        _print_quarantine_table(QuarantineStore(gates_dir / "quarantine.json"))
    return rc


def _cmd_report(gates_dir: Path) -> int:
    summary = gates_dir / "last-run" / "summary.md"
    if not summary.exists():
        print("mtest: no last run found. Run `mtest` first.", file=sys.stderr)
        return 2
    print(summary.read_text(encoding="utf-8"))
    return 0


def _cmd_quarantine(gates_dir: Path) -> int:
    q = QuarantineStore(gates_dir / "quarantine.json")
    entries = q.all()
    if not entries:
        print("mtest: no quarantined tests.")
        return 0
    print(f"mtest: {len(entries)} quarantined test(s):\n")
    for tid, e in sorted(entries.items()):
        marker = "🟡 watching" if not e.quarantined_at else "🔴 QUARANTINED"
        print(f"  {marker}  {tid}")
        print(f"    last outcomes: {' '.join(e.last_outcomes) or '(none)'}")
        if e.quarantined_at:
            print(f"    quarantined: {e.quarantined_at}")
            print(f"    passes toward eviction: {e.consecutive_passes}/{e.needs_for_eviction}")
        if e.last_failure_summary:
            print(f"    last failure: {e.last_failure_summary[:120]}")
        print()
    return 0


def _print_quarantine_table(store: QuarantineStore) -> None:
    """Compact end-of-run quarantine table (``--quarantine-report``).

    The gate's flake-holes in one glance: what's excluded (severity
    WARN in run.jsonl), what's merely watched, and how far each
    quarantined test is from eviction.
    """
    entries = store.all()
    if not entries:
        print("\n[mtest] quarantine report: empty — no flaky tests tracked.")
        return
    quarantined = sum(1 for e in entries.values() if e.quarantined_at)
    watching = len(entries) - quarantined
    print(f"\n[mtest] quarantine report: {quarantined} quarantined (WARN), {watching} watching")
    header = f"  {'STATE':<12} {'EVICT':<6} {'LAST OUTCOMES':<28} TEST"
    print(header)
    for tid, e in sorted(entries.items()):
        state = "QUARANTINED" if e.quarantined_at else "watching"
        evict = f"{e.consecutive_passes}/{e.needs_for_eviction}" if e.quarantined_at else "-"
        outcomes = " ".join(o[:1].upper() for o in e.last_outcomes) or "-"
        print(f"  {state:<12} {evict:<6} {outcomes:<28} {tid}")
    print(
        "  (P=passed F=failed E=error H=hung C=crashed S=skipped; "
        "evict = consecutive passes needed via --quarantine-attempt)"
    )


def _cmd_self_check(
    repo_root: Path,
    gates_dir: Path,
    pytest_cmd: list[str],
    verbose: bool,
) -> int:
    """Run the canned scenarios. Each must produce the expected
    outcome OR the supervisor itself is broken."""
    from .self_check import run_self_check

    ok = run_self_check(repo_root, gates_dir, pytest_cmd, verbose)
    return 0 if ok else 4


def _print_selection(
    report: SelectionReport,
    allowed_tiers: set[int],
    budget_s: float | None,
    quarantined_count: int,
    skip_q: bool,
) -> None:
    print(
        f"mtest: selection — tiers {sorted(allowed_tiers)}, budget {budget_s}s"
        if budget_s
        else f"mtest: selection — tiers {sorted(allowed_tiers)}, no budget"
    )
    print(
        f"  selected:            {len(report.selected):>4}  "
        f"(~{report.estimated_total_s:.1f}s estimated)"
    )
    print(f"  skipped — wrong tier: {len(report.skipped_wrong_tier):>4}")
    if skip_q:
        print(f"  skipped — quarantined: {len(report.skipped_quarantined):>4}")
    print(f"  skipped — over budget: {len(report.skipped_budget):>4}")
    if quarantined_count:
        print(
            f"  (quarantined total in store: {quarantined_count} — "
            f"use --no-skip-quarantined to include)"
        )


def _parse_budget(spec: str) -> float | None:
    spec = spec.strip().lower()
    if spec.endswith("s"):
        try:
            return float(spec[:-1])
        except ValueError:
            return None
    if spec.endswith("m"):
        try:
            return float(spec[:-1]) * 60.0
        except ValueError:
            return None
    if spec.endswith("h"):
        try:
            return float(spec[:-1]) * 3600.0
        except ValueError:
            return None
    try:
        return float(spec)
    except ValueError:
        return None


def _make_run_id() -> str:
    ts = now_iso().replace(":", "").replace("-", "").replace(".", "")
    return f"run-{ts}"


def _load_completed(state_path: Path) -> set[str]:
    if not state_path.exists():
        return set()
    try:
        import json

        return set(json.loads(state_path.read_text(encoding="utf-8")).get("completed", []))
    except (OSError, ValueError):
        return set()


if __name__ == "__main__":
    sys.exit(main())
