//! The engine (spec §10): a fixed-tick, deterministic [`Encounter`] loop that
//! enforces the **canonical within-tick resolution order** — itself a determinism
//! invariant, because reordering changes outcomes (regen-before-damage yields
//! fewer staggers; sequential hits differ from batched).
//!
//! ```text
//! 0. Controllers decide (ascending actor-id) → pay cost, open cooldowns/windows,
//!    enqueue instant hits / start casts.
//! 1. Expire finishing statuses, dazes, and defense windows (inclusive of tick t).
//! 2. Resolve casts completing this tick → enqueue their hits / install statuses.
//! 3. Apply queued hits sequentially in ascending source-id order — each followed
//!    immediately by its stagger check and fatigue add.
//! 4. Tick DoT/HoT and other per-tick effects.
//! 5. Apply regen (clamped to cap).
//! ```
//!
//! A fight is a pure function of `(rules, seed, actors, controllers)`. Every roll
//! is a `root_rng.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one
//! ability never reshuffles another actor's or tick's stream (§10).

use crate::actor::{Actor, ActorId, HitOutcome, Status, StatusKind};
use crate::ability::Ability;
use crate::config::Rules;
use crate::controller::{Action, Controller, View};
use crate::effect::{EffectKind, EffectSpec, Valence};
use crate::event::{Event, FightMetrics, FightRecorder, Outcome, BlockedReason};
use crate::fixed::{apply_bp, Milli};
use crate::mitigation::{self, MitigationInputs, MitigationStage};
use crate::rng::Rng;

/// Knobs for a single fight that aren't balance (those live in [`Rules`]).
#[derive(Clone, Copy, Debug)]
pub struct EncounterOptions {
    /// Hard cap on ticks; reaching it is a `Timeout` outcome.
    pub max_ticks: u64,
    /// Retain the raw event log (for inspecting one fight; off for sweeps).
    pub log_events: bool,
    /// Sample cap/fill time series every N ticks (0 = off).
    pub sample_every: u32,
}

impl Default for EncounterOptions {
    fn default() -> Self {
        Self { max_ticks: 6_000, log_events: false, sample_every: 0 }
    }
}

/// A resolved instant magnitude waiting for sequential application at step 3.
#[derive(Clone, Debug)]
struct PendingHit {
    source: ActorId,
    source_eff_level: i32,
    target: ActorId,
    ability_id: String,
    seq: u64,
    payload: HitPayload,
}

#[derive(Clone, Debug)]
enum HitPayload {
    /// Fill damage, pre-con/pre-mitigation (potency already folded).
    Fill { pre_mitigation: Milli },
    /// Direct fatigue (a curse), pre-con-resist.
    Ceiling { magnitude: Milli },
    /// Fatigue healed (friendly, no resist).
    Recovery { magnitude: Milli },
    /// Instant fill restore (friendly).
    Heal { magnitude: Milli },
}

/// The fight.
pub struct Encounter {
    pub actors: Vec<Actor>,
    controllers: Vec<Box<dyn Controller>>,
    rules: Rules,
    root_rng: Rng,
    tick: u64,
    opts: EncounterOptions,
    recorder: FightRecorder,
    pending: Vec<PendingHit>,
    seq: u64,
}

impl Encounter {
    /// Build an encounter. `actors` must carry dense ids `0..n` matching their
    /// index, paired with a controller each.
    pub fn new(
        actors: Vec<Actor>,
        controllers: Vec<Box<dyn Controller>>,
        rules: Rules,
        seed: u64,
        opts: EncounterOptions,
    ) -> Self {
        assert_eq!(actors.len(), controllers.len(), "one controller per actor");
        for (i, a) in actors.iter().enumerate() {
            assert_eq!(a.id as usize, i, "actor ids must be dense 0..n in index order");
        }
        let recorder = FightRecorder::new(&actors, seed, rules.tick_rate, opts.log_events, opts.sample_every);
        Self {
            actors,
            controllers,
            rules,
            root_rng: Rng::from_seed(seed),
            tick: 0,
            opts,
            recorder,
            pending: Vec::new(),
            seq: 0,
        }
    }

    /// Run to resolution and return the rolled-up metrics.
    pub fn run(mut self) -> FightMetrics {
        loop {
            self.step();
            if let Some(outcome) = self.resolution() {
                return self.recorder.finish(self.tick, outcome, &self.actors);
            }
            if self.tick >= self.opts.max_ticks {
                return self.recorder.finish(self.tick, Outcome::Timeout, &self.actors);
            }
            self.tick += 1;
        }
    }

    /// Run and also return the raw event log (only populated if `log_events`).
    pub fn run_with_events(mut self) -> (FightMetrics, Vec<Event>) {
        let metrics = loop {
            self.step();
            if let Some(outcome) = self.resolution() {
                break self.recorder.clone().finish(self.tick, outcome, &self.actors);
            }
            if self.tick >= self.opts.max_ticks {
                break self.recorder.clone().finish(self.tick, Outcome::Timeout, &self.actors);
            }
            self.tick += 1;
        };
        let events = self.recorder.into_events();
        (metrics, events)
    }

    /// Advance exactly one tick, for a real-time front-end driving the engine on
    /// a wall clock (spec §10). Returns `Some(outcome)` the tick the fight
    /// resolves (or times out); `None` while it continues. Idempotent once
    /// resolved — calling again past resolution is a no-op returning the outcome.
    pub fn tick_once(&mut self) -> Option<Outcome> {
        if let Some(o) = self.resolution() {
            return Some(o);
        }
        self.step();
        if let Some(o) = self.resolution() {
            return Some(o);
        }
        if self.tick >= self.opts.max_ticks {
            return Some(Outcome::Timeout);
        }
        self.tick += 1;
        None
    }

    /// The current tick index (for a front-end clock / display).
    pub fn current_tick(&self) -> u64 {
        self.tick
    }

    /// Drain events recorded since the last drain (empty unless `log_events`).
    /// Lets a front-end pull per-tick events for damage numbers / log lines
    /// without retaining the whole fight.
    pub fn drain_events(&mut self) -> Vec<Event> {
        self.recorder.take_events()
    }

    /// One full tick through the canonical order.
    fn step(&mut self) {
        self.pending.clear();
        self.decide_and_commit(); // step 0
        self.expire(); // step 1
        self.resolve_casts(); // step 2
        self.apply_hits(); // step 3
        self.tick_over_time(); // step 4
        self.apply_regen(); // step 5
        self.sample();
    }

    // --- step 0 ------------------------------------------------------------

    fn decide_and_commit(&mut self) {
        let n = self.actors.len();

        // Pass 1: ALL controllers decide against the *same* start-of-tick snapshot
        // (no commits happen yet). Within one 0.1s tick combatants act
        // simultaneously — a higher-id actor must not get sub-tick reaction to a
        // lower-id actor's spend, or a perfect mirror match would be unfair.
        let mut decisions: Vec<(ActorId, Action)> = Vec::with_capacity(n);
        for id in 0..n as ActorId {
            let idx = id as usize;
            if !self.actors[idx].is_active() {
                continue; // unconscious/dead: out of the fight
            }
            if !self.actors[idx].can_act() {
                // Dazed or mid-cast: no fresh decision. Dazed = blocked; casting
                // proceeds toward step 2.
                if matches!(self.actors[idx].stagger, crate::actor::StaggerState::Dazed { .. }) {
                    self.recorder.blocked(self.tick, id, "", BlockedReason::Dazed);
                }
                continue;
            }
            // Global cooldown / swing timer: still mid-recovery from the last
            // action, so no new one can start this tick (§10 attack delay).
            if self.tick < self.actors[idx].next_action_at {
                continue;
            }
            let action = {
                let view = View {
                    me: &self.actors[idx],
                    actors: &self.actors,
                    tick: self.tick,
                    rules: &self.rules,
                };
                let mut rng = self.root_rng.fork(&format!("actor{id}:decide:tick{}", self.tick));
                self.controllers[idx].decide(&view, &mut rng)
            };
            decisions.push((id, action));
        }

        // Pass 2: commit in ascending id order (payment + cast-start are still
        // ordered, the §10 step-0 invariant — only the *decision* is simultaneous).
        for (id, action) in decisions {
            match action {
                Action::Idle => self.recorder.idle(id),
                Action::Use { ability_id, targets } => self.commit_use(id, &ability_id, targets),
            }
        }
    }

    fn commit_use(&mut self, source: ActorId, ability_id: &str, targets: Vec<ActorId>) {
        let idx = source as usize;
        let Some(ability) = self.actors[idx].ability(ability_id).cloned() else {
            // Controller named an ability not in the loadout — treat as a no-target
            // misfire so it's visible rather than silent.
            self.recorder.blocked(self.tick, source, ability_id, BlockedReason::NoTarget);
            return;
        };

        if self.actors[idx].on_cooldown(&ability.id, self.tick) {
            self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::OnCooldown);
            return;
        }
        if !self.actors[idx].can_afford(ability.cost) {
            // The §6 affordability gate: heavy hitters fall away as the ceiling
            // grinds down. This is a real "wanted to act but couldn't" signal.
            self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::CantAfford);
            return;
        }

        // Pay at cast-start (§6): fizzle/interrupt later = paid for nothing.
        let fill_before = self.actors[idx].vigor;
        self.actors[idx].pay(ability.cost);
        self.recorder.spent(source, ability.cost.fill);
        // Open the global cooldown — no new action until it elapses.
        self.actors[idx].next_action_at = self.tick + self.rules.global_cooldown as u64;
        if ability.cooldown > 0 {
            self.actors[idx]
                .cooldowns
                .insert(ability.id.clone(), self.tick + ability.cooldown as u64);
        }
        self.recorder.action(self.tick, &self.actors[idx], &ability.id, fill_before);
        self.classify_action(source, &ability);

        if ability.cast_time == 0 {
            // Instant: resolve now (step 0). Self-buffs install immediately so a
            // block protects this very tick; hits queue for step 3.
            self.resolve_ability(source, &ability, &targets);
        } else {
            let completes_at = self.tick + ability.cast_time as u64;
            self.actors[idx].casting = Some(crate::actor::Cast {
                ability_id: ability.id.clone(),
                targets,
                completes_at,
            });
            self.recorder.cast_started(self.tick, source, &ability.id, completes_at);
        }
    }

    fn classify_action(&mut self, source: ActorId, ability: &Ability) {
        if ability.is_support() {
            self.recorder.recovery_used(source);
        } else if ability.cost.fill == 0 && ability.is_offensive() {
            self.recorder.auto_attack(source);
        } else {
            self.recorder.ability_used(source);
        }
    }

    // --- step 1 ------------------------------------------------------------

    fn expire(&mut self) {
        let tick = self.tick;
        for a in &mut self.actors {
            a.expire_statuses(tick);
            a.expire_daze(tick);
        }
    }

    // --- step 2 ------------------------------------------------------------

    fn resolve_casts(&mut self) {
        let n = self.actors.len();
        for id in 0..n as ActorId {
            let idx = id as usize;
            let due = self.actors[idx]
                .casting
                .as_ref()
                .filter(|c| c.completes_at == self.tick)
                .map(|c| (c.ability_id.clone(), c.targets.clone()));
            if let Some((ability_id, targets)) = due {
                self.actors[idx].casting = None;
                if let Some(ability) = self.actors[idx].ability(&ability_id).cloned() {
                    self.resolve_ability(id, &ability, &targets);
                }
            }
        }
    }

    /// Roll fizzle, then route every effect: install timed statuses immediately,
    /// queue instant magnitudes for step 3.
    fn resolve_ability(&mut self, source: ActorId, ability: &Ability, targets: &[ActorId]) {
        // Fizzle (§9): competency vs difficulty. Paid for nothing on failure.
        let comp = self.actors[source as usize].competency_in(&ability.school);
        let chance = self.rules.fizzle_chance_bp(comp, ability.difficulty);
        if chance > 0 {
            let mut rng = self
                .root_rng
                .fork(&format!("actor{source}:fizzle:tick{}", self.tick));
            if rng.chance_bp(chance) {
                self.recorder.fizzle(self.tick, source, &ability.id);
                return;
            }
        }

        let eff_level = self.actors[source as usize]
            .effective_level(&ability.school, self.rules.competency_level_per_point_bp);

        for (order, effect) in ability.effects.iter().enumerate() {
            let chosen = self.select_targets(source, ability, effect, targets);
            for target in chosen {
                self.route_effect(source, eff_level, target, ability, effect, order as u64);
            }
        }
    }

    /// Pick targets for one effect by valence, honoring the controller's request
    /// and topping up to `ability.targets` from the appropriate side.
    fn select_targets(
        &self,
        source: ActorId,
        ability: &Ability,
        effect: &EffectSpec,
        requested: &[ActorId],
    ) -> Vec<ActorId> {
        let want = ability.targets.max(1) as usize;
        let me = &self.actors[source as usize];
        let mut out: Vec<ActorId> = Vec::new();

        match effect.valence() {
            Valence::Friendly => {
                // Self first, then requested allies, then nearest allies.
                let push = |id: ActorId, out: &mut Vec<ActorId>| {
                    if out.len() < want && !out.contains(&id) {
                        out.push(id);
                    }
                };
                // Honor explicit friendly requests (e.g. heal an ally), else self.
                for &r in requested {
                    if let Some(t) = self.actors.get(r as usize) {
                        if t.team == me.team && t.is_alive() {
                            push(r, &mut out);
                        }
                    }
                }
                push(source, &mut out);
                for a in &self.actors {
                    if a.team == me.team && a.is_alive() {
                        push(a.id, &mut out);
                    }
                }
            }
            Valence::Hostile => {
                let push = |id: ActorId, out: &mut Vec<ActorId>| {
                    if out.len() < want && !out.contains(&id) {
                        out.push(id);
                    }
                };
                for &r in requested {
                    if let Some(t) = self.actors.get(r as usize) {
                        if t.team != me.team && t.is_active() {
                            push(r, &mut out);
                        }
                    }
                }
                // Top up with the most-vulnerable enemies (lowest cap).
                let mut enemies: Vec<&Actor> = self
                    .actors
                    .iter()
                    .filter(|a| a.team != me.team && a.is_active())
                    .collect();
                enemies.sort_by_key(|a| (a.cap(), a.vigor, a.id));
                for a in enemies {
                    push(a.id, &mut out);
                }
            }
        }
        out
    }

    /// Apply a status immediately or queue an instant magnitude.
    fn route_effect(
        &mut self,
        source: ActorId,
        source_eff_level: i32,
        target: ActorId,
        ability: &Ability,
        effect: &EffectSpec,
        order: u64,
    ) {
        let potency = ability.potency_bp;
        let tmax = self.actors[target as usize].max_vigor;
        let scaled = |bp: i64| apply_bp(tmax, apply_bp(bp, potency));
        let _ = order;

        match effect.kind {
            EffectKind::FillDamage { magnitude_bp } => {
                let payload = HitPayload::Fill { pre_mitigation: scaled(magnitude_bp) };
                let seq = self.next_seq();
                self.queue(PendingHit {
                    source,
                    source_eff_level,
                    target,
                    ability_id: ability.id.clone(),
                    seq,
                    payload,
                });
            }
            EffectKind::CeilingDamage { magnitude_bp } => {
                let payload = HitPayload::Ceiling { magnitude: scaled(magnitude_bp) };
                let seq = self.next_seq();
                self.queue(PendingHit {
                    source,
                    source_eff_level,
                    target,
                    ability_id: ability.id.clone(),
                    seq,
                    payload,
                });
            }
            EffectKind::Recovery { magnitude_bp } => {
                let payload = HitPayload::Recovery { magnitude: scaled(magnitude_bp) };
                let seq = self.next_seq();
                self.queue(PendingHit {
                    source,
                    source_eff_level,
                    target,
                    ability_id: ability.id.clone(),
                    seq,
                    payload,
                });
            }
            EffectKind::Hot { per_tick_bp, duration: 0 } => {
                let payload = HitPayload::Heal { magnitude: scaled(per_tick_bp) };
                let seq = self.next_seq();
                self.queue(PendingHit {
                    source,
                    source_eff_level,
                    target,
                    ability_id: ability.id.clone(),
                    seq,
                    payload,
                });
            }
            // --- timed statuses: install now, tick later ---
            EffectKind::Dot { per_tick_bp, duration } => self.install(
                target,
                StatusKind::Dot { per_tick_bp: apply_bp(per_tick_bp, potency), source_eff_level },
                duration,
                source,
            ),
            EffectKind::Hot { per_tick_bp, duration } => self.install(
                target,
                StatusKind::Hot { per_tick_bp: apply_bp(per_tick_bp, potency) },
                duration,
                source,
            ),
            EffectKind::RegenSabotage { reduce_bp, duration } => {
                self.install(target, StatusKind::RegenSabotage { reduce_bp }, duration, source)
            }
            EffectKind::FatigueAmp { amp_bp, duration } => {
                self.install(target, StatusKind::FatigueAmp { amp_bp }, duration, source)
            }
            EffectKind::MitigationBuff { stage, amount_bp, duration } => self.install(
                target,
                StatusKind::Mitigation { stage, amount_bp: apply_bp(amount_bp, potency) },
                duration,
                source,
            ),
            EffectKind::MitigationDebuff { stage, amount_bp, duration } => self.install(
                target,
                // A debuff strips mitigation → stored negative so the pipeline
                // amplifies the hit (a vulnerability/sunder).
                StatusKind::Mitigation { stage, amount_bp: -apply_bp(amount_bp, potency) },
                duration,
                source,
            ),
        }
    }

    fn install(&mut self, target: ActorId, kind: StatusKind, duration: u32, source: ActorId) {
        if duration == 0 {
            return;
        }
        let expires_at = self.tick + duration as u64 - 1;
        self.actors[target as usize].add_status(Status { kind, expires_at, source });
    }

    fn queue(&mut self, hit: PendingHit) {
        self.pending.push(hit);
    }

    fn next_seq(&mut self) -> u64 {
        self.seq += 1;
        self.seq
    }

    /// Apply deterministic ± damage variance to a magnitude. Returns the input
    /// unchanged when variance is disabled. Keyed by `(source, target, seq, tick)`
    /// so it never reshuffles another hit's stream (§10).
    fn varied(&self, amount: Milli, source: ActorId, target: ActorId, seq: u64) -> Milli {
        let var = self.rules.damage_variance_bp;
        if var <= 0 {
            return amount;
        }
        let mut rng = self.root_rng.fork(&format!(
            "hit:src{source}:tgt{target}:seq{seq}:tick{}",
            self.tick
        ));
        // Uniform factor in [BP_ONE - var, BP_ONE + var].
        let factor = (crate::fixed::BP_ONE - var) + rng.range(0, (2 * var + 1) as i32) as i64;
        apply_bp(amount, factor)
    }

    // --- step 3 ------------------------------------------------------------

    fn apply_hits(&mut self) {
        // Sequential in ascending source-id order, stable within a source: hit 1
        // lowers fill before hit 2's stagger check (§10).
        let mut pending = std::mem::take(&mut self.pending);
        pending.sort_by_key(|h| (h.source, h.seq));
        for h in pending {
            self.apply_one(h);
        }
    }

    fn apply_one(&mut self, h: PendingHit) {
        let tidx = h.target as usize;
        if self.actors[tidx].stagger.is_dead() {
            return;
        }
        match h.payload {
            HitPayload::Fill { pre_mitigation } => {
                // Per-hit damage variance (deterministic, keyed by the hit). The
                // genre's damage range; also what desyncs a mirror match.
                let pre_mitigation = self.varied(pre_mitigation, h.source, h.target, h.seq);
                let con = self
                    .rules
                    .con_mult_bp(h.source_eff_level, self.actors[tidx].level);
                let inp = MitigationInputs {
                    armor_reduce_bp: self.actors[tidx].armor_bp,
                    con_mult_bp: con,
                    active_defense_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::ActiveDefense),
                    stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
                    buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
                };
                let mit = mitigation::run(pre_mitigation, inp);

                // Capture cast-in-progress to detect interruption.
                let was_casting = self.actors[tidx]
                    .casting
                    .as_ref()
                    .map(|c| c.ability_id.clone());

                let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
                self.recorder
                    .hit(self.tick, h.source, &h.ability_id, &self.actors[tidx], mit, outcome);

                if matches!(outcome, HitOutcome::Dazed | HitOutcome::Unconscious | HitOutcome::Killed) {
                    if let Some(cast_id) = was_casting {
                        if self.actors[tidx].casting.is_none() {
                            self.recorder.interrupted(self.tick, h.target, &cast_id);
                        }
                    }
                }
                if matches!(outcome, HitOutcome::Killed) {
                    self.recorder.death(self.tick, h.target);
                }
            }
            HitPayload::Ceiling { magnitude } => {
                // Con effectiveness gates how much of the curse lands (§7).
                let con = self
                    .rules
                    .con_mult_bp(h.source_eff_level, self.actors[tidx].level);
                let landed = apply_bp(magnitude, con);
                let before_down = !self.actors[tidx].is_active();
                self.actors[tidx].add_fatigue(landed);
                self.recorder
                    .ceiling_damage(self.tick, h.source, h.target, &h.ability_id, landed);
                if !before_down && !self.actors[tidx].is_active() {
                    // Collapse from the curse counts as a death-adjacent event only
                    // if it actually killed; unconscious is logged via state.
                    if self.actors[tidx].stagger.is_dead() {
                        self.recorder.death(self.tick, h.target);
                    }
                }
            }
            HitPayload::Recovery { magnitude } => {
                let was_down = !self.actors[tidx].is_active();
                self.actors[tidx]
                    .recover_fatigue(magnitude, self.tick, self.rules.revive_daze);
                let revived = was_down && self.actors[tidx].is_active();
                self.recorder
                    .recovery(self.tick, h.source, h.target, &h.ability_id, magnitude, revived);
            }
            HitPayload::Heal { magnitude } => {
                let was_down = !self.actors[tidx].is_active();
                self.actors[tidx]
                    .restore_fill(magnitude, self.tick, self.rules.revive_daze);
                let revived = was_down && self.actors[tidx].is_active();
                self.recorder.fill_restored(h.target, magnitude);
                if revived {
                    self.recorder
                        .recovery(self.tick, h.source, h.target, &h.ability_id, 0, true);
                }
            }
        }
    }

    // --- step 4 ------------------------------------------------------------

    fn tick_over_time(&mut self) {
        let n = self.actors.len();
        for tidx in 0..n {
            if self.actors[tidx].stagger.is_dead() {
                continue;
            }
            // Snapshot the over-time statuses to apply (avoid borrow conflicts).
            let tmax = self.actors[tidx].max_vigor;
            let tlevel = self.actors[tidx].level;
            let mut dots: Vec<(Milli, i32, ActorId)> = Vec::new();
            let mut hots: Vec<Milli> = Vec::new();
            for s in &self.actors[tidx].statuses {
                match s.kind {
                    StatusKind::Dot { per_tick_bp, source_eff_level } => {
                        dots.push((apply_bp(tmax, per_tick_bp), source_eff_level, s.source));
                    }
                    StatusKind::Hot { per_tick_bp } => {
                        hots.push(apply_bp(tmax, per_tick_bp));
                    }
                    _ => {}
                }
            }
            for (pre, src_level, src) in dots {
                let con = self.rules.con_mult_bp(src_level, tlevel);
                let inp = MitigationInputs {
                    armor_reduce_bp: self.actors[tidx].armor_bp,
                    con_mult_bp: con,
                    active_defense_reduce_bp: 0, // a poison isn't parried
                    stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
                    buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
                };
                let mit = mitigation::run(pre, inp);
                let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
                self.recorder
                    .hit(self.tick, src, "dot", &self.actors[tidx], mit, outcome);
                if matches!(outcome, HitOutcome::Killed) {
                    self.recorder.death(self.tick, self.actors[tidx].id);
                }
            }
            for amount in hots {
                self.actors[tidx]
                    .restore_fill(amount, self.tick, self.rules.revive_daze);
                self.recorder.fill_restored(self.actors[tidx].id, amount);
            }
        }
    }

    // --- step 5 ------------------------------------------------------------

    fn apply_regen(&mut self) {
        for a in &mut self.actors {
            let before = a.vigor;
            a.regen_tick(true, &self.rules.regen);
            let gained = a.vigor - before;
            if gained > 0 {
                self.recorder.regen(a.id, gained);
            }
        }
    }

    fn sample(&mut self) {
        let tick = self.tick;
        // Snapshot ids first to avoid borrowing actors while recorder mutates.
        for i in 0..self.actors.len() {
            let snapshot = self.actors[i].clone();
            self.recorder.sample(tick, &snapshot);
        }
    }

    // --- resolution --------------------------------------------------------

    /// `Some(outcome)` once the fight is decided: one team holds the only
    /// conscious actor(s) (win), or none are conscious (draw).
    fn resolution(&self) -> Option<Outcome> {
        let mut teams_up: Vec<u8> = Vec::new();
        for a in &self.actors {
            if a.is_active() && !teams_up.contains(&a.team) {
                teams_up.push(a.team);
            }
        }
        match teams_up.len() {
            0 => Some(Outcome::Draw),
            1 => Some(Outcome::Win { team: teams_up[0] }),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ability::{AbilitySpec, Delivery, RawCost};
    use crate::actor::{compile_actor, StatBlock};
    use crate::config::CombatConfig;
    use crate::effect::EffectSpec;
    use std::collections::BTreeMap;

    fn swing_spec() -> AbilitySpec {
        AbilitySpec {
            id: "swing".into(),
            name: "Swing".into(),
            school: "martial".into(),
            delivery: Delivery::Melee,
            effects: vec![EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1_000 }, 0)],
            potency_bp: 10_000,
            targets: 1,
            cast_time: 0,
            cooldown: 10,
            cost_override: Some(RawCost { fill: 0, fatigue: 1 }),
            fatigue_units: None,
            difficulty: None,
        }
    }

    fn fighter(name: &str, team: u8, level: i32) -> StatBlock {
        StatBlock {
            name: name.into(),
            team,
            level,
            max_vigor: 100,
            regen_idle_per_sec: 10,
            regen_combat_per_sec: 5,
            armor_bp: 0,
            competency: BTreeMap::new(),
            loadout: vec!["swing".into()],
            start_vigor_bp: 10_000,
            start_fatigue_bp: 0,
        }
    }

    fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> (Vec<Actor>, Rules) {
        let rules = cfg.compile();
        let actors = blocks
            .iter()
            .enumerate()
            .map(|(i, sb)| {
                let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
                assert!(missing.is_empty(), "missing abilities: {missing:?}");
                compile_actor(i as ActorId, sb, loadout, rules.tick_rate)
            })
            .collect();
        (actors, rules)
    }

    #[test]
    fn deterministic_even_fight_resolves() {
        let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
        let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 10)];
        let (actors, rules) = build(&cfg, &blocks);

        let controllers: Vec<Box<dyn Controller>> = vec![
            Box::new(crate::controller::SwingOnCooldown),
            Box::new(crate::controller::SwingOnCooldown),
        ];
        let enc = Encounter::new(actors, controllers, rules, 42, EncounterOptions::default());
        let m = enc.run();
        // Two identical swingers: it ends (someone goes down) and doesn't time out.
        assert!(matches!(m.outcome, Outcome::Win { .. } | Outcome::Draw));
        assert!(m.ticks < 6_000);
    }

    #[test]
    fn same_seed_same_outcome() {
        let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
        let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 12)];

        let run_once = |seed: u64| {
            let (actors, rules) = build(&cfg, &blocks);
            let controllers: Vec<Box<dyn Controller>> = vec![
                Box::new(crate::controller::SwingOnCooldown),
                Box::new(crate::controller::SwingOnCooldown),
            ];
            Encounter::new(actors, controllers, rules, seed, EncounterOptions::default()).run()
        };
        let a = run_once(7);
        let b = run_once(7);
        assert_eq!(a.ticks, b.ticks);
        assert_eq!(format!("{:?}", a.outcome), format!("{:?}", b.outcome));
        assert_eq!(a.actors[0].fill_damage_dealt, b.actors[0].fill_damage_dealt);
    }

    #[test]
    fn higher_con_attacker_wins() {
        let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
        // +5 levels of con advantage should win reliably.
        let blocks = vec![fighter("Strong", 0, 15), fighter("Weak", 1, 10)];
        let (actors, rules) = build(&cfg, &blocks);
        let controllers: Vec<Box<dyn Controller>> = vec![
            Box::new(crate::controller::SwingOnCooldown),
            Box::new(crate::controller::SwingOnCooldown),
        ];
        let m = Encounter::new(actors, controllers, rules, 1, EncounterOptions::default()).run();
        assert_eq!(m.outcome, Outcome::Win { team: 0 });
    }
}
