"""Snapshot-pool league self-play runner for the JAX PPO stack."""

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 yaml

from lost_cities_jax.engine import reset
from lost_cities_jax.opponents import policy_by_name
from lost_cities_jax.ppo import (
    JaxPPOConfig,
    TrainState,
    checkpoint_policy,
    create_train_state,
    evaluate_checkpoint_match,
    evaluate_checkpoint_vs_static,
    load_config,
    make_league_train_iteration,
    restore_checkpoint,
    sample_league_assignments,
    save_checkpoint,
)


@dataclass
class PoolMember:
    name: str
    kind: str
    anchor: bool = True
    stalling: bool = False
    exploiter: bool = False
    policy_name: str | None = None
    config: str | None = None
    checkpoint: str | None = None
    recent_win_rate: float = 0.5
    created_cycle: int = 0
    created_update: int = 0


@dataclass
class LeagueConfig:
    base_config: str
    warm_start_checkpoint: str
    experiment_name: str = "jax-ppo-league-v1"
    seed: int = 20260705
    artifact_root: str = "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league"
    cycles: int = 5
    league_updates_per_cycle: int = 500
    snapshot_interval_updates: int = 250
    mirror_probability: float = 0.5
    uniform_mix: float = 0.1
    stalling_anchor_floor: float = 0.02
    stalling_anchor_cap: float = 0.05
    pool_max_size: int = 24
    max_active_pool_members: int = 12
    exploiter_member_fraction_cap: float = 1.0 / 3.0
    wall_clock_hours: float = 6.0
    success_exploiter_win_rate: float = 0.60
    stagnation_window: int = 5
    stagnation_min_delta: float = 0.02
    evaluation_games: int = 10_000
    evaluation_batch_games: int = 8192
    evaluation_duplicate: bool = True
    evaluation_shuffle_bank_seed: int = 20260704
    recent_snapshot_evals: int = 3
    exploiter_config: str = "configs/jax_ppo/ladder-v2-exploiter.yaml"
    exploiter_resume: str | None = None
    exploiter_updates: int = 250
    expert_guard_ci_low: float = 0.0
    max_steps_guard: float = 0.02
    tracked_summary_path: str = "docs/reports/league-v1-2026-07-05-summary.jsonl"
    report_path: str = "docs/reports/league-v1-2026-07-05.md"
    anchors: list[PoolMember] = field(default_factory=list)


def load_league_config(path: str | Path) -> LeagueConfig:
    data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
    run = data.get("run", {})
    league = data.get("league", {})
    evaluation = data.get("evaluation", {})
    exploiter = data.get("exploiter", {})
    guards = data.get("guards", {})
    tracking = data.get("tracking", {})
    anchors = [_member_from_dict(item) for item in data.get("anchors", [])]
    return LeagueConfig(
        base_config=data["base_config"],
        warm_start_checkpoint=data["warm_start_checkpoint"],
        experiment_name=run.get("experiment_name", "jax-ppo-league-v1"),
        seed=run.get("seed", 20260705),
        artifact_root=run.get("artifact_root", "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league"),
        cycles=league.get("cycles", 5),
        league_updates_per_cycle=league.get("league_updates_per_cycle", 500),
        snapshot_interval_updates=league.get("snapshot_interval_updates", 250),
        mirror_probability=league.get("mirror_probability", 0.5),
        uniform_mix=league.get("uniform_mix", 0.1),
        stalling_anchor_floor=league.get("stalling_anchor_floor", 0.02),
        stalling_anchor_cap=league.get("stalling_anchor_cap", 0.05),
        pool_max_size=league.get("pool_max_size", 24),
        max_active_pool_members=league.get("max_active_pool_members", 12),
        exploiter_member_fraction_cap=league.get("exploiter_member_fraction_cap", 1.0 / 3.0),
        wall_clock_hours=league.get("wall_clock_hours", 6.0),
        success_exploiter_win_rate=league.get("success_exploiter_win_rate", 0.60),
        stagnation_window=league.get("stagnation_window", 5),
        stagnation_min_delta=league.get("stagnation_min_delta", 0.02),
        evaluation_games=evaluation.get("games", 10_000),
        evaluation_batch_games=evaluation.get("batch_games", 8192),
        evaluation_duplicate=evaluation.get("duplicate", True),
        evaluation_shuffle_bank_seed=evaluation.get("shuffle_bank_seed", 20260704),
        recent_snapshot_evals=evaluation.get("recent_snapshot_evals", 3),
        exploiter_config=exploiter.get("config", "configs/jax_ppo/ladder-v2-exploiter.yaml"),
        exploiter_resume=exploiter.get("resume"),
        exploiter_updates=exploiter.get("updates", 250),
        expert_guard_ci_low=guards.get("expert_ci_low", 0.0),
        max_steps_guard=guards.get("max_steps_rate", 0.02),
        tracked_summary_path=tracking.get(
            "tracked_summary_path", "docs/reports/league-v1-2026-07-05-summary.jsonl"
        ),
        report_path=tracking.get("report_path", "docs/reports/league-v1-2026-07-05.md"),
        anchors=anchors,
    )


def run_league(config_path: str | Path) -> Path:
    cfg = load_league_config(config_path)
    ppo_cfg = _main_ppo_config(cfg)
    run_dir = _create_league_run_dir(cfg)
    _write_json(run_dir / "league_config.json", _league_config_to_dict(cfg))
    _write_json(run_dir / "main_ppo_config.json", asdict(ppo_cfg))
    summary_path = Path(cfg.tracked_summary_path)
    summary_path.parent.mkdir(parents=True, exist_ok=True)
    summary_path.write_text("", encoding="utf-8")

    rng = jax.random.PRNGKey(cfg.seed)
    rng, init_key, reset_key, assignment_key = jax.random.split(rng, 4)
    state = create_train_state(ppo_cfg, init_key)
    state = restore_checkpoint(Path(cfg.warm_start_checkpoint), state)
    env_state = jax.jit(jax.vmap(reset))(jax.random.split(reset_key, ppo_cfg.ppo.batch_games))
    pool = list(cfg.anchors)
    if not pool:
        pool = _default_pool_members()

    initial_probs = pool_sampling_probabilities(pool, cfg)
    assignments = sample_league_assignments(
        assignment_key,
        ppo_cfg.ppo.batch_games,
        jnp.asarray(initial_probs, dtype=jnp.float32),
        cfg.mirror_probability,
    )

    metrics_path = run_dir / "league_metrics.jsonl"
    pool_path = run_dir / "pool.jsonl"
    start_time = time.perf_counter()
    global_update = 0
    snapshots: list[PoolMember] = []
    best_snapshot: PoolMember | None = None
    best_expert_ci = -math.inf
    exploiter_rates: list[float] = []
    stop_reason = "cycles_complete"

    for cycle in range(1, cfg.cycles + 1):
        segment_count = max(1, cfg.league_updates_per_cycle // cfg.snapshot_interval_updates)
        for segment in range(segment_count):
            updates = _segment_updates(cfg, segment, segment_count)
            active_pool = active_training_pool(pool, cfg)
            probs = pool_sampling_probabilities(active_pool, cfg)
            policies = [_policy_for_member(member) for member in active_pool]
            train_iteration = make_league_train_iteration(
                ppo_cfg, policies, jnp.asarray(probs, dtype=jnp.float32), cfg.mirror_probability
            )
            rng, reset_key, assignment_key = jax.random.split(rng, 3)
            env_state = jax.jit(jax.vmap(reset))(
                jax.random.split(reset_key, ppo_cfg.ppo.batch_games)
            )
            assignments = sample_league_assignments(
                assignment_key,
                ppo_cfg.ppo.batch_games,
                jnp.asarray(probs, dtype=jnp.float32),
                cfg.mirror_probability,
            )
            for _ in range(updates):
                iter_start = time.perf_counter()
                state, env_state, assignments, rng, metrics = train_iteration(
                    state, env_state, assignments, rng, jnp.asarray(0.0, dtype=jnp.float32)
                )
                jax.tree_util.tree_leaves(metrics)[0].block_until_ready()
                row = _metrics_to_row(metrics, global_update, cycle, active_pool, probs)
                row["iteration_seconds"] = time.perf_counter() - iter_start
                row["elapsed_seconds"] = time.perf_counter() - start_time
                _append_jsonl(metrics_path, row)
                if global_update % ppo_cfg.run.log_every == 0:
                    print(
                        json.dumps(
                            {
                                "cycle": cycle,
                                "update": global_update,
                                "return_mean": row["return_mean"],
                                "opened_colors_mean": row["opened_colors_mean"],
                                "play_action_rate": row["play_action_rate"],
                                "active_pool": row["active_pool_size"],
                            },
                            sort_keys=True,
                        ),
                        flush=True,
                    )
                global_update += 1

            snapshot = _save_main_snapshot(
                run_dir, state, ppo_cfg, cycle, global_update, anchor=False
            )
            snapshots.append(snapshot)
            pool.append(snapshot)
            pool = prune_pool(pool, cfg)
            _append_jsonl(
                pool_path, {"cycle": cycle, "update": global_update, "pool": _pool_json(pool)}
            )
            eval_rows = evaluate_snapshot(
                snapshot, ppo_cfg, pool, snapshots, cfg, run_dir, cycle, global_update
            )
            for row in eval_rows:
                _append_jsonl(run_dir / "league_summary.jsonl", row)
                _append_jsonl(summary_path, row)
                if row["event"] == "snapshot_eval" and row["opponent"] == "heuristic_expert":
                    if row["score_diff_ci95_low"] > best_expert_ci:
                        best_snapshot = snapshot
                        best_expert_ci = row["score_diff_ci95_low"]
                    if row["score_diff_ci95_low"] < cfg.expert_guard_ci_low:
                        _append_jsonl(
                            run_dir / "alerts.jsonl",
                            {**row, "alert": "expert_ci_low_below_guard"},
                        )
                if row["event"] == "snapshot_eval" and row["max_steps_rate"] > cfg.max_steps_guard:
                    _append_jsonl(
                        run_dir / "alerts.jsonl",
                        {**row, "alert": "max_steps_rate_above_guard"},
                    )
            _update_pool_win_rates(pool, eval_rows)

        target = best_snapshot or snapshots[-1]
        exploiter_member, exploiter_row = run_exploiter_cycle(cfg, ppo_cfg, target, run_dir, cycle)
        final_exploiter_row = None
        if snapshots and target.name != snapshots[-1].name:
            final_exploiter_row = evaluate_exploiter_member(
                cfg,
                ppo_cfg,
                exploiter_member,
                snapshots[-1],
                cycle,
                event="exploiter_final_eval",
            )
        pool.append(exploiter_member)
        pool = prune_pool(pool, cfg)
        success_row = final_exploiter_row or exploiter_row
        exploiter_rates.append(success_row["win_rate"])
        _append_jsonl(run_dir / "league_summary.jsonl", exploiter_row)
        _append_jsonl(summary_path, exploiter_row)
        if final_exploiter_row is not None:
            _append_jsonl(run_dir / "league_summary.jsonl", final_exploiter_row)
            _append_jsonl(summary_path, final_exploiter_row)
        _append_jsonl(
            pool_path, {"cycle": cycle, "update": global_update, "pool": _pool_json(pool)}
        )

        if _success(cfg, success_row, best_expert_ci):
            stop_reason = "success"
            break
        if _stagnated(cfg, exploiter_rates):
            stop_reason = "stagnation"
            break
        if (time.perf_counter() - start_time) / 3600.0 >= cfg.wall_clock_hours:
            stop_reason = "wall_clock_budget"
            break

    final_snapshot = _save_main_snapshot(
        run_dir, state, ppo_cfg, cycle, global_update, anchor=False
    )
    final_row = {
        "event": "league_complete",
        "stop_reason": stop_reason,
        "cycles_completed": cycle,
        "updates": global_update,
        "final_checkpoint": str(final_snapshot.checkpoint),
        "run_dir": str(run_dir),
    }
    _append_jsonl(run_dir / "league_summary.jsonl", final_row)
    _append_jsonl(summary_path, final_row)
    write_report(cfg, run_dir, summary_path)
    return run_dir


def active_training_pool(pool: list[PoolMember], cfg: LeagueConfig) -> list[PoolMember]:
    if len(pool) <= cfg.max_active_pool_members:
        return list(pool)
    anchors = [member for member in pool if member.anchor]
    nonanchors = [member for member in pool if not member.anchor]
    nonanchors.sort(key=lambda member: (member.recent_win_rate, -member.created_update))
    slots = max(cfg.max_active_pool_members - len(anchors), 0)
    return anchors + nonanchors[:slots]


def pool_sampling_probabilities(pool: list[PoolMember], cfg: LeagueConfig) -> list[float]:
    raw = [(1.0 - _clamp(member.recent_win_rate, 0.001, 0.999)) ** 2 for member in pool]
    raw_sum = sum(raw)
    if raw_sum <= 0.0:
        base = [1.0 / len(pool)] * len(pool)
    else:
        base = [value / raw_sum for value in raw]
    probs = [(1.0 - cfg.uniform_mix) * value + cfg.uniform_mix / len(pool) for value in base]

    fixed: dict[int, float] = {}
    for idx, member in enumerate(pool):
        if member.stalling:
            fixed[idx] = _clamp(probs[idx], cfg.stalling_anchor_floor, cfg.stalling_anchor_cap)
    fixed_sum = sum(fixed.values())
    if fixed_sum >= 1.0:
        total = fixed_sum
        return [fixed.get(idx, 0.0) / total for idx in range(len(pool))]
    free_indices = [idx for idx in range(len(pool)) if idx not in fixed]
    free_sum = sum(probs[idx] for idx in free_indices)
    result = [0.0] * len(pool)
    for idx, value in fixed.items():
        result[idx] = value
    if free_indices and free_sum > 0.0:
        scale = (1.0 - fixed_sum) / free_sum
        for idx in free_indices:
            result[idx] = probs[idx] * scale
    elif free_indices:
        value = (1.0 - fixed_sum) / len(free_indices)
        for idx in free_indices:
            result[idx] = value
    return result


def evaluate_snapshot(
    snapshot: PoolMember,
    ppo_cfg: JaxPPOConfig,
    pool: list[PoolMember],
    snapshots: list[PoolMember],
    cfg: LeagueConfig,
    run_dir: Path,
    cycle: int,
    update: int,
) -> list[dict[str, Any]]:
    eval_members = [member for member in pool if member.anchor]
    recent = [member for member in snapshots if member.name != snapshot.name][
        -cfg.recent_snapshot_evals :
    ]
    for member in recent:
        if all(existing.name != member.name for existing in eval_members):
            eval_members.append(member)

    rows = []
    for member in eval_members:
        output = run_dir / "eval" / snapshot.name / f"vs_{member.name}.json"
        result = _evaluate_against_member(snapshot, ppo_cfg, member, cfg)
        _write_json(output, result)
        elo = _elo_estimate(result["win_rate"])
        row = {
            "event": "snapshot_eval",
            "cycle": cycle,
            "update": update,
            "snapshot": snapshot.name,
            "checkpoint": snapshot.checkpoint,
            "opponent": member.name,
            "opponent_kind": member.kind,
            "elo_estimate": elo,
            **_result_summary(result),
        }
        rows.append(row)
    return rows


def run_exploiter_cycle(
    cfg: LeagueConfig,
    ppo_cfg: JaxPPOConfig,
    target: PoolMember,
    league_run_dir: Path,
    cycle: int,
) -> tuple[PoolMember, dict[str, Any]]:
    exploiter_cfg = load_config(cfg.exploiter_config)
    exploiter_cfg.run.experiment_name = f"{cfg.experiment_name}-cycle-{cycle}-exploiter"
    exploiter_cfg.run.artifact_root = str(league_run_dir / "exploiters")
    exploiter_cfg.run.total_updates = cfg.exploiter_updates
    from lost_cities_jax.ppo import train_against_checkpoint

    exploiter_run = train_against_checkpoint(
        exploiter_cfg,
        ppo_cfg,
        target.checkpoint or cfg.warm_start_checkpoint,
        resume=cfg.exploiter_resume,
    )
    result = evaluate_checkpoint_match(
        exploiter_cfg,
        exploiter_run / "latest",
        ppo_cfg,
        target.checkpoint or cfg.warm_start_checkpoint,
        games=cfg.evaluation_games,
        duplicate=cfg.evaluation_duplicate,
    )
    output = exploiter_run / f"eval_vs_{target.name}_duplicate.json"
    _write_json(output, result)
    member = PoolMember(
        name=f"exploiter_c{cycle}",
        kind="checkpoint",
        anchor=False,
        exploiter=True,
        config=str(exploiter_run / "config.json"),
        checkpoint=str(exploiter_run / "latest"),
        recent_win_rate=1.0 - result["win_rate"],
        created_cycle=cycle,
    )
    row = {
        "event": "exploiter_eval",
        "cycle": cycle,
        "target": target.name,
        "target_checkpoint": target.checkpoint,
        "exploiter": member.name,
        "exploiter_checkpoint": member.checkpoint,
        "eval_json": str(output),
        **_result_summary(result),
    }
    return member, row


def evaluate_exploiter_member(
    cfg: LeagueConfig,
    ppo_cfg: JaxPPOConfig,
    exploiter: PoolMember,
    target: PoolMember,
    cycle: int,
    *,
    event: str,
) -> dict[str, Any]:
    if exploiter.config is None or exploiter.checkpoint is None:
        msg = f"exploiter member is missing config/checkpoint: {exploiter.name}"
        raise ValueError(msg)
    if target.checkpoint is None:
        msg = f"target member is missing checkpoint: {target.name}"
        raise ValueError(msg)
    output = Path(exploiter.checkpoint).parent / f"eval_vs_{target.name}_duplicate.json"
    result = evaluate_checkpoint_match(
        load_config(exploiter.config),
        exploiter.checkpoint,
        ppo_cfg,
        target.checkpoint,
        games=cfg.evaluation_games,
        duplicate=cfg.evaluation_duplicate,
    )
    _write_json(output, result)
    return {
        "event": event,
        "cycle": cycle,
        "target": target.name,
        "target_checkpoint": target.checkpoint,
        "exploiter": exploiter.name,
        "exploiter_checkpoint": exploiter.checkpoint,
        "eval_json": str(output),
        **_result_summary(result),
    }


def prune_pool(pool: list[PoolMember], cfg: LeagueConfig) -> list[PoolMember]:
    anchors = [member for member in pool if member.anchor]
    nonanchors = [member for member in pool if not member.anchor]
    exploiter_cap = max(1, int(cfg.pool_max_size * cfg.exploiter_member_fraction_cap))
    exploiters = [member for member in nonanchors if member.exploiter]
    if len(exploiters) > exploiter_cap:
        exploiters.sort(key=lambda member: member.created_cycle)
        remove = {member.name for member in exploiters[: len(exploiters) - exploiter_cap]}
        nonanchors = [member for member in nonanchors if member.name not in remove]
    if len(anchors) + len(nonanchors) <= cfg.pool_max_size:
        return anchors + nonanchors
    nonanchors.sort(key=lambda member: (member.recent_win_rate, -member.created_update))
    keep = max(cfg.pool_max_size - len(anchors), 0)
    return anchors + nonanchors[:keep]


def write_report(cfg: LeagueConfig, run_dir: Path, summary_path: Path) -> None:
    rows = [
        json.loads(line)
        for line in summary_path.read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]
    report_path = Path(cfg.report_path)
    report_path.parent.mkdir(parents=True, exist_ok=True)
    plot_paths = _write_plots(cfg, report_path.parent, rows)
    exploiter_rows = [
        row for row in rows if row.get("event") in {"exploiter_eval", "exploiter_final_eval"}
    ]
    final_exploiter_rows = [row for row in rows if row.get("event") == "exploiter_final_eval"]
    expert_rows = [
        row
        for row in rows
        if row.get("event") == "snapshot_eval" and row.get("opponent") == "heuristic_expert"
    ]
    snapshot_rows = [row for row in rows if row.get("event") == "snapshot_eval"]
    final_snapshot_rows = _final_snapshot_rows(snapshot_rows)
    completion_rows = [row for row in rows if row.get("event") == "league_complete"]
    final = completion_rows[-1] if completion_rows else (rows[-1] if rows else {})
    lines = [
        "# League v1 Report - 2026-07-05",
        "",
        f"**Status:** `{final.get('stop_reason', 'unknown')}`.",
        f"**Run dir:** `{run_dir}`.",
        f"**Final checkpoint:** `{final.get('final_checkpoint', 'unknown')}`.",
        "",
        "## Summary",
        "",
        f"- Updates: {final.get('updates', 'unknown')}",
        f"- Cycles completed: {final.get('cycles_completed', 'unknown')}",
        f"- Success threshold: exploiter win rate <= {cfg.success_exploiter_win_rate:.2f} "
        f"and expert CI low > {cfg.expert_guard_ci_low:.2f}",
        "",
        "## Exploiter Series",
        "",
        "| Type | Cycle | Win rate | Mean diff | Opened colors | Max-step | Target |",
        "| --- | ---: | ---: | ---: | ---: | ---: | --- |",
    ]
    for row in exploiter_rows:
        kind = "final-checkpoint" if row["event"] == "exploiter_final_eval" else "cycle-target"
        lines.append(
            "| {kind} | {cycle} | {win_rate:.4f} | {mean_score_diff:+.4f} | "
            "{opened_colors_per_game:.4f} | {max_steps_rate:.4f} | `{target}` |".format(
                kind=kind, **row
            )
        )
    lines.extend(
        [
            "",
            "## Expert Evaluation Series",
            "",
            "| Cycle | Update | Win rate | Mean diff | CI low | Opened colors | Max-step |",
            "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
        ]
    )
    for row in expert_rows:
        lines.append(
            "| {cycle} | {update} | {win_rate:.4f} | {mean_score_diff:+.4f} | "
            "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | "
            "{max_steps_rate:.4f} |".format(**row)
        )
    lines.extend(
        [
            "",
            "## Final Anchor Table",
            "",
            "| Opponent | Win rate | Mean diff | CI low | Opened colors | Max-step | Elo est. |",
            "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
        ]
    )
    for row in final_snapshot_rows:
        lines.append(
            "| `{opponent}` | {win_rate:.4f} | {mean_score_diff:+.4f} | "
            "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | "
            "{max_steps_rate:.4f} | {elo_estimate:+.1f} |".format(**row)
        )
    lines.extend(
        [
            "",
            "## Plots",
            "",
            *[f"- `{path}`" for path in plot_paths],
            "",
            "## Hypothesis Read",
            "",
            _hypothesis_read(final_exploiter_rows or exploiter_rows, expert_rows),
            "",
            "## Decisions",
            "",
            "- Training uses a PFSP active subset when the pool exceeds "
            f"`max_active_pool_members={cfg.max_active_pool_members}`. The full pool is retained "
            "for lifecycle and evaluation; the subset keeps single-GPU cycle time within budget.",
            "- Stalling anchors are capped between the configured floor and cap during PFSP sampling.",
        ]
    )
    report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _main_ppo_config(cfg: LeagueConfig) -> JaxPPOConfig:
    ppo_cfg = load_config(cfg.base_config)
    ppo_cfg.run.experiment_name = cfg.experiment_name
    ppo_cfg.run.seed = cfg.seed
    ppo_cfg.run.artifact_root = cfg.artifact_root
    ppo_cfg.run.total_updates = cfg.snapshot_interval_updates
    ppo_cfg.reward.potential_shaping_initial = 0.0
    ppo_cfg.reward.potential_shaping_final = 0.0
    ppo_cfg.reward.potential_shaping_anneal_steps = 0
    ppo_cfg.evaluation.games = cfg.evaluation_games
    ppo_cfg.evaluation.batch_games = cfg.evaluation_batch_games
    ppo_cfg.evaluation.duplicate = cfg.evaluation_duplicate
    ppo_cfg.evaluation.shuffle_bank_seed = cfg.evaluation_shuffle_bank_seed
    return ppo_cfg


def _member_from_dict(data: dict[str, Any]) -> PoolMember:
    return PoolMember(
        name=data["name"],
        kind=data["kind"],
        anchor=data.get("anchor", True),
        stalling=data.get("stalling", False),
        exploiter=data.get("exploiter", False),
        policy_name=data.get("policy_name"),
        config=data.get("config"),
        checkpoint=data.get("checkpoint"),
        recent_win_rate=data.get("recent_win_rate", 0.5),
    )


def _default_pool_members() -> list[PoolMember]:
    return [
        PoolMember("discard_only", "static", stalling=True, policy_name="discard_only"),
        PoolMember("heuristic_balanced", "static", policy_name="heuristic_balanced"),
        PoolMember("heuristic_cautious", "static", stalling=True, policy_name="heuristic_cautious"),
        PoolMember("heuristic_expert", "static", policy_name="heuristic_expert"),
    ]


def _policy_for_member(member: PoolMember):
    if member.kind == "static":
        return policy_by_name(member.policy_name or member.name)
    if member.kind == "checkpoint":
        if member.config is None or member.checkpoint is None:
            msg = f"checkpoint pool member is missing config/checkpoint: {member.name}"
            raise ValueError(msg)
        return checkpoint_policy(load_config(member.config), member.checkpoint)
    msg = f"unknown pool member kind: {member.kind}"
    raise ValueError(msg)


def _evaluate_against_member(
    snapshot: PoolMember, ppo_cfg: JaxPPOConfig, member: PoolMember, cfg: LeagueConfig
) -> dict[str, Any]:
    if snapshot.checkpoint is None:
        msg = f"snapshot has no checkpoint: {snapshot.name}"
        raise ValueError(msg)
    if member.kind == "static":
        return evaluate_checkpoint_vs_static(
            ppo_cfg,
            snapshot.checkpoint,
            member.policy_name or member.name,
            games=cfg.evaluation_games,
            duplicate=cfg.evaluation_duplicate,
        )
    if member.kind == "checkpoint":
        if member.config is None or member.checkpoint is None:
            msg = f"checkpoint eval member is missing config/checkpoint: {member.name}"
            raise ValueError(msg)
        return evaluate_checkpoint_match(
            ppo_cfg,
            snapshot.checkpoint,
            load_config(member.config),
            member.checkpoint,
            games=cfg.evaluation_games,
            duplicate=cfg.evaluation_duplicate,
        )
    msg = f"unknown pool member kind: {member.kind}"
    raise ValueError(msg)


def _save_main_snapshot(
    run_dir: Path,
    state: TrainState,
    ppo_cfg: JaxPPOConfig,
    cycle: int,
    update: int,
    *,
    anchor: bool,
) -> PoolMember:
    checkpoint = run_dir / "snapshots" / f"cycle_{cycle:02d}_update_{update:06d}"
    save_checkpoint(checkpoint, state, ppo_cfg)
    save_checkpoint(run_dir / "latest", state, ppo_cfg)
    return PoolMember(
        name=f"league_c{cycle:02d}_u{update:06d}",
        kind="checkpoint",
        anchor=anchor,
        config=str(run_dir / "main_ppo_config.json"),
        checkpoint=str(checkpoint),
        created_cycle=cycle,
        created_update=update,
    )


def _segment_updates(cfg: LeagueConfig, segment: int, segment_count: int) -> int:
    base = cfg.league_updates_per_cycle // segment_count
    remainder = cfg.league_updates_per_cycle - base * segment_count
    return base + (1 if segment < remainder else 0)


def _metrics_to_row(
    metrics: dict[str, jax.Array],
    update: int,
    cycle: int,
    active_pool: list[PoolMember],
    probs: list[float],
) -> dict[str, Any]:
    row = {
        key: float(value) if getattr(value, "shape", ()) == () else value.tolist()
        for key, value in metrics.items()
    }
    row["event"] = "train_update"
    row["update"] = update
    row["cycle"] = cycle
    row["active_pool_size"] = len(active_pool)
    row["active_pool"] = [member.name for member in active_pool]
    row["active_pool_probs"] = [float(value) for value in probs]
    return row


def _update_pool_win_rates(pool: list[PoolMember], rows: list[dict[str, Any]]) -> None:
    by_name = {member.name: member for member in pool}
    for row in rows:
        member = by_name.get(row.get("opponent"))
        if member is not None:
            member.recent_win_rate = row["win_rate"]


def _success(cfg: LeagueConfig, exploiter_row: dict[str, Any], best_expert_ci: float) -> bool:
    return (
        exploiter_row["win_rate"] <= cfg.success_exploiter_win_rate
        and best_expert_ci > cfg.expert_guard_ci_low
    )


def _stagnated(cfg: LeagueConfig, exploiter_rates: list[float]) -> bool:
    if len(exploiter_rates) < cfg.stagnation_window:
        return False
    window = exploiter_rates[-cfg.stagnation_window :]
    return max(window) - min(window) < cfg.stagnation_min_delta


def _result_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",
        "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 _elo_estimate(win_rate: float) -> float:
    p = _clamp(win_rate, 0.001, 0.999)
    return 400.0 * math.log10(p / (1.0 - p))


def _hypothesis_read(
    exploiter_rows: list[dict[str, Any]], expert_rows: list[dict[str, Any]]
) -> str:
    if not exploiter_rows or not expert_rows:
        return "미결: exploiter 또는 expert 시계열이 부족하다."
    if len(exploiter_rows) == 1:
        exploit_end = exploiter_rows[-1]["win_rate"]
        open_start = expert_rows[0]["opened_colors_per_game"]
        open_end = expert_rows[-1]["opened_colors_per_game"]
        if exploit_end <= 0.60:
            return (
                "부분 지지: 피탈률 목표는 한 사이클 만에 달성했다. 다만 expert 상대 "
                f"오픈 색은 {open_start:.2f}에서 {open_end:.2f}로 증가해, "
                "적대적 압력이 selectivity를 유도한다는 하위 가설은 아직 지지되지 않는다."
            )
        return (
            "미결: exploiter 시계열이 한 점뿐이라 추세 판단은 이르다. "
            f"expert 상대 오픈 색은 {open_start:.2f}에서 {open_end:.2f}로 이동했다."
        )
    exploit_start = exploiter_rows[0]["win_rate"]
    exploit_end = exploiter_rows[-1]["win_rate"]
    open_start = expert_rows[0]["opened_colors_per_game"]
    open_end = expert_rows[-1]["opened_colors_per_game"]
    if exploit_end <= 0.60 and open_end < open_start:
        return "지지: 피탈률이 성공 기준까지 하락했고 expert 상대 오픈 색도 감소했다."
    if exploit_end < exploit_start and open_end < open_start:
        return "부분 지지: 피탈률과 오픈 색이 함께 하락했지만 성공 기준에는 아직 못 미쳤다."
    if exploit_end >= exploit_start and open_end >= open_start:
        return "기각 쪽: exploiter 압력이 누적되어도 피탈률과 오픈 색이 함께 개선되지 않았다."
    return "미결: 피탈률과 오픈 색 수가 서로 다른 방향으로 움직였다."


def _final_snapshot_rows(snapshot_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    if not snapshot_rows:
        return []
    final_update = max(row["update"] for row in snapshot_rows)
    return [row for row in snapshot_rows if row["update"] == final_update]


def _write_plots(cfg: LeagueConfig, output_dir: Path, rows: list[dict[str, Any]]) -> list[str]:
    try:
        import matplotlib.pyplot as plt
    except Exception:
        return []
    output_dir.mkdir(parents=True, exist_ok=True)
    plot_paths = []

    exploiter_rows = [row for row in rows if row.get("event") == "exploiter_eval"]
    if exploiter_rows:
        path = output_dir / "league-v1-exploiter-win-rate.png"
        plt.figure()
        plt.plot(
            [row["cycle"] for row in exploiter_rows],
            [row["win_rate"] for row in exploiter_rows],
            marker="o",
        )
        plt.axhline(cfg.success_exploiter_win_rate, color="red", linestyle="--")
        plt.xlabel("Cycle")
        plt.ylabel("Exploiter win rate")
        plt.tight_layout()
        plt.savefig(path)
        plt.close()
        plot_paths.append(str(path))

    expert_rows = [
        row
        for row in rows
        if row.get("event") == "snapshot_eval" and row.get("opponent") == "heuristic_expert"
    ]
    if expert_rows:
        path = output_dir / "league-v1-opened-colors.png"
        plt.figure()
        plt.plot(
            [row["update"] for row in expert_rows],
            [row["opened_colors_per_game"] for row in expert_rows],
            marker="o",
        )
        plt.xlabel("Update")
        plt.ylabel("Opened colors vs expert")
        plt.tight_layout()
        plt.savefig(path)
        plt.close()
        plot_paths.append(str(path))

        path = output_dir / "league-v1-elo-estimate.png"
        plt.figure()
        plt.plot(
            [row["update"] for row in expert_rows],
            [row["elo_estimate"] for row in expert_rows],
            marker="o",
        )
        plt.xlabel("Update")
        plt.ylabel("Logistic Elo estimate vs expert")
        plt.tight_layout()
        plt.savefig(path)
        plt.close()
        plot_paths.append(str(path))
    return plot_paths


def _create_league_run_dir(cfg: LeagueConfig) -> Path:
    root = Path(cfg.artifact_root).resolve()
    timestamp = time.strftime("%Y-%m-%d_%H%M%S")
    run_dir = root / f"{timestamp}_{_slugify(cfg.experiment_name)}"
    run_dir.mkdir(parents=True, exist_ok=False)
    return run_dir


def _league_config_to_dict(cfg: LeagueConfig) -> dict[str, Any]:
    data = asdict(cfg)
    data["anchors"] = [asdict(member) for member in cfg.anchors]
    return data


def _pool_json(pool: list[PoolMember]) -> list[dict[str, Any]]:
    return [asdict(member) for member in pool]


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 _clamp(value: float, lower: float, upper: float) -> float:
    return min(max(value, lower), upper)


def _slugify(value: str) -> str:
    return "".join(ch.lower() if ch.isalnum() else "-" for ch in value).strip("-")


__all__ = [
    "LeagueConfig",
    "PoolMember",
    "active_training_pool",
    "load_league_config",
    "pool_sampling_probabilities",
    "run_league",
]
