//! combat-sim: the batch harness over `combat-core` (spec §12).
//!
//! Loads the config "table", runs N fights across a matchup/parameter sweep, and
//! exports per-fight metrics so "feel" (fight duration, difficulty curve) is
//! *measured and tuned*, not eyeballed. The two headline sweeps:
//!
//! - **con** — player vs. an equal-kit enemy across level delta × player-skill,
//!   with auto-attack-only included as one player policy so the §12 anti-turtle
//!   guardrail (fraction of fights won by auto-only) falls out of the same table.
//! - **archetypes** — a skilled player against golem / rat / battlemage.
//!
//! Outputs (under `--out`, default `out/`): `fights.csv` (one row per fight),
//! `summary.csv` (aggregated per scenario cell), `sample_fight.json` (one fight's
//! full event log + metrics for plotting a single fight).

use combat_core::actor::{compile_actor, StatBlock};
use combat_core::config::Rules;
use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown};
use combat_core::engine::{Encounter, EncounterOptions};
use combat_core::event::{FightMetrics, FinalState, Outcome};
use combat_core::{Actor, CombatConfig};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;

/// `1000` milli-units == 1 displayed unit.
fn u(milli: i64) -> f64 {
    milli as f64 / 1000.0
}

/// Which controller drives the player in a cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PlayerCtrl {
    /// The §12 guardrail policy: only ever auto-attacks.
    AutoOnly,
    /// Skill-tunable scripted "player".
    Skill(i64),
}

impl PlayerCtrl {
    fn label(&self) -> String {
        match self {
            PlayerCtrl::AutoOnly => "auto_only".to_string(),
            PlayerCtrl::Skill(s) => format!("skill{s}"),
        }
    }
    fn skill(&self) -> i64 {
        match self {
            PlayerCtrl::AutoOnly => 0,
            PlayerCtrl::Skill(s) => *s,
        }
    }
    fn make(&self) -> Box<dyn Controller> {
        match self {
            PlayerCtrl::AutoOnly => Box::new(SwingOnCooldown),
            PlayerCtrl::Skill(s) => Box::new(ScriptedPlayer::new(*s)),
        }
    }
}

/// One flattened fight record (a `fights.csv` row).
#[derive(Serialize)]
struct FightRow {
    sweep: String,
    matchup: String,
    con_delta: i32,
    player_ctrl: String,
    player_skill: i64,
    seed: u64,

    player_won: u8,
    outcome: String,
    ticks: u64,
    seconds: f64,
    player_final: String,
    enemy_final: String,

    // Player-side throughput + flow accounting (§12).
    p_fill_dmg_dealt: f64,
    p_fill_dmg_taken: f64,
    p_ceiling_dmg_dealt: f64,
    p_ceiling_dmg_taken: f64,
    p_regen_gained: f64,
    p_spent_actions: f64,
    p_lost_damage: f64,
    p_restored: f64,

    // Stagger ledger.
    p_dazes_inflicted: u32,
    p_unconscious_inflicted: u32,
    p_dazes_taken: u32,
    p_unconscious_taken: u32,

    // Reliability.
    p_fizzles: u32,
    p_interruptions: u32,

    // "Wanted to act but couldn't" (§12).
    p_blocked_cant_afford: u32,
    p_blocked_on_cooldown: u32,
    p_blocked_dazed: u32,

    // Action mix (auto-attack-only guardrail signal).
    p_auto_attacks: u32,
    p_abilities_used: u32,
    p_recoveries_used: u32,
    p_idle_ticks: u32,

    p_near_death_recoveries: u32,
    p_min_fill: f64,
    p_min_cap: f64,
}

/// Aggregated per-cell summary (a `summary.csv` row).
#[derive(Serialize)]
struct SummaryRow {
    sweep: String,
    matchup: String,
    con_delta: i32,
    player_ctrl: String,
    fights: u32,
    player_win_rate: f64,
    draw_rate: f64,
    timeout_rate: f64,
    mean_seconds: f64,
    median_seconds: f64,
    mean_player_dazes_taken: f64,
    mean_player_staggers_inflicted: f64,
    mean_player_fizzles: f64,
    mean_player_blocked_cant_afford: f64,
    mean_player_min_cap: f64,
}

struct Args {
    config: PathBuf,
    out: PathBuf,
    seeds: u64,
    max_ticks: u64,
}

fn parse_args() -> Args {
    let mut config = PathBuf::from("configs/default.json");
    let mut out = PathBuf::from("out");
    let mut seeds = 60u64;
    let mut max_ticks = 6_000u64;

    let mut it = std::env::args().skip(1);
    while let Some(a) = it.next() {
        match a.as_str() {
            "--config" => config = PathBuf::from(it.next().unwrap_or_default()),
            "--out" => out = PathBuf::from(it.next().unwrap_or_default()),
            "--seeds" => seeds = it.next().and_then(|s| s.parse().ok()).unwrap_or(seeds),
            "--max-ticks" => max_ticks = it.next().and_then(|s| s.parse().ok()).unwrap_or(max_ticks),
            "--help" | "-h" => {
                eprintln!(
                    "combat-sim [--config PATH] [--out DIR] [--seeds N] [--max-ticks N]\n\
                     Runs the con-tier and archetype sweeps and writes CSV + a sample fight."
                );
                std::process::exit(0);
            }
            other => eprintln!("warning: ignoring unknown arg {other:?}"),
        }
    }
    Args { config, out, seeds, max_ticks }
}

fn main() -> ExitCode {
    let args = parse_args();

    let config = match std::fs::read_to_string(&args.config) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("error: cannot read config {:?}: {e}", args.config);
            return ExitCode::FAILURE;
        }
    };
    let config: CombatConfig = match serde_json::from_str(&config) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: invalid config JSON: {e}");
            return ExitCode::FAILURE;
        }
    };
    let rules = config.compile();

    if let Err(e) = std::fs::create_dir_all(&args.out) {
        eprintln!("error: cannot create out dir {:?}: {e}", args.out);
        return ExitCode::FAILURE;
    }

    let mut fights: Vec<FightRow> = Vec::new();
    con_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights);
    archetype_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights);

    let summary = summarize(&fights);

    if let Err(e) = write_csv(&args.out.join("fights.csv"), &fights) {
        eprintln!("error writing fights.csv: {e}");
        return ExitCode::FAILURE;
    }
    if let Err(e) = write_csv(&args.out.join("summary.csv"), &summary) {
        eprintln!("error writing summary.csv: {e}");
        return ExitCode::FAILURE;
    }

    // A single inspectable fight (even-con, skilled player) with its full event log.
    dump_sample_fight(&config, &rules, args.max_ticks, &args.out);

    println!(
        "combat-sim: {} fights across {} cells → {:?}",
        fights.len(),
        summary.len(),
        args.out
    );
    println!("  fights.csv   one row per fight");
    println!("  summary.csv  aggregated per scenario cell");
    println!("  sample_fight.json  one even-con fight, full event log");
    ExitCode::SUCCESS
}

/// Build a fighter from a named archetype, overriding team/level.
fn make_fighter(
    config: &CombatConfig,
    rules: &Rules,
    archetype: &str,
    id: u32,
    team: u8,
    level: i32,
) -> Actor {
    let base = config
        .stat_blocks
        .get(archetype)
        .unwrap_or_else(|| panic!("config missing stat_block {archetype:?}"));
    let mut sb: StatBlock = base.clone();
    sb.team = team;
    sb.level = level;
    let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
    if !missing.is_empty() {
        eprintln!("warning: {archetype} loadout missing abilities: {missing:?}");
    }
    compile_actor(id, &sb, loadout, rules.tick_rate)
}

/// Run one fight and flatten to a row.
#[allow(clippy::too_many_arguments)]
fn run_fight(
    rules: &Rules,
    sweep: &str,
    matchup: &str,
    con_delta: i32,
    player_ctrl: PlayerCtrl,
    player: Actor,
    enemy: Actor,
    enemy_ctrl: Box<dyn Controller>,
    seed: u64,
    max_ticks: u64,
) -> FightRow {
    let controllers: Vec<Box<dyn Controller>> = vec![player_ctrl.make(), enemy_ctrl];
    let opts = EncounterOptions { max_ticks, log_events: false, sample_every: 0 };
    let m = Encounter::new(vec![player, enemy], controllers, rules.clone(), seed, opts).run();
    flatten(&m, sweep, matchup, con_delta, player_ctrl, seed)
}

fn flatten(
    m: &FightMetrics,
    sweep: &str,
    matchup: &str,
    con_delta: i32,
    player_ctrl: PlayerCtrl,
    seed: u64,
) -> FightRow {
    let p = m.actor(0).expect("player metrics");
    let e = m.actor(1).expect("enemy metrics");
    let (outcome, player_won) = match m.outcome {
        Outcome::Win { team: 0 } => ("win_player".to_string(), 1),
        Outcome::Win { team } => (format!("win_team{team}"), 0),
        Outcome::Draw => ("draw".to_string(), 0),
        Outcome::Timeout => ("timeout".to_string(), 0),
    };
    FightRow {
        sweep: sweep.to_string(),
        matchup: matchup.to_string(),
        con_delta,
        player_ctrl: player_ctrl.label(),
        player_skill: player_ctrl.skill(),
        seed,
        player_won,
        outcome,
        ticks: m.ticks,
        seconds: m.seconds(),
        player_final: final_label(p.final_state),
        enemy_final: final_label(e.final_state),
        p_fill_dmg_dealt: u(p.fill_damage_dealt),
        p_fill_dmg_taken: u(p.fill_damage_taken),
        p_ceiling_dmg_dealt: u(p.ceiling_damage_dealt),
        p_ceiling_dmg_taken: u(p.ceiling_damage_taken),
        p_regen_gained: u(p.fill_gained_regen),
        p_spent_actions: u(p.fill_spent_actions),
        p_lost_damage: u(p.fill_lost_damage),
        p_restored: u(p.fill_restored),
        p_dazes_inflicted: p.dazes_inflicted,
        p_unconscious_inflicted: p.unconscious_inflicted,
        p_dazes_taken: p.dazes_taken,
        p_unconscious_taken: p.unconscious_taken,
        p_fizzles: p.fizzles,
        p_interruptions: p.interruptions_suffered,
        p_blocked_cant_afford: p.blocked_cant_afford,
        p_blocked_on_cooldown: p.blocked_on_cooldown,
        p_blocked_dazed: p.blocked_dazed,
        p_auto_attacks: p.auto_attacks,
        p_abilities_used: p.abilities_used,
        p_recoveries_used: p.recoveries_used,
        p_idle_ticks: p.idle_ticks,
        p_near_death_recoveries: p.near_death_recoveries,
        p_min_fill: u(p.min_fill),
        p_min_cap: u(p.min_cap),
    }
}

fn final_label(s: FinalState) -> String {
    match s {
        FinalState::Alive => "alive",
        FinalState::Dazed => "dazed",
        FinalState::Unconscious => "unconscious",
        FinalState::Dead => "dead",
    }
    .to_string()
}

/// The headline sweep: player (team 0) vs. an equal-kit duelist enemy (team 1),
/// across level delta × player policy. Enemy plays hold-and-punish.
fn con_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
    let player_level = 10;
    let player_ctrls = [
        PlayerCtrl::AutoOnly,
        PlayerCtrl::Skill(20),
        PlayerCtrl::Skill(50),
        PlayerCtrl::Skill(80),
        PlayerCtrl::Skill(100),
    ];
    // A fixed, competent benchmark opponent isolates the con + player-skill
    // signal (vs. a different enemy AI, which would inject matchup asymmetry).
    for delta in -8..=8 {
        let enemy_level = player_level + delta;
        for ctrl in player_ctrls {
            for seed in 0..seeds {
                let player = make_fighter(config, rules, "duelist", 0, 0, player_level);
                let enemy = make_fighter(config, rules, "duelist", 1, 1, enemy_level);
                out.push(run_fight(
                    rules,
                    "con",
                    "duelist_vs_duelist",
                    delta,
                    ctrl,
                    player,
                    enemy,
                    Box::new(ScriptedPlayer::new(80)),
                    seed,
                    max_ticks,
                ));
            }
        }
    }
}

/// A skilled player against the three archetypes, to feel out asymmetric matchups.
fn archetype_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
    let player_level = 10;
    let enemies = ["rat", "skeleton", "battlemage", "golem", "troll", "wraith"];
    let ctrl = PlayerCtrl::Skill(80);
    for enemy_name in enemies {
        let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level);
        let delta = enemy_level - player_level;
        for seed in 0..seeds {
            let player = make_fighter(config, rules, "duelist", 0, 0, player_level);
            let enemy = make_fighter(config, rules, enemy_name, 1, 1, enemy_level);
            out.push(run_fight(
                rules,
                "archetypes",
                enemy_name,
                delta,
                ctrl,
                player,
                enemy,
                Box::new(HoldAndPunish::default()),
                seed,
                max_ticks,
            ));
        }
    }
}

/// Roll fights up into per-cell summary rows.
fn summarize(fights: &[FightRow]) -> Vec<SummaryRow> {
    // Group by (sweep, matchup, con_delta, player_ctrl).
    let mut groups: BTreeMap<(String, String, i32, String), Vec<&FightRow>> = BTreeMap::new();
    for f in fights {
        groups
            .entry((f.sweep.clone(), f.matchup.clone(), f.con_delta, f.player_ctrl.clone()))
            .or_default()
            .push(f);
    }

    let mut rows = Vec::new();
    for ((sweep, matchup, con_delta, player_ctrl), fs) in groups {
        let n = fs.len() as f64;
        let wins = fs.iter().filter(|f| f.player_won == 1).count() as f64;
        let draws = fs.iter().filter(|f| f.outcome == "draw").count() as f64;
        let timeouts = fs.iter().filter(|f| f.outcome == "timeout").count() as f64;
        let mean = |xs: &[f64]| xs.iter().sum::<f64>() / xs.len().max(1) as f64;
        let mut secs: Vec<f64> = fs.iter().map(|f| f.seconds).collect();
        secs.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let median = if secs.is_empty() { 0.0 } else { secs[secs.len() / 2] };

        rows.push(SummaryRow {
            sweep,
            matchup,
            con_delta,
            player_ctrl,
            fights: fs.len() as u32,
            player_win_rate: wins / n,
            draw_rate: draws / n,
            timeout_rate: timeouts / n,
            mean_seconds: mean(&secs),
            median_seconds: median,
            mean_player_dazes_taken: mean(&fs.iter().map(|f| f.p_dazes_taken as f64).collect::<Vec<_>>()),
            mean_player_staggers_inflicted: mean(
                &fs.iter()
                    .map(|f| (f.p_dazes_inflicted + f.p_unconscious_inflicted) as f64)
                    .collect::<Vec<_>>(),
            ),
            mean_player_fizzles: mean(&fs.iter().map(|f| f.p_fizzles as f64).collect::<Vec<_>>()),
            mean_player_blocked_cant_afford: mean(
                &fs.iter().map(|f| f.p_blocked_cant_afford as f64).collect::<Vec<_>>(),
            ),
            mean_player_min_cap: mean(&fs.iter().map(|f| f.p_min_cap).collect::<Vec<_>>()),
        });
    }
    rows
}

/// Dump one even-con, skilled fight with its full event log for the notebook.
fn dump_sample_fight(config: &CombatConfig, rules: &Rules, max_ticks: u64, out: &std::path::Path) {
    let player = make_fighter(config, rules, "duelist", 0, 0, 10);
    let enemy = make_fighter(config, rules, "duelist", 1, 1, 10);
    let controllers: Vec<Box<dyn Controller>> =
        vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())];
    let opts = EncounterOptions { max_ticks, log_events: true, sample_every: 1 };
    let (metrics, events) = Encounter::new(vec![player, enemy], controllers, rules.clone(), 7, opts)
        .run_with_events();

    #[derive(Serialize)]
    struct Sample<'a> {
        metrics: &'a FightMetrics,
        events: &'a [combat_core::event::Event],
    }
    let sample = Sample { metrics: &metrics, events: &events };
    match serde_json::to_string_pretty(&sample) {
        Ok(j) => {
            if let Err(e) = std::fs::write(out.join("sample_fight.json"), j) {
                eprintln!("warning: cannot write sample_fight.json: {e}");
            }
        }
        Err(e) => eprintln!("warning: cannot serialize sample fight: {e}"),
    }
}

fn write_csv<T: Serialize>(path: &std::path::Path, rows: &[T]) -> Result<(), Box<dyn std::error::Error>> {
    let mut w = csv::Writer::from_path(path)?;
    for r in rows {
        w.serialize(r)?;
    }
    w.flush()?;
    Ok(())
}
