#!/usr/bin/env python3
"""Phase 1 CI gate: fail when the tool-chaining eval shows salvage above the SLO.

This is the *enforcement* half of the reliability-codification measurement loop. Phase 0
shipped the measurement primitives:

  * ``evals/`` + ``scripts/run_tool_chaining_evals.py`` — write a behavioral suite artifact
    with ``salvage_rate`` / ``completion_without_salvage_rate`` / ``chain_success_rate``.
  * ``kanban_storage.salvage_stats`` + the ``check_kanban_salvage_slo`` doctor probe — the
    *production* (live DB) view of the same number.

This script consumes the **eval-suite artifact** (not the live DB — that is the doctor
probe's job) and returns non-zero when salvage exceeds the configured ceiling. It is meant to
run in CI after a job that ran the live eval, or against a committed/known-good artifact.

It deliberately does **not** disable any salvage/synthesis fallback — those stay in place as
the backup for models that cannot call tools natively. This gate only watches the *number*.

Usage::

    python scripts/check_salvage_slo.py                      # default artifact + config SLO
    python scripts/check_salvage_slo.py --artifact path.json --max-salvage-rate 0.2
    python scripts/check_salvage_slo.py --require-artifact    # missing artifact is a failure
    python scripts/check_salvage_slo.py --max-age-hours 168   # stale artifact is a failure

Exit codes::

    0 — OK (within SLO), or a graceful skip (no artifact / sample below min) when not required
    1 — SLO breached, or a required precondition failed (missing/stale artifact, schema)
    2 — script/usage error (bad JSON, bad arguments)
"""
from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parents[1]

# Kept in sync with evals/tool_chaining.py:ARTIFACT_SCHEMA_VERSION. A mismatch is a warning,
# not a hard error, so an older artifact can still be read on a best-effort basis.
EXPECTED_SCHEMA_VERSION = 1

DEFAULT_ARTIFACT = REPO_ROOT / "evals" / "artifacts" / "tool_chaining_latest.json"

# Literal fallbacks if the app config cannot be imported (keeps the gate CI-portable). These
# mirror the [kanban] defaults in core/config.py.
FALLBACK_MAX_SALVAGE_RATE = 0.25
FALLBACK_MIN_SAMPLE = 20


def _num(value: object, default: float, cast):
    """Non-lossy coercion: a legitimate 0 must survive (``value or default`` would clobber it)."""
    try:
        if value is None:
            return cast(default)
        return cast(value)
    except (TypeError, ValueError):
        return cast(default)


def _config_defaults() -> tuple[float, int]:
    """(max_salvage_rate, min_sample) from [kanban] config, with literal fallback.

    Importing the app config is best-effort: a bare CI checkout without full deps still runs the
    gate against the literal fallbacks rather than crashing.
    """
    try:
        from core.config import get_kanban_config

        cfg = get_kanban_config()
        max_rate = _num(cfg.get("salvage_slo_max", FALLBACK_MAX_SALVAGE_RATE), FALLBACK_MAX_SALVAGE_RATE, float)
        min_sample = _num(cfg.get("salvage_slo_min_sample", FALLBACK_MIN_SAMPLE), FALLBACK_MIN_SAMPLE, int)
        return max_rate, min_sample
    except Exception:
        return FALLBACK_MAX_SALVAGE_RATE, FALLBACK_MIN_SAMPLE


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    cfg_max, cfg_min = _config_defaults()
    p = argparse.ArgumentParser(description="Fail when tool-chaining eval salvage exceeds the SLO.")
    p.add_argument(
        "--artifact",
        default=str(DEFAULT_ARTIFACT),
        help=f"Path to the eval-suite JSON artifact (default: {DEFAULT_ARTIFACT}).",
    )
    p.add_argument(
        "--max-salvage-rate",
        type=float,
        default=cfg_max,
        help=f"Maximum allowed salvage_rate (default from config: {cfg_max}).",
    )
    p.add_argument(
        "--min-sample",
        type=int,
        default=cfg_min,
        help=(
            "Minimum completed-run sample before the SLO is enforced; below this the gate "
            f"skips green to avoid false alarms (default from config: {cfg_min})."
        ),
    )
    p.add_argument(
        "--min-chain-success-rate",
        type=float,
        default=None,
        help="Optional floor on chain_success_rate; below it fails. Off by default.",
    )
    p.add_argument(
        "--max-age-hours",
        type=float,
        default=None,
        help="Optional staleness ceiling on the artifact's generated_at; older fails. Off by default.",
    )
    p.add_argument(
        "--require-artifact",
        action="store_true",
        help="Treat a missing/unreadable artifact as a failure (exit 1) instead of a green skip.",
    )
    return p.parse_args(argv)


def _load_artifact(path: str) -> dict[str, Any]:
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
    if not isinstance(data, dict):
        raise ValueError("artifact root is not a JSON object")
    return data


def _artifact_age_hours(generated_at: str) -> float | None:
    """Hours since ``generated_at`` (UTC, ``%Y-%m-%dT%H:%M:%SZ``). None when unparseable."""
    if not isinstance(generated_at, str) or not generated_at.strip():
        return None
    try:
        ts = datetime.strptime(generated_at.strip(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
    except ValueError:
        return None
    delta = datetime.now(tz=timezone.utc) - ts
    return delta.total_seconds() / 3600.0


def evaluate(data: dict[str, Any], args: argparse.Namespace) -> tuple[int, list[str], list[str]]:
    """Return (exit_code, failures, notes). Pure: no I/O, so it is trivially unit-testable."""
    failures: list[str] = []
    notes: list[str] = []

    schema = data.get("schema_version")
    if schema != EXPECTED_SCHEMA_VERSION:
        notes.append(
            f"artifact schema_version={schema!r} != expected {EXPECTED_SCHEMA_VERSION} "
            "(reading best-effort)"
        )

    completed = int(_num(data.get("completed"), 0, int))
    total = int(_num(data.get("total"), 0, int))
    salvage_rate = float(_num(data.get("salvage_rate"), 0.0, float))
    chain_rate = float(_num(data.get("chain_success_rate"), 0.0, float))

    notes.append(
        f"total={total} completed={completed} salvage_rate={salvage_rate} "
        f"chain_success_rate={chain_rate} model={data.get('model') or '?'}"
    )

    # Staleness precondition (only when requested).
    if args.max_age_hours is not None:
        age = _artifact_age_hours(str(data.get("generated_at") or ""))
        if age is None:
            failures.append(
                f"artifact generated_at is missing/unparseable but --max-age-hours="
                f"{args.max_age_hours} requires a freshness check"
            )
        elif age > float(args.max_age_hours):
            failures.append(
                f"artifact is stale: {age:.1f}h old > --max-age-hours={args.max_age_hours}"
            )

    # Sample gate: salvage_rate's denominator is `completed`, so compare against that.
    if completed < int(args.min_sample):
        notes.append(
            f"completed sample {completed} < min_sample {args.min_sample}; "
            "skipping salvage SLO enforcement (green)"
        )
        # Still enforce an explicitly-requested chain-success floor even on a small sample,
        # since that is a separate, opt-in assertion.
        if args.min_chain_success_rate is not None and chain_rate < float(args.min_chain_success_rate):
            failures.append(
                f"chain_success_rate {chain_rate} < floor {args.min_chain_success_rate}"
            )
        return (1 if failures else 0), failures, notes

    if salvage_rate > float(args.max_salvage_rate):
        failures.append(
            f"salvage_rate {salvage_rate} exceeds SLO max {args.max_salvage_rate} "
            f"(over {completed} completed runs)"
        )

    if args.min_chain_success_rate is not None and chain_rate < float(args.min_chain_success_rate):
        failures.append(
            f"chain_success_rate {chain_rate} < floor {args.min_chain_success_rate}"
        )

    return (1 if failures else 0), failures, notes


def main(argv: list[str] | None = None) -> int:
    args = _parse_args(argv)

    if not os.path.isfile(args.artifact):
        msg = f"no eval artifact at {args.artifact}"
        if args.require_artifact:
            print(f"Salvage SLO gate FAILED: {msg} (--require-artifact)", file=sys.stderr)
            return 1
        print(f"Salvage SLO gate SKIPPED: {msg}; pass --require-artifact to make this an error.")
        return 0

    try:
        data = _load_artifact(args.artifact)
    except (OSError, ValueError, json.JSONDecodeError) as exc:
        print(f"ERROR: cannot read artifact {args.artifact}: {exc}", file=sys.stderr)
        return 2

    code, failures, notes = evaluate(data, args)
    for note in notes:
        print(f"  · {note}")
    if failures:
        print("\nSalvage SLO gate FAILED:", file=sys.stderr)
        for f in failures:
            print(f"  - {f}", file=sys.stderr)
        print(
            "\nThe salvage/synthesis fallbacks are still intact — this gate only flags that the "
            "harness is leaning on them too often. See docs and .cursor rules for the doctrine.",
            file=sys.stderr,
        )
        return code
    print("Salvage SLO gate OK")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
