"""Gate 1/2 audit runner for selectivity and strengthened exploiters."""

from __future__ import annotations

import json
import math
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any

import jax
import jax.numpy as jnp
import numpy as np
import yaml

from lost_cities_jax.engine import current_hand_sorted, legal_action_mask, reset_from_order, step
from lost_cities_jax.obs import observation
from lost_cities_jax.opponents import policy_by_name
from lost_cities_jax.ppo import (
    ActorCritic,
    JaxPPOConfig,
    batch_score_diff_for_players,
    checkpoint_policy,
    create_train_state,
    evaluate_checkpoint_match,
    evaluate_checkpoint_vs_static,
    evaluate_policy_match,
    load_config,
    make_shuffle_bank,
    mask_logits,
    restore_checkpoint,
    train_against_checkpoint,
)
from lost_cities_jax.types import (
    CARDS_PER_COLOR,
    MAX_STEPS,
    N_ACTIONS,
    N_CARDS,
    PLAY,
    State,
)

ACTION_IDS = jnp.arange(N_ACTIONS, dtype=jnp.int32)
ACTION_HAND_SLOT = ACTION_IDS // 12
ACTION_PLACE_TYPE = (ACTION_IDS % 12) // 6
NEG_INF = -1.0e9


@dataclass
class ExploiterSpec:
    name: str
    config: str
    resume: str | None = None
    notes: str = ""


@dataclass
class RepairConfig:
    max_cycles: int = 3
    league_template: str = "configs/jax_ppo/league-v1.yaml"
    experiment_name: str = "jax-ppo-gates-1-2-repair"
    artifact_subdir: str = "gate2c"
    evaluation_games: int | None = None
    evaluation_batch_games: int | None = None
    guard_expert_ci_low: float = 0.0
    guard_max_steps_rate: float = 0.02


@dataclass
class GatesConfig:
    artifact_root: str = "/mnt/2tbhdd/coolrl-lost-cities-artifacts/gates-1-2"
    report_path: str = "docs/reports/gates-1-2-2026-07-05.md"
    summary_path: str = "docs/reports/gates-1-2-2026-07-05-summary.jsonl"
    target_config: str = (
        "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/"
        "2026-07-05_052325_jax-ppo-league-v1/main_ppo_config.json"
    )
    target_checkpoint: str = (
        "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/"
        "2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500"
    )
    shuffle_bank_seed: int = 20260704
    gate1_games: int = 2_000
    gate1_batch_games: int = 8192
    delta_games: int = 200
    delta_target_events: int = 500
    delta_pairs: int = 64
    delta_batch_events: int = 16
    gate2_eval_games: int = 2_000
    gate2_pass_threshold: float = 0.55
    gpu_budget_hours: float = 8.0
    exploiters: list[ExploiterSpec] = field(default_factory=list)
    repair: RepairConfig = field(default_factory=RepairConfig)


def load_gates_config(path: str | Path) -> GatesConfig:
    defaults = GatesConfig()
    data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
    gate1 = data.get("gate1", {})
    gate2 = data.get("gate2", {})
    repair = data.get("repair", {})
    target = data.get("target", {})
    run = data.get("run", {})
    exploiters = [ExploiterSpec(**item) for item in gate2.get("exploiters", [])]
    repair_cfg = RepairConfig(**{**asdict(defaults.repair), **repair})
    return GatesConfig(
        artifact_root=run.get(
            "artifact_root", "/mnt/2tbhdd/coolrl-lost-cities-artifacts/gates-1-2"
        ),
        report_path=run.get("report_path", "docs/reports/gates-1-2-2026-07-05.md"),
        summary_path=run.get("summary_path", "docs/reports/gates-1-2-2026-07-05-summary.jsonl"),
        target_config=target.get("config", defaults.target_config),
        target_checkpoint=target.get("checkpoint", defaults.target_checkpoint),
        shuffle_bank_seed=run.get("shuffle_bank_seed", 20260704),
        gate1_games=gate1.get("games", 2_000),
        gate1_batch_games=gate1.get("batch_games", 8192),
        delta_games=gate1.get("delta_games", 200),
        delta_target_events=gate1.get("delta_target_events", 500),
        delta_pairs=gate1.get("delta_pairs", 64),
        delta_batch_events=gate1.get("delta_batch_events", 16),
        gate2_eval_games=gate2.get("eval_games", 2_000),
        gate2_pass_threshold=gate2.get("pass_threshold", 0.55),
        gpu_budget_hours=run.get("gpu_budget_hours", 8.0),
        exploiters=exploiters,
        repair=repair_cfg,
    )


def run_gates(config_path: str | Path) -> Path:
    cfg = load_gates_config(config_path)
    run_dir = _create_run_dir(cfg)
    _write_json(run_dir / "config.json", _config_json(cfg))
    summary_path = Path(cfg.summary_path)
    summary_path.parent.mkdir(parents=True, exist_ok=True)
    summary_path.write_text("", encoding="utf-8")
    start = time.perf_counter()

    rows: list[dict[str, Any]] = []
    for row in run_policy_tournament(cfg, run_dir):
        rows.append(row)
        _append_jsonl(summary_path, row)
        _append_jsonl(run_dir / "summary.jsonl", row)

    for row in run_delta_open_audit(cfg, run_dir):
        rows.append(row)
        _append_jsonl(summary_path, row)
        _append_jsonl(run_dir / "summary.jsonl", row)

    for row in run_exploiter_battery(cfg, run_dir, start):
        rows.append(row)
        _append_jsonl(summary_path, row)
        _append_jsonl(run_dir / "summary.jsonl", row)

    for row in run_conditional_repair(cfg, run_dir, rows, start):
        rows.append(row)
        _append_jsonl(summary_path, row)
        _append_jsonl(run_dir / "summary.jsonl", row)

    final_row = {
        "event": "gates_complete",
        "run_dir": str(run_dir),
        "elapsed_seconds": time.perf_counter() - start,
    }
    rows.append(final_row)
    _append_jsonl(summary_path, final_row)
    _append_jsonl(run_dir / "summary.jsonl", final_row)
    write_report(cfg, run_dir, rows)
    return run_dir


def repair_gates(config_path: str | Path, run_dir: str | Path) -> Path:
    cfg = load_gates_config(config_path)
    run_path = Path(run_dir)
    rows = _read_jsonl(run_path / "summary.jsonl")
    summary_path = Path(cfg.summary_path)
    prior_complete = _latest_row(rows, "gates_complete")
    prior_elapsed = float(prior_complete.get("elapsed_seconds", 0.0)) if prior_complete else 0.0
    start = time.perf_counter() - prior_elapsed
    for row in run_conditional_repair(cfg, run_path, rows, start):
        rows.append(row)
        _append_jsonl(summary_path, row)
        _append_jsonl(run_path / "summary.jsonl", row)
    final_row = {
        "event": "gates_repair_complete",
        "run_dir": str(run_path),
        "elapsed_seconds_total": time.perf_counter() - start,
    }
    rows.append(final_row)
    _append_jsonl(summary_path, final_row)
    _append_jsonl(run_path / "summary.jsonl", final_row)
    write_report(cfg, run_path, rows)
    return run_path


def run_policy_tournament(cfg: GatesConfig, run_dir: Path) -> list[dict[str, Any]]:
    target_cfg = load_config(cfg.target_config)
    target_policy = checkpoint_policy(target_cfg, cfg.target_checkpoint)
    variants = ["expert_cap2", "expert_cap3", "expert_capN"]
    rows: list[dict[str, Any]] = []
    for left, right in [
        ("expert_cap2", "expert_cap3"),
        ("expert_cap2", "expert_capN"),
        ("expert_cap3", "expert_capN"),
    ]:
        output = run_dir / "gate1" / f"{left}_vs_{right}.json"
        result = evaluate_policy_match(
            policy_by_name(left),
            policy_by_name(right),
            games=cfg.gate1_games,
            duplicate=True,
            shuffle_bank_seed=cfg.shuffle_bank_seed,
            batch_games=cfg.gate1_batch_games,
            output=output,
        )
        rows.append(
            {
                "event": "gate1_tournament",
                "learner": left,
                "opponent": right,
                **_summary(result),
                "json": str(output),
            }
        )

    for variant in variants:
        output = run_dir / "gate1" / f"{variant}_vs_league_v1_update_500.json"
        result = evaluate_policy_match(
            policy_by_name(variant),
            target_policy,
            games=cfg.gate1_games,
            duplicate=True,
            shuffle_bank_seed=cfg.shuffle_bank_seed,
            batch_games=cfg.gate1_batch_games,
            output=output,
        )
        rows.append(
            {
                "event": "gate1_vs_league",
                "learner": variant,
                "opponent": "league_v1_update_500",
                **_summary(result),
                "json": str(output),
            }
        )
    return rows


def run_delta_open_audit(cfg: GatesConfig, run_dir: Path) -> list[dict[str, Any]]:
    target_cfg = load_config(cfg.target_config)
    state = create_train_state(target_cfg, jax.random.PRNGKey(0))
    state = restore_checkpoint(Path(cfg.target_checkpoint), state)
    opponent = policy_by_name("heuristic_expert")
    events = _collect_open_events(cfg, state.params, target_cfg, opponent)
    event_count = int(events["actual"].shape[0])
    if event_count == 0:
        row = {
            "event": "gate1_delta_open",
            "states": 0,
            "pairs": cfg.delta_pairs,
            "mean_delta": 0.0,
            "ci95_low": 0.0,
            "ci95_high": 0.0,
            "judgment": "no_events",
        }
        _write_json(run_dir / "gate1" / "delta_open_summary.json", row)
        return [row]

    deltas = _delta_rollouts(cfg, state.params, target_cfg, opponent, events)
    delta_path = run_dir / "gate1" / "delta_open_deltas.npy"
    delta_path.parent.mkdir(parents=True, exist_ok=True)
    np.save(delta_path, deltas)
    hist_path = Path(cfg.report_path).parent / "gates-1-2-delta-open-hist.png"
    _write_delta_histogram(deltas, hist_path)
    mean = float(np.mean(deltas))
    std = float(np.std(deltas, ddof=1)) if deltas.size > 1 else 0.0
    se = std / math.sqrt(max(deltas.size, 1))
    row = {
        "event": "gate1_delta_open",
        "states": event_count,
        "pairs": cfg.delta_pairs,
        "samples": int(deltas.size),
        "mean_delta": mean,
        "std_delta": std,
        "ci95_low": mean - 1.959963984540054 * se,
        "ci95_high": mean + 1.959963984540054 * se,
        "quantiles": _quantiles(deltas),
        "histogram": _histogram(deltas),
        "deltas_npy": str(delta_path),
        "histogram_png": str(hist_path),
        "judgment": _delta_judgment(mean, se),
    }
    _write_json(run_dir / "gate1" / "delta_open_summary.json", row)
    return [row]


def run_exploiter_battery(
    cfg: GatesConfig, run_dir: Path, start_time: float
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for spec in cfg.exploiters:
        if (time.perf_counter() - start_time) / 3600.0 >= cfg.gpu_budget_hours:
            rows.append({"event": "gate2_budget_stop", "next_exploiter": spec.name})
            break
        rows.append(
            _train_and_eval_exploiter(
                cfg,
                spec,
                target_config=cfg.target_config,
                target_checkpoint=cfg.target_checkpoint,
                artifact_root=run_dir / "exploiters",
                event="gate2_exploiter",
                target_name="league_v1_update_500",
            )
        )
    if rows:
        exploiter_rows = [row for row in rows if row.get("event") == "gate2_exploiter"]
        if exploiter_rows:
            worst = max(exploiter_rows, key=lambda row: row["win_rate"])
            rows.append(
                {
                    "event": "gate2_judgment",
                    "worst_exploiter": worst["exploiter"],
                    "worst_win_rate": worst["win_rate"],
                    "threshold": cfg.gate2_pass_threshold,
                    "passed": worst["win_rate"] <= cfg.gate2_pass_threshold,
                }
            )
    return rows


def run_conditional_repair(
    cfg: GatesConfig,
    run_dir: Path,
    existing_rows: list[dict[str, Any]],
    start_time: float,
) -> list[dict[str, Any]]:
    gate2 = _latest_row(existing_rows, "gate2_judgment")
    if gate2 is None:
        return [{"event": "gate2c_skipped", "reason": "gate2_incomplete"}]
    if gate2.get("passed"):
        return [{"event": "gate2c_skipped", "reason": "gate2_passed"}]

    rows: list[dict[str, Any]] = []
    target_config = cfg.target_config
    target_checkpoint = cfg.target_checkpoint
    worst_name = gate2["worst_exploiter"]
    extra_pool_members: list[dict[str, Any]] = []
    for cycle in range(1, cfg.repair.max_cycles + 1):
        if (time.perf_counter() - start_time) / 3600.0 >= cfg.gpu_budget_hours:
            rows.append({"event": "gate2c_budget_stop", "cycle": cycle})
            break

        worst_spec = _exploiter_spec_by_name(cfg, worst_name)
        if worst_spec is None:
            rows.append(
                {
                    "event": "gate2c_error",
                    "cycle": cycle,
                    "reason": f"unknown worst exploiter: {worst_name}",
                }
            )
            break

        previous_target_config = target_config
        previous_target_checkpoint = target_checkpoint
        league_config_path = _write_repair_league_config(
            cfg,
            run_dir,
            cycle,
            target_config,
            target_checkpoint,
            worst_spec,
            extra_pool_members,
        )
        from lost_cities_jax.league import run_league

        league_run_dir = run_league(league_config_path)
        league_rows = _read_jsonl(league_run_dir / "league_summary.jsonl")
        completion = _latest_row(league_rows, "league_complete")
        if completion is None:
            rows.append(
                {
                    "event": "gate2c_error",
                    "cycle": cycle,
                    "reason": "league_cycle_missing_completion",
                    "league_run_dir": str(league_run_dir),
                }
            )
            break
        target_config = str(league_run_dir / "main_ppo_config.json")
        target_checkpoint = completion["final_checkpoint"]
        rows.append(
            {
                "event": "gate2c_league_cycle",
                "cycle": cycle,
                "worst_protocol": worst_spec.name,
                "league_config": str(league_config_path),
                "league_run_dir": str(league_run_dir),
                "target_config": target_config,
                "target_checkpoint": target_checkpoint,
            }
        )

        guard = _evaluate_repair_guard(cfg, run_dir, cycle, target_config, target_checkpoint)
        rows.append(guard)
        if not guard["passed"]:
            rows.append(
                {
                    "event": "gate2c_guard_rollback",
                    "cycle": cycle,
                    "rolled_back_to_config": previous_target_config,
                    "rolled_back_to_checkpoint": previous_target_checkpoint,
                    "failed_checkpoint": target_checkpoint,
                }
            )
            target_config = previous_target_config
            target_checkpoint = previous_target_checkpoint
            continue

        exploiter_member = _repair_exploiter_member(league_rows, cycle)
        if exploiter_member is not None:
            extra_pool_members.append(exploiter_member)

        battery_rows = run_repair_battery(
            cfg,
            run_dir,
            cycle,
            target_config,
            target_checkpoint,
            start_time,
        )
        rows.extend(battery_rows)
        judgment = _repair_judgment(cfg, battery_rows, cycle)
        rows.append(judgment)
        if judgment.get("passed"):
            break
        worst_name = judgment.get("worst_exploiter", worst_name)
    return rows


def run_repair_battery(
    cfg: GatesConfig,
    run_dir: Path,
    cycle: int,
    target_config: str,
    target_checkpoint: str,
    start_time: float,
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for spec in cfg.exploiters:
        if (time.perf_counter() - start_time) / 3600.0 >= cfg.gpu_budget_hours:
            rows.append(
                {"event": "gate2c_budget_stop", "cycle": cycle, "next_exploiter": spec.name}
            )
            break
        rows.append(
            _train_and_eval_exploiter(
                cfg,
                spec,
                target_config=target_config,
                target_checkpoint=target_checkpoint,
                artifact_root=run_dir
                / cfg.repair.artifact_subdir
                / f"cycle_{cycle:02d}"
                / "battery",
                event="gate2c_exploiter",
                target_name=f"repair_cycle_{cycle:02d}",
                repair_cycle=cycle,
            )
        )
    return rows


def _train_and_eval_exploiter(
    cfg: GatesConfig,
    spec: ExploiterSpec,
    *,
    target_config: str,
    target_checkpoint: str,
    artifact_root: Path,
    event: str,
    target_name: str,
    repair_cycle: int | None = None,
) -> dict[str, Any]:
    target_cfg = load_config(target_config)
    exploiter_cfg = load_config(spec.config)
    exploiter_cfg.run.artifact_root = str(artifact_root)
    train_dir = train_against_checkpoint(
        exploiter_cfg,
        target_cfg,
        target_checkpoint,
        resume=spec.resume,
    )
    output = train_dir / f"eval_vs_{target_name}_duplicate.json"
    result = evaluate_checkpoint_match(
        exploiter_cfg,
        train_dir / "latest",
        target_cfg,
        target_checkpoint,
        games=cfg.gate2_eval_games,
        duplicate=True,
        output=output,
    )
    row = {
        "event": event,
        "exploiter": spec.name,
        "target_name": target_name,
        "target_config": target_config,
        "target_checkpoint": target_checkpoint,
        "train_dir": str(train_dir),
        "checkpoint": str(train_dir / "latest"),
        "resume": spec.resume,
        "notes": spec.notes,
        "json": str(output),
        **_summary(result),
    }
    if repair_cycle is not None:
        row["cycle"] = repair_cycle
    return row


def _write_repair_league_config(
    cfg: GatesConfig,
    run_dir: Path,
    cycle: int,
    target_config: str,
    target_checkpoint: str,
    worst_spec: ExploiterSpec,
    extra_pool_members: list[dict[str, Any]],
) -> Path:
    template_path = Path(cfg.repair.league_template)
    data = yaml.safe_load(template_path.read_text(encoding="utf-8")) or {}
    data["base_config"] = target_config
    data["warm_start_checkpoint"] = target_checkpoint

    run = data.setdefault("run", {})
    run["experiment_name"] = f"{cfg.repair.experiment_name}-c{cycle:02d}"
    run["artifact_root"] = str(run_dir / cfg.repair.artifact_subdir / "league")
    run["seed"] = int(run.get("seed", 20260705)) + cycle

    league = data.setdefault("league", {})
    league["cycles"] = 1
    league["success_exploiter_win_rate"] = cfg.gate2_pass_threshold

    evaluation = data.setdefault("evaluation", {})
    if cfg.repair.evaluation_games is not None:
        evaluation["games"] = cfg.repair.evaluation_games
    if cfg.repair.evaluation_batch_games is not None:
        evaluation["batch_games"] = cfg.repair.evaluation_batch_games

    exploiter = data.setdefault("exploiter", {})
    exploiter["config"] = worst_spec.config
    exploiter_cfg = load_config(worst_spec.config)
    exploiter["updates"] = exploiter_cfg.run.total_updates
    if worst_spec.resume:
        exploiter["resume"] = worst_spec.resume
    else:
        exploiter.pop("resume", None)

    guards = data.setdefault("guards", {})
    guards["expert_ci_low"] = cfg.repair.guard_expert_ci_low
    guards["max_steps_rate"] = cfg.repair.guard_max_steps_rate

    cycle_dir = run_dir / cfg.repair.artifact_subdir / f"cycle_{cycle:02d}"
    tracking = data.setdefault("tracking", {})
    tracking["tracked_summary_path"] = str(cycle_dir / "league_summary.jsonl")
    tracking["report_path"] = str(cycle_dir / "league_report.md")

    anchors = list(data.get("anchors", []))
    existing_names = {item.get("name") for item in anchors}
    for member in extra_pool_members:
        if member["name"] not in existing_names:
            anchors.append(member)
            existing_names.add(member["name"])
    data["anchors"] = anchors

    config_path = cycle_dir / "league_config.yaml"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
    return config_path


def _evaluate_repair_guard(
    cfg: GatesConfig,
    run_dir: Path,
    cycle: int,
    target_config: str,
    target_checkpoint: str,
) -> dict[str, Any]:
    target_cfg = load_config(target_config)
    output = (
        run_dir
        / cfg.repair.artifact_subdir
        / f"cycle_{cycle:02d}"
        / "guard_vs_heuristic_expert.json"
    )
    result = evaluate_checkpoint_vs_static(
        target_cfg,
        target_checkpoint,
        "heuristic_expert",
        games=cfg.gate2_eval_games,
        duplicate=True,
        output=output,
    )
    passed = (
        result["score_diff_ci95_low"] > cfg.repair.guard_expert_ci_low
        and result["max_steps_rate"] <= cfg.repair.guard_max_steps_rate
    )
    return {
        "event": "gate2c_guard_expert",
        "cycle": cycle,
        "target_config": target_config,
        "target_checkpoint": target_checkpoint,
        "json": str(output),
        "passed": passed,
        **_summary(result),
    }


def _repair_exploiter_member(
    league_rows: list[dict[str, Any]], cycle: int
) -> dict[str, Any] | None:
    exploiter = _latest_row(league_rows, "exploiter_eval")
    if exploiter is None:
        return None
    checkpoint = Path(exploiter["exploiter_checkpoint"])
    return {
        "name": f"repair_exploiter_c{cycle:02d}",
        "kind": "checkpoint",
        "anchor": False,
        "stalling": False,
        "exploiter": True,
        "config": str(checkpoint.parent / "config.json"),
        "checkpoint": str(checkpoint),
        "recent_win_rate": 1.0 - float(exploiter["win_rate"]),
        "created_cycle": cycle,
        "created_update": 0,
    }


def _repair_judgment(
    cfg: GatesConfig, battery_rows: list[dict[str, Any]], cycle: int
) -> dict[str, Any]:
    exploiters = [row for row in battery_rows if row.get("event") == "gate2c_exploiter"]
    if not exploiters:
        return {"event": "gate2c_judgment", "cycle": cycle, "passed": False, "reason": "no_battery"}
    worst = max(exploiters, key=lambda row: row["win_rate"])
    return {
        "event": "gate2c_judgment",
        "cycle": cycle,
        "worst_exploiter": worst["exploiter"],
        "worst_win_rate": worst["win_rate"],
        "threshold": cfg.gate2_pass_threshold,
        "passed": worst["win_rate"] <= cfg.gate2_pass_threshold,
    }


def _exploiter_spec_by_name(cfg: GatesConfig, name: str) -> ExploiterSpec | None:
    return next((spec for spec in cfg.exploiters if spec.name == name), None)


def write_report(cfg: GatesConfig, run_dir: Path, rows: list[dict[str, Any]]) -> None:
    report_path = Path(cfg.report_path)
    report_path.parent.mkdir(parents=True, exist_ok=True)
    tournament = [row for row in rows if row.get("event") == "gate1_tournament"]
    vs_league = [row for row in rows if row.get("event") == "gate1_vs_league"]
    delta = next((row for row in rows if row.get("event") == "gate1_delta_open"), None)
    exploiters = [row for row in rows if row.get("event") == "gate2_exploiter"]
    gate2 = next((row for row in rows if row.get("event") == "gate2_judgment"), None)
    repair_cycles = [row for row in rows if row.get("event") == "gate2c_league_cycle"]
    repair_guards = [row for row in rows if row.get("event") == "gate2c_guard_expert"]
    repair_exploiters = [row for row in rows if row.get("event") == "gate2c_exploiter"]
    repair_judgments = [row for row in rows if row.get("event") == "gate2c_judgment"]
    repair_skips = [row for row in rows if row.get("event") == "gate2c_skipped"]
    final_robustness = repair_judgments[-1] if repair_judgments else gate2
    lines = [
        "# Gates 1-2 Report - 2026-07-05",
        "",
        f"**Run dir:** `{run_dir}`.",
        f"**Target:** `{cfg.target_checkpoint}`.",
        "",
        "## Gate 1A - Policy Class Tournament",
        "",
        "| Learner | Opponent | Win rate | Mean diff | CI low | Opened colors | Max-step |",
        "| --- | --- | ---: | ---: | ---: | ---: | ---: |",
    ]
    for row in tournament:
        lines.append(_match_row(row))
    lines.extend(
        [
            "",
            "## Gate 1A - Variants vs League v1",
            "",
            "| Learner | Opponent | Win rate | Mean diff | CI low | Opened colors | Max-step |",
            "| --- | --- | ---: | ---: | ---: | ---: | ---: |",
        ]
    )
    for row in vs_league:
        lines.append(_match_row(row))
    lines.extend(["", "## Gate 1B - Delta Open Audit", ""])
    if delta:
        lines.extend(
            [
                f"- States: {delta['states']}",
                f"- Paired samples: {delta.get('samples', 0)}",
                f"- Mean delta: {delta['mean_delta']:+.4f}",
                f"- CI95: [{delta['ci95_low']:+.4f}, {delta['ci95_high']:+.4f}]",
                f"- Judgment: `{delta['judgment']}`",
                f"- Histogram: `{delta.get('histogram_png', '')}`",
            ]
        )
    lines.extend(
        [
            "",
            "## Gate 1C - Selectivity Judgment",
            "",
            _gate1_judgment(tournament, delta),
            "",
            "## Gate 2A - Strengthened Exploiter Battery",
            "",
            "| Exploiter | Win rate | Mean diff | CI low | Opened colors | Max-step | Run |",
            "| --- | ---: | ---: | ---: | ---: | ---: | --- |",
        ]
    )
    for row in exploiters:
        lines.append(
            "| `{exploiter}` | {win_rate:.4f} | {mean_score_diff:+.4f} | "
            "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | "
            "{max_steps_rate:.4f} | `{train_dir}` |".format(**row)
        )
    lines.extend(["", "## Gate 2B - Robustness Judgment", ""])
    if gate2:
        status = "통과" if gate2["passed"] else "보수 필요"
        lines.append(
            f"{status}: worst exploiter `{gate2['worst_exploiter']}` win rate "
            f"{gate2['worst_win_rate']:.4f} vs threshold {gate2['threshold']:.4f}."
        )
    else:
        lines.append("미결: exploiter battery did not complete.")
    lines.extend(["", "## Gate 2C - Conditional Repair League", ""])
    if repair_skips:
        lines.append(f"Skipped: `{repair_skips[-1]['reason']}`.")
    elif repair_cycles:
        lines.extend(
            [
                "| Cycle | Worst protocol used | Guard pass | Battery worst | Passed | Checkpoint |",
                "| ---: | --- | ---: | ---: | ---: | --- |",
            ]
        )
        guards_by_cycle = {row["cycle"]: row for row in repair_guards}
        judgments_by_cycle = {row["cycle"]: row for row in repair_judgments}
        for row in repair_cycles:
            cycle = row["cycle"]
            guard = guards_by_cycle.get(cycle, {})
            judgment = judgments_by_cycle.get(cycle, {})
            worst = judgment.get("worst_win_rate")
            worst_text = "n/a" if worst is None else f"{worst:.4f}"
            lines.append(
                f"| {cycle} | `{row['worst_protocol']}` | "
                f"{str(guard.get('passed', False))} | {worst_text} | "
                f"{str(judgment.get('passed', False))} | `{row['target_checkpoint']}` |"
            )
        if repair_exploiters:
            lines.extend(
                [
                    "",
                    "| Cycle | Exploiter | Win rate | Mean diff | CI low | Opened colors | Max-step |",
                    "| ---: | --- | ---: | ---: | ---: | ---: | ---: |",
                ]
            )
            for row in repair_exploiters:
                lines.append(
                    "| {cycle} | `{exploiter}` | {win_rate:.4f} | {mean_score_diff:+.4f} | "
                    "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | "
                    "{max_steps_rate:.4f} |".format(**row)
                )
    else:
        lines.append("Not run yet.")
    lines.extend(
        [
            "",
            "## Human Play Recommendation",
            "",
            _human_play_recommendation(tournament, delta, final_robustness),
            "",
            "## Decisions",
            "",
            "- Existing `heuristic_expert` remains unchanged. The cap variants add only a "
            "hard `max_open_colors` gate around new-color openings.",
            "- `expert_capN` means no hard cap; EV thresholds and the existing soft "
            "concentration penalties are retained.",
            "- Warm-started exploiters use shaping coefficient 0 to measure target-specific "
            "exploitation without reintroducing early shaping rewards.",
        ]
    )
    report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _collect_open_events(cfg: GatesConfig, params: Any, target_cfg: JaxPPOConfig, opponent) -> dict:
    collect_batch = _make_collect_open_events_fn(target_cfg, opponent)
    collected_states: list[State] = []
    collected_actual: list[np.ndarray] = []
    collected_alt: list[np.ndarray] = []
    collected_seats: list[np.ndarray] = []
    for seat in [0, 1]:
        for start in range(0, cfg.delta_games, cfg.gate1_batch_games):
            count = min(cfg.gate1_batch_games, cfg.delta_games - start)
            orders = make_shuffle_bank(cfg.shuffle_bank_seed + start, count)
            states, actual, alt, event_mask = collect_batch(
                params, jnp.asarray(orders, dtype=jnp.int8), jnp.asarray(seat, dtype=jnp.int32)
            )
            event_mask_np = np.asarray(jax.device_get(event_mask)).reshape(-1)
            event_idx = np.flatnonzero(event_mask_np)
            remaining = cfg.delta_target_events - sum(x.shape[0] for x in collected_actual)
            if remaining <= 0:
                break
            event_idx = event_idx[:remaining]
            if event_idx.size == 0:
                continue
            collected_states.append(_take_scan_states(states, event_idx))
            collected_actual.append(np.asarray(jax.device_get(actual)).reshape(-1)[event_idx])
            collected_alt.append(np.asarray(jax.device_get(alt)).reshape(-1)[event_idx])
            collected_seats.append(np.full((event_idx.size,), seat, dtype=np.int32))
        if sum(x.shape[0] for x in collected_actual) >= cfg.delta_target_events:
            break
    if not collected_actual:
        empty_state = jax.tree_util.tree_map(
            lambda x: np.asarray(x)[:0], reset_from_order(jnp.arange(N_CARDS, dtype=jnp.int8))
        )
        return {
            "states": empty_state,
            "actual": np.asarray([], dtype=np.int32),
            "alt": np.asarray([], dtype=np.int32),
            "seats": np.asarray([], dtype=np.int32),
        }
    return {
        "states": _concat_states(collected_states),
        "actual": np.concatenate(collected_actual).astype(np.int32),
        "alt": np.concatenate(collected_alt).astype(np.int32),
        "seats": np.concatenate(collected_seats).astype(np.int32),
    }


def _make_collect_open_events_fn(target_cfg: JaxPPOConfig, opponent_policy):
    model = ActorCritic(target_cfg.network.hidden_size, target_cfg.network.num_layers)

    @jax.jit
    def collect(params, deck_orders: jax.Array, learner_seat: jax.Array):
        learner = learner_seat.astype(jnp.int32)
        opponent = 1 - learner
        env_state = jax.vmap(reset_from_order)(deck_orders)
        keys = jax.random.split(jax.random.PRNGKey(0), deck_orders.shape[0])

        def body(carry, _):
            env, key = carry
            key, opponent_key = jax.random.split(key)
            legal = jax.vmap(legal_action_mask)(env)
            obs = jax.vmap(observation, in_axes=(0, None))(env, learner)
            logits, _ = model.apply(params, obs)
            open_mask = _new_open_action_mask(env, learner)
            learner_actions = jnp.argmax(mask_logits(logits, legal), axis=-1).astype(jnp.int32)
            alt_mask = legal & ~open_mask
            alt_actions = jnp.argmax(jnp.where(alt_mask, logits, NEG_INF), axis=-1).astype(
                jnp.int32
            )
            opponent_keys = jax.random.split(opponent_key, deck_orders.shape[0])
            opponent_actions = jax.vmap(opponent_policy, in_axes=(0, None, 0))(
                env, opponent, opponent_keys
            )
            learner_turn = env.to_move.astype(jnp.int32) == learner
            active = ~env.done
            actions = jnp.where(learner_turn, learner_actions, opponent_actions)
            active_colors = jnp.sum(env.col_len[:, learner, :] > 0, axis=-1)
            event = (
                active
                & learner_turn
                & (active_colors >= 3)
                & jnp.take_along_axis(open_mask, learner_actions[:, None], axis=-1)[:, 0]
            )
            next_env, _, _ = jax.vmap(step, in_axes=(0, 0))(env, actions)
            return (next_env, key), (env, learner_actions, alt_actions, event)

        (_, _), outputs = jax.lax.scan(body, (env_state, keys[0]), xs=None, length=MAX_STEPS)
        return outputs

    return collect


def _delta_rollouts(
    cfg: GatesConfig,
    params: Any,
    target_cfg: JaxPPOConfig,
    opponent,
    events: dict,
) -> np.ndarray:
    delta_batch = _make_delta_rollout_fn(target_cfg, opponent)
    n_events = int(events["actual"].shape[0])
    deltas: list[np.ndarray] = []
    for start in range(0, n_events, cfg.delta_batch_events):
        end = min(start + cfg.delta_batch_events, n_events)
        states = _state_slice(events["states"], start, end)
        repeated_states = jax.tree_util.tree_map(
            lambda x: jnp.repeat(jnp.asarray(x), cfg.delta_pairs, axis=0), states
        )
        actual = np.repeat(events["actual"][start:end], cfg.delta_pairs)
        alt = np.repeat(events["alt"][start:end], cfg.delta_pairs)
        seats = np.repeat(events["seats"][start:end], cfg.delta_pairs)
        batch = delta_batch(
            params,
            repeated_states,
            jnp.asarray(actual, dtype=jnp.int32),
            jnp.asarray(alt, dtype=jnp.int32),
            jnp.asarray(seats, dtype=jnp.int32),
            jax.random.PRNGKey(cfg.shuffle_bank_seed + 100_000 + start),
        )
        deltas.append(np.asarray(jax.device_get(batch), dtype=np.float64))
    return np.concatenate(deltas)


def _make_delta_rollout_fn(target_cfg: JaxPPOConfig, opponent_policy):
    model = ActorCritic(target_cfg.network.hidden_size, target_cfg.network.num_layers)

    @jax.jit
    def delta_batch(
        params: Any,
        states: State,
        actual_actions: jax.Array,
        alt_actions: jax.Array,
        learners: jax.Array,
        rng: jax.Array,
    ) -> jax.Array:
        actual_env, _, _ = jax.vmap(step, in_axes=(0, 0))(states, actual_actions)
        alt_env, _, _ = jax.vmap(step, in_axes=(0, 0))(states, alt_actions)

        def branch_action(env: State, key: jax.Array) -> tuple[jax.Array, jax.Array]:
            key, learner_key, opponent_key = jax.random.split(key, 3)
            legal = jax.vmap(legal_action_mask)(env)
            obs = jax.vmap(observation, in_axes=(0, 0))(env, learners)
            logits, _ = model.apply(params, obs)
            learner_actions = jax.random.categorical(
                learner_key, mask_logits(logits, legal), axis=-1
            ).astype(jnp.int32)
            opponent_keys = jax.random.split(opponent_key, learners.shape[0])
            opponent_actions = jax.vmap(opponent_policy, in_axes=(0, 0, 0))(
                env, 1 - learners, opponent_keys
            )
            learner_turn = env.to_move.astype(jnp.int32) == learners
            return jnp.where(learner_turn, learner_actions, opponent_actions), key

        def body(carry, _):
            env_a, env_b, key = carry
            key, step_key = jax.random.split(key)
            actions_a, _ = branch_action(env_a, step_key)
            actions_b, _ = branch_action(env_b, step_key)
            next_a, _, _ = jax.vmap(step, in_axes=(0, 0))(env_a, actions_a)
            next_b, _, _ = jax.vmap(step, in_axes=(0, 0))(env_b, actions_b)
            return (next_a, next_b, key), None

        (final_a, final_b, _), _ = jax.lax.scan(
            body, (actual_env, alt_env, rng), xs=None, length=MAX_STEPS
        )
        return batch_score_diff_for_players(final_a, learners) - batch_score_diff_for_players(
            final_b, learners
        )

    return delta_batch


def _new_open_action_mask(states: State, learner: jax.Array) -> jax.Array:
    hand = jax.vmap(current_hand_sorted, in_axes=(0, None))(states, learner).astype(jnp.int32)
    action_cards = hand[:, ACTION_HAND_SLOT]
    valid = action_cards < N_CARDS
    safe = jnp.clip(action_cards, 0, N_CARDS - 1)
    colors = safe // CARDS_PER_COLOR
    col_len = states.col_len[:, learner, :]
    batch = jnp.arange(colors.shape[0])[:, None]
    open_color = col_len[batch, colors] == 0
    return (ACTION_PLACE_TYPE[None, :] == PLAY) & valid & open_color


def _take_scan_states(states: State, event_idx: np.ndarray) -> State:
    return jax.tree_util.tree_map(
        lambda x: np.asarray(jax.device_get(x)).reshape((-1, *x.shape[2:]))[event_idx], states
    )


def _concat_states(states: list[State]) -> State:
    return jax.tree_util.tree_map(lambda *xs: np.concatenate(xs, axis=0), *states)


def _state_slice(state: State, start: int, end: int) -> State:
    return jax.tree_util.tree_map(lambda x: x[start:end], state)


def _summary(result: dict[str, Any]) -> dict[str, Any]:
    keys = [
        "games",
        "wins",
        "losses",
        "ties",
        "win_rate",
        "wilson_low",
        "wilson_high",
        "mean_score_diff",
        "score_diff_ci95_low",
        "score_diff_ci95_high",
        "score_diff_std",
        "mean_game_length",
        "max_steps_rate",
        "opened_colors_per_game",
        "play_action_rate",
        "positive_expeditions_per_game",
    ]
    return {key: result[key] for key in keys if key in result}


def _match_row(row: dict[str, Any]) -> str:
    return (
        "| `{learner}` | `{opponent}` | {win_rate:.4f} | {mean_score_diff:+.4f} | "
        "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | "
        "{max_steps_rate:.4f} |"
    ).format(**row)


def _gate1_judgment(tournament: list[dict[str, Any]], delta: dict[str, Any] | None) -> str:
    capn_rows = [row for row in tournament if row["opponent"] == "expert_capN"]
    focused_beats_capn = capn_rows and all(row["score_diff_ci95_low"] > 0.0 for row in capn_rows)
    capn_beats_any_focus = any(row["score_diff_ci95_high"] < 0.0 for row in capn_rows)
    capn_not_significantly_worse = capn_rows and all(
        row["score_diff_ci95_low"] <= 0.0 for row in capn_rows
    )
    delta_judgment = delta.get("judgment") if delta else "missing"
    if capn_beats_any_focus and delta_judgment == "positive":
        return "통념 기각: capN이 집중 변형에 밀리지 않고 4번째+ 오픈 delta도 양수다."
    if focused_beats_capn and delta_judgment == "negative":
        return "에이전트 결함 확정: 집중 변형이 capN을 이기고 4번째+ 오픈 delta도 음수다."
    if capn_not_significantly_worse and delta_judgment == "near_zero":
        return (
            "통념 기각/미결: capN은 집중 변형보다 유의하게 나쁘지 않고, "
            "리그 정책의 4번째+ 오픈 delta는 0 근처다. selectivity는 현재 "
            "주요 성능 병목으로 보이지 않는다."
        )
    return "미결: 정책 클래스 토너먼트와 delta_open 판정이 같은 방향으로 충분히 정렬되지 않았다."


def _human_play_recommendation(
    tournament: list[dict[str, Any]], delta: dict[str, Any] | None, gate2: dict[str, Any] | None
) -> str:
    del tournament, delta
    if gate2 and gate2["passed"]:
        return (
            "조건부 예: 강화 exploiter 관문은 통과했다. 다만 selectivity 관문이 "
            "미결/부분 지지이면 인간 대전은 실력 인증이 아니라 행동 양식 진단으로 시작해야 한다."
        )
    return (
        "아니오: 강화 exploiter 관문이 통과되지 않았거나 완료되지 않았다. 인간 대전 전 "
        "최악 exploiter에 대한 보수 리그가 우선이다."
    )


def _delta_judgment(mean: float, se: float) -> str:
    margin = 1.959963984540054 * se
    if mean - margin > 0.0:
        return "positive"
    if mean + margin < 0.0:
        return "negative"
    return "near_zero"


def _quantiles(values: np.ndarray) -> dict[str, float]:
    q = np.quantile(values, [0.0, 0.05, 0.25, 0.5, 0.75, 0.95, 1.0])
    return {
        "min": float(q[0]),
        "p05": float(q[1]),
        "p25": float(q[2]),
        "p50": float(q[3]),
        "p75": float(q[4]),
        "p95": float(q[5]),
        "max": float(q[6]),
    }


def _histogram(values: np.ndarray) -> dict[str, list[float] | list[int]]:
    counts, edges = np.histogram(values, bins=[-200, -100, -50, -20, 0, 20, 50, 100, 200])
    return {"edges": edges.astype(float).tolist(), "counts": counts.astype(int).tolist()}


def _write_delta_histogram(values: np.ndarray, path: Path) -> None:
    try:
        import matplotlib.pyplot as plt
    except Exception:
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    plt.figure()
    plt.hist(values, bins=[-200, -100, -50, -20, 0, 20, 50, 100, 200])
    plt.axvline(float(np.mean(values)), color="red", linestyle="--")
    plt.xlabel("Delta score diff: open branch - non-open branch")
    plt.ylabel("Count")
    plt.tight_layout()
    plt.savefig(path)
    plt.close()


def _config_json(cfg: GatesConfig) -> dict[str, Any]:
    data = asdict(cfg)
    data["exploiters"] = [asdict(spec) for spec in cfg.exploiters]
    return data


def _write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def _append_jsonl(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(data, sort_keys=True) + "\n")


def _read_jsonl(path: Path) -> list[dict[str, Any]]:
    if not path.exists():
        return []
    return [
        json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
    ]


def _latest_row(rows: list[dict[str, Any]], event: str) -> dict[str, Any] | None:
    return next((row for row in reversed(rows) if row.get("event") == event), None)


def _create_run_dir(cfg: GatesConfig) -> Path:
    root = Path(cfg.artifact_root).resolve()
    run_dir = root / f"{time.strftime('%Y-%m-%d_%H%M%S')}_gates-1-2"
    run_dir.mkdir(parents=True, exist_ok=False)
    return run_dir


__all__ = [
    "GatesConfig",
    "load_gates_config",
    "repair_gates",
    "run_gates",
]
