//! Integration tests for the run state machine, driven against the REAL baked
//! campaign: game-web/config.json + the three atlas manifests under
//! tools/out/atlas/. A scripted bot plays whole runs headlessly — the same
//! loop the browser drives, minus the pixels.

use game_wasm::manifest::{Dir, DIRS};
use game_wasm::run::{GameRun, Mode};
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::path::Path;

const CAMPAIGN: [&str; 3] = ["7", "69", "59"];

fn repo() -> &'static Path {
    Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()
}

fn config_json() -> String {
    fs::read_to_string(repo().join("game-web/config.json")).expect("game-web/config.json")
}

fn manifests_json() -> String {
    let parts: Vec<String> = CAMPAIGN
        .iter()
        .map(|s| {
            fs::read_to_string(repo().join(format!("tools/out/atlas/{s}/manifest.json")))
                .unwrap_or_else(|_| panic!("manifest for seed {s} — bake the campaign first"))
        })
        .collect();
    format!("[{}]", parts.join(","))
}

fn new_run(archetype: &str, seed: u64) -> GameRun {
    GameRun::new(&config_json(), &manifests_json(), archetype, seed).expect("run builds")
}

// ------------------------------------------------------------------ //
// Placement invariants
// ------------------------------------------------------------------ //

#[test]
fn placement_invariants_across_seeds() {
    for seed in [1u64, 2, 3, 42, 1337, 9001] {
        let run = new_run("duelist", seed);
        assert_eq!(run.floors.len(), 3);
        for (i, floor) in run.floors.iter().enumerate() {
            let start = floor.manifest.start_room;
            assert!(
                !floor.monsters.contains_key(&start),
                "start room must be safe"
            );
            assert!(
                !floor.treasure.contains_key(&start),
                "no loot at the entrance"
            );
            assert_ne!(floor.exit_room, start, "exit is somewhere deeper");
            assert!(
                floor
                    .manifest
                    .distances(start)
                    .contains_key(&floor.exit_room),
                "exit reachable from start"
            );
            let fights = floor.monsters.len();
            assert!(
                (3..=14).contains(&fights),
                "floor {i} seed {seed}: {fights} fights is out of the sane band"
            );
            if i == 2 {
                let boss = &floor.monsters[&floor.exit_room];
                assert_eq!(boss.len(), 1);
                assert_eq!(
                    boss[0].archetype, "boss",
                    "the deepest room belongs to the boss"
                );
            } else {
                assert!(floor
                    .monsters
                    .get(&floor.exit_room)
                    .map(|p| p.iter().all(|s| s.archetype != "boss"))
                    .unwrap_or(true));
            }
        }
    }
}

#[test]
fn dir_arithmetic() {
    assert_eq!(Dir::N.turned(1), Dir::E);
    assert_eq!(Dir::N.turned(-1), Dir::W);
    assert_eq!(Dir::S.opposite(), Dir::N);
    assert_eq!(Dir::W.turned(4), Dir::W);
}

// ------------------------------------------------------------------ //
// Determinism
// ------------------------------------------------------------------ //

#[test]
fn identical_runs_emit_identical_state() {
    let script = |run: &mut GameRun| -> Vec<String> {
        let mut out = vec![run.state_json()];
        for _ in 0..6 {
            run.step(true);
            out.push(run.state_json());
            // Tick any battle to a fixed depth so combat state is compared too.
            for _ in 0..40 {
                run.combat_tick();
            }
            out.push(run.state_json());
            run.turn(1);
        }
        out
    };
    let a = script(&mut new_run("duelist", 777));
    let b = script(&mut new_run("duelist", 777));
    assert_eq!(
        a, b,
        "same seed + same commands must replay bit-identically"
    );
}

// ------------------------------------------------------------------ //
// Combat handoff + persistence
// ------------------------------------------------------------------ //

/// March the player into the first monster room (BFS), returns rooms walked.
fn walk_into_trouble(run: &mut GameRun) -> bool {
    for _ in 0..64 {
        if run.mode == Mode::Combat {
            return true;
        }
        let target = {
            let floor = run.floor();
            nearest(run, |r| floor.monsters.contains_key(&r))
        };
        let Some(path) = target else { return false };
        step_along(run, &path);
    }
    run.mode == Mode::Combat
}

/// BFS path from the current room to the nearest room satisfying `want`.
fn nearest(run: &GameRun, want: impl Fn(usize) -> bool) -> Option<Vec<usize>> {
    let by_id = run.floor().manifest.by_id();
    let mut prev: HashMap<usize, usize> = HashMap::new();
    let mut q = VecDeque::from([run.room]);
    let mut seen = std::collections::HashSet::from([run.room]);
    while let Some(at) = q.pop_front() {
        if at != run.room && want(at) {
            let mut path = vec![at];
            let mut cur = at;
            while let Some(&p) = prev.get(&cur) {
                if p == run.room {
                    break;
                }
                path.push(p);
                cur = p;
            }
            path.reverse();
            return Some(path);
        }
        if let Some(room) = by_id.get(&at) {
            for d in DIRS {
                if let Some(n) = room.nav.get(d) {
                    if seen.insert(n) {
                        prev.insert(n, at);
                        q.push_back(n);
                    }
                }
            }
        }
    }
    None
}

/// Walk the first hop of `path` (sets facing directly; turn() is unit-tested).
fn step_along(run: &mut GameRun, path: &[usize]) {
    let Some(&next) = path.first() else { return };
    let dir = {
        let by_id = run.floor().manifest.by_id();
        DIRS.into_iter()
            .find(|d| by_id[&run.room].nav.get(*d) == Some(next))
    };
    if let Some(dir) = dir {
        run.facing = dir;
        run.step(true);
    }
}

/// Fight the current battle to resolution with a simple but active policy:
/// heavies on cooldown cadence, second wind when the ceiling sags.
fn fight_out(run: &mut GameRun) {
    let mut t = 0u64;
    while run.mode == Mode::Combat && t < 30_000 {
        if t % 50 == 5 {
            run.use_ability("heavy_strike", -1);
        }
        if t % 80 == 30 {
            run.use_ability("second_wind", -1);
        }
        run.combat_tick();
        t += 1;
    }
    assert!(t < 30_000, "fight failed to resolve in 30k ticks");
}

#[test]
fn fights_wound_and_wounds_persist() {
    let mut run = new_run("duelist", 4242);
    assert!(
        walk_into_trouble(&mut run),
        "campaign floor 1 must offer a fight"
    );
    fight_out(&mut run);
    let _ = run.state_json(); // settles the finished battle
    if run.mode == Mode::Explore {
        // Swinging costs fatigue; winning a fight always leaves a mark.
        assert!(
            run.player.fatigue_bp > 0 || run.player.vigor_bp < 10_000,
            "a won fight must leave the player worn"
        );
        assert!(run.stats.kills > 0);
    } else {
        assert_eq!(run.mode, Mode::Dead, "only other resolution is death");
    }
}

#[test]
fn fleeing_returns_and_costs_ceiling() {
    let mut run = new_run("duelist", 31337);
    assert!(walk_into_trouble(&mut run));
    let battle_room = run.room;
    let before = run.prev_room;
    for _ in 0..20 {
        run.combat_tick();
    }
    run.flee();
    assert_eq!(run.mode, Mode::Explore);
    assert_eq!(run.room, before, "flee backs out the way you came");
    assert!(run.player.fatigue_bp >= 800, "flee marks the ceiling");
    assert!(
        run.floor().monsters.contains_key(&battle_room),
        "the monsters don't forget"
    );
}

#[test]
fn potions_apply_out_of_combat_only() {
    let mut run = new_run("duelist", 99);
    run.player.draughts = 1;
    run.player.tonics = 1;
    run.player.vigor_bp = 4_000;
    run.player.fatigue_bp = 3_000;
    run.use_item("draught");
    assert_eq!(run.player.vigor_bp, 7_000 - 0, "draught restores fill");
    run.use_item("tonic");
    assert_eq!(run.player.fatigue_bp, 0, "tonic clears fatigue");
    assert_eq!(run.player.draughts, 0);
    assert_eq!(run.player.tonics, 0);
    run.use_item("draught");
    let v = run.player.vigor_bp;
    assert_eq!(v, 7_000, "empty belt does nothing");
}

// ------------------------------------------------------------------ //
// Boss tuning probe — pure combat-core sim, no dungeon.
// ------------------------------------------------------------------ //

#[test]
fn boss_is_winnable_by_skilled_play() {
    use combat_core::actor::compile_actor;
    use combat_core::config::CombatConfig;
    use combat_core::controller::{Controller, ScriptedPlayer};
    use combat_core::engine::{Encounter, EncounterOptions};
    use combat_core::event::Outcome;
    use game_wasm::monster_ai::MonsterAI;

    let config: CombatConfig = serde_json::from_str(&config_json()).unwrap();
    let rules = config.compile();

    let mut report = Vec::new();
    let mut best = 0;
    for skill in [50i64, 80, 100] {
        let mut wins = 0;
        let trials = 40;
        for seed in 0..trials {
            // The hero as they'd plausibly reach the bottom: level 12 (two
            // descents), a couple of relics, rested but not pristine.
            let mut hero = config.stat_blocks["duelist"].clone();
            hero.team = 0;
            hero.level = 12;
            hero.max_vigor = 180;
            hero.start_vigor_bp = 9_500;
            hero.start_fatigue_bp = 500;
            let mut boss = config.stat_blocks["boss"].clone();
            boss.team = 1;

            let (hl, _) = rules.resolve_loadout(&hero.loadout);
            let (bl, _) = rules.resolve_loadout(&boss.loadout);
            let actors = vec![
                compile_actor(0, &hero, hl, rules.tick_rate),
                compile_actor(1, &boss, bl, rules.tick_rate),
            ];
            let controllers: Vec<Box<dyn Controller>> =
                vec![Box::new(ScriptedPlayer::new(skill)), Box::new(MonsterAI)];
            let opts = EncounterOptions {
                max_ticks: 6_000,
                log_events: false,
                sample_every: 0,
            };
            let mut enc = Encounter::new(actors, controllers, rules.clone(), seed, opts);
            if matches!(enc.run().outcome, Outcome::Win { team: 0 }) {
                wins += 1;
            }
        }
        best = best.max(wins);
        report.push(format!("skill {skill}: {wins}/{trials}"));
    }
    println!("boss win rates — {}", report.join(", "));
    assert!(
        best >= 8,
        "even perfect play rarely beats the boss: {}",
        report.join(", ")
    );
}

// ------------------------------------------------------------------ //
// Whole-run bot
// ------------------------------------------------------------------ //

#[test]
fn bot_plays_whole_runs_to_a_verdict() {
    let mut verdicts = Vec::new();
    for seed in [11u64, 22, 33, 44, 55, 66] {
        let mut run = new_run("duelist", seed);
        let mut budget = 4_000usize;
        loop {
            budget -= 1;
            assert!(budget > 0, "seed {seed}: run did not terminate in budget");
            match run.mode {
                Mode::Dead | Mode::Victory => break,
                Mode::Combat => fight_out(&mut run),
                Mode::Explore => {
                    let _ = run.state_json();
                    // A patient crawler rests up before opening the next door.
                    if run.player.vigor_bp < 9_000 {
                        run.rest();
                        continue;
                    }
                    if run.player.fatigue_bp > 4_000 && run.player.tonics > 0 {
                        run.use_item("tonic");
                    }
                    if run.player.vigor_bp < 4_500 && run.player.draughts > 0 {
                        run.use_item("draught");
                    }
                    let at_exit = run.room == run.floor().exit_room;
                    let last = run.floor_idx + 1 == run.floors.len();
                    if at_exit && !last {
                        run.descend();
                        continue;
                    }
                    let goal = {
                        let floor = run.floor();
                        let exit = floor.exit_room;
                        nearest(&run, |r| {
                            floor.treasure.contains_key(&r)
                                || floor.monsters.contains_key(&r)
                                || r == exit
                        })
                    };
                    match goal {
                        Some(path) => step_along(&mut run, &path),
                        None => break, // nothing left and no exit path — shouldn't happen
                    }
                }
            }
        }
        let v = format!(
            "seed {seed}: {} on floor {} — kills {}, gold {}, relics {}, fled {}, explored {}",
            run.mode.label(),
            run.floor_idx + 1,
            run.stats.kills,
            run.stats.gold_looted,
            run.player.relics,
            run.stats.flees,
            run.stats.rooms_explored,
        );
        println!("{v}");
        verdicts.push((run.mode, run.floor_idx));
        assert!(matches!(run.mode, Mode::Dead | Mode::Victory));
    }
    // The campaign must be survivable past floor 1 for a mediocre bot, or the
    // tuning is off for humans too.
    assert!(
        verdicts.iter().any(|(_, floor)| *floor >= 1),
        "no bot run escaped floor 1 — too brutal"
    );
}
