//! The [`Actor`] (spec §5): every combatant — player or monster — is the *same*
//! struct with the *same* combined `Vigor` pool, stagger cascade, and ability
//! resolution. Variety is two independent dials: a [`StatBlock`] (archetype) and
//! a controller (intent, see `controller.rs`). The Actor never knows who drives
//! it.
//!
//! ## The combined pool (spec §3)
//! - `vigor` — current fill. **Also the absorb capacity** the stagger cascade
//!   compares hits against (§4).
//! - `fatigue` — accumulated, fight-scoped ceiling loss. Every point that *leaves*
//!   the pool (spend or damage) adds some. Recovers only out of combat or via
//!   recovery effects.
//! - `cap = max_vigor − fatigue` — the highest fill can regen to right now. When
//!   it hits zero, the actor has spent its whole life total and collapses.
//!
//! This module owns the pure state mutations (pay, take a hit, regen, collapse).
//! The engine (`engine.rs`) orchestrates *when* they happen; mitigation is
//! computed there and the post-mitigation magnitude handed to [`Actor::take_hit`].

use crate::ability::{Ability, AbilityCost};
use crate::fixed::{apply_bp, clamp, mul_div_round, units, Milli, BP_ONE, UNIT};
use std::collections::BTreeMap;

pub type ActorId = u32;

/// Where an actor sits on the stagger cascade (spec §4). `Dazed` and
/// `Unconscious` are reached by a hit overrunning capacity, or by the ceiling
/// collapsing to zero (§3).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StaggerState {
    /// Acting normally.
    Normal,
    /// Knocked down until `until` (inclusive tick). Regen reduced; cannot act.
    Dazed { until: u64 },
    /// Regen zeroed; cannot act. Revivable only by a friendly recovery; otherwise
    /// follow-up hits grind to 0 → `Dead`.
    Unconscious,
    /// Terminal.
    Dead,
}

impl StaggerState {
    pub fn can_act(&self) -> bool {
        matches!(self, StaggerState::Normal)
    }
    pub fn is_down(&self) -> bool {
        matches!(self, StaggerState::Unconscious | StaggerState::Dead)
    }
    pub fn is_dead(&self) -> bool {
        matches!(self, StaggerState::Dead)
    }
}

/// What a single hit did to the target — returned by [`Actor::take_hit`] so the
/// engine can log a precise event and detect interruptions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitOutcome {
    Absorbed,
    Dazed,
    Unconscious,
    /// The hit (or the grind that followed an earlier one) reached `Dead`.
    Killed,
}

/// A timed status riding on an actor. `Dazed`/`Unconscious` live in
/// [`StaggerState`]; these are the effect-driven ones.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Status {
    pub kind: StatusKind,
    /// Inclusive last tick this status applies. Expired when `tick > expires_at`.
    pub expires_at: u64,
    pub source: ActorId,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusKind {
    /// Reduce regen by `reduce_bp` (§6.1 regen sabotage).
    RegenSabotage { reduce_bp: i64 },
    /// Multiply fatigue-per-spend/per-hit by `(1 + amp_bp)` (§6.1).
    FatigueAmp { amp_bp: i64 },
    /// Fill drain per tick, bp of `max_vigor`, with the source's effective level
    /// for con at tick time.
    Dot { per_tick_bp: i64, source_eff_level: i32 },
    /// Fill restore per tick, bp of `max_vigor`.
    Hot { per_tick_bp: i64 },
    /// A mitigation contribution at `stage` (block windows, stances, buffs,
    /// vulnerability debuffs). `amount_bp` is "fraction removed"; negative
    /// amplifies (a sunder).
    Mitigation {
        stage: crate::mitigation::MitigationStage,
        amount_bp: i64,
    },
}

/// An in-progress spell cast (spec §6 cast time). Cost is already paid; if the
/// caster is interrupted before `completes_at`, it is lost (paid for nothing).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cast {
    pub ability_id: String,
    pub targets: Vec<ActorId>,
    pub completes_at: u64,
}

/// Authored archetype (spec §5, config). Compiled into an [`Actor`] at fight
/// setup. All magnitudes are **displayed units**; rates are **units per second**
/// (converted to per-tick milli at compile, given the tick rate).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StatBlock {
    pub name: String,
    pub team: u8,
    pub level: i32,
    pub max_vigor: i64,
    pub regen_idle_per_sec: i64,
    pub regen_combat_per_sec: i64,
    #[serde(default)]
    pub armor_bp: i64,
    /// Per-school competency (§9). Missing schools default to 0.
    #[serde(default)]
    pub competency: BTreeMap<String, i64>,
    /// Ability ids to slot for this fight (memorization, §9). Resolved against
    /// the config ability library.
    pub loadout: Vec<String>,
    /// Starting fill as bp of `max_vigor` (default full).
    #[serde(default = "default_start_bp")]
    pub start_vigor_bp: i64,
    /// Starting fatigue as bp of `max_vigor` (default 0).
    #[serde(default)]
    pub start_fatigue_bp: i64,
}

fn default_start_bp() -> i64 {
    10_000
}

/// The compiled, mutable combatant.
#[derive(Clone, Debug)]
pub struct Actor {
    pub id: ActorId,
    pub name: String,
    pub team: u8,
    pub level: i32,

    pub max_vigor: Milli,
    pub vigor: Milli,
    pub fatigue: Milli,

    /// Per-tick regen in milli-units, idle and in-combat.
    pub regen_idle: Milli,
    pub regen_combat: Milli,

    pub armor_bp: i64,
    pub competency: BTreeMap<String, i64>,

    /// The slotted abilities usable this fight (memorization commit).
    pub loadout: Vec<Ability>,
    /// ability id → tick it becomes usable again.
    pub cooldowns: BTreeMap<String, u64>,

    pub statuses: Vec<Status>,
    pub casting: Option<Cast>,
    pub stagger: StaggerState,
    /// Earliest tick this actor may begin a new action (the global-cooldown /
    /// swing-timer gate, §10). `0` until the first action.
    pub next_action_at: u64,
}

impl Actor {
    /// Current ceiling: `max_vigor − fatigue`, clamped to `[0, max_vigor]`.
    #[inline]
    pub fn cap(&self) -> Milli {
        clamp(self.max_vigor - self.fatigue, 0, self.max_vigor)
    }

    /// Absorb capacity the stagger cascade compares against — current fill (§4).
    #[inline]
    pub fn capacity(&self) -> Milli {
        self.vigor
    }

    pub fn is_alive(&self) -> bool {
        !self.stagger.is_dead()
    }

    /// "In the fight": conscious enough to matter. A team loses when none of its
    /// actors satisfy this (spec §3/§4 — knocked out is out).
    pub fn is_active(&self) -> bool {
        !self.stagger.is_down()
    }

    pub fn can_act(&self) -> bool {
        self.stagger.can_act() && self.casting.is_none()
    }

    /// Effective level for *outgoing* effectiveness in `school`: base level plus
    /// competency folded in (spec §7/§9). `per_point_bp` is levels-per-competency-
    /// point in bp (config). Raw incoming-damage uses plain `level`, not this.
    pub fn effective_level(&self, school: &str, per_point_bp: i64) -> i32 {
        let comp = self.competency.get(school).copied().unwrap_or(0);
        let bonus = mul_div_round(comp, per_point_bp, BP_ONE);
        self.level + bonus as i32
    }

    pub fn competency_in(&self, school: &str) -> i64 {
        self.competency.get(school).copied().unwrap_or(0)
    }

    /// Can this actor afford to *begin* `cost`? Only the fill side gates starting
    /// (spec §6 affordability). Fatigue is paid regardless once committed.
    #[inline]
    pub fn can_afford(&self, cost: AbilityCost) -> bool {
        cost.fill <= self.vigor
    }

    pub fn ability(&self, id: &str) -> Option<&Ability> {
        self.loadout.iter().find(|a| a.id == id)
    }

    pub fn on_cooldown(&self, id: &str, tick: u64) -> bool {
        self.cooldowns.get(id).is_some_and(|&ready| tick < ready)
    }

    /// Pay an ability's absolute cost: spend fill, add the fatigue "mark" (§3),
    /// scaled by any `FatigueAmp` status. Re-clamps and may trigger collapse.
    pub fn pay(&mut self, cost: AbilityCost) {
        self.vigor -= cost.fill;
        let marked = self.amplified_fatigue(cost.fatigue);
        self.fatigue += marked;
        self.reconcile();
    }

    /// Apply a post-mitigation fill hit (spec §4 stagger cascade). Returns what
    /// it did. `landed` is already through the mitigation pipeline. Adds the
    /// damage-taken fatigue share (§3) and resolves the stagger band.
    pub fn take_hit(&mut self, landed: Milli, tick: u64, p: &StaggerParams) -> HitOutcome {
        if self.stagger.is_dead() {
            return HitOutcome::Killed;
        }
        let landed = landed.max(0);

        // A hit landing on an *already* unconscious actor is the follow-up that
        // grinds them out (spec §4): it kills. The hit that first knocks you
        // unconscious does not — that's the line between the two.
        let was_unconscious = matches!(self.stagger, StaggerState::Unconscious);

        // Fatigue share of every hit (the §3 "spend leaves a mark" for damage),
        // scaled by FatigueAmp. Applied before re-clamping so a hit erodes the
        // ceiling even if it's later floored.
        let fat = self.amplified_fatigue(apply_bp(landed, p.fatigue_ratio_bp));
        self.fatigue += fat;

        // Band the hit against capacity *before* subtracting (capacity = fill).
        let cap_now = self.capacity();
        let band = stagger_band(landed, cap_now, p);

        self.vigor -= landed;

        if was_unconscious {
            self.stagger = StaggerState::Dead;
            self.casting = None;
            self.reconcile();
            return HitOutcome::Killed;
        }

        let outcome = match band {
            Band::Absorbed => HitOutcome::Absorbed,
            Band::Dazed => {
                self.enter_dazed(tick, p.daze_duration);
                HitOutcome::Dazed
            }
            Band::Unconscious => {
                self.enter_unconscious();
                HitOutcome::Unconscious
            }
        };
        self.reconcile();
        outcome
    }

    /// Apply per-tick regen toward the cap (spec §3). Zero while unconscious;
    /// reduced (never below a non-zero floor) while dazed or regen-sabotaged.
    pub fn regen_tick(&mut self, in_combat: bool, p: &RegenParams) {
        if self.stagger.is_down() {
            return; // unconscious/dead: no regen
        }
        let base = if in_combat { self.regen_combat } else { self.regen_idle };
        let mut rate = base;

        if matches!(self.stagger, StaggerState::Dazed { .. }) {
            rate = apply_bp(rate, p.dazed_regen_bp);
        }
        for s in &self.statuses {
            if let StatusKind::RegenSabotage { reduce_bp } = s.kind {
                rate = apply_bp(rate, (BP_ONE - reduce_bp).max(0));
            }
        }
        // Non-zero floor so an actor can always claw back toward affording an
        // action — no soft-lock (spec §3/§6).
        rate = rate.max(p.regen_floor);

        self.vigor = clamp(self.vigor + rate, 0, self.cap());
    }

    /// Reduce fatigue (raise the ceiling) by `amount` milli — a recovery effect
    /// (§6.1). Revives an unconscious (not dead) actor to a brief daze if it
    /// brings the cap back above zero, the clutch-save path (§12 near-death).
    pub fn recover_fatigue(&mut self, amount: Milli, tick: u64, revive_daze: u32) {
        if self.stagger.is_dead() {
            return;
        }
        self.fatigue = (self.fatigue - amount).max(0);
        if matches!(self.stagger, StaggerState::Unconscious) && self.cap() > 0 {
            // Wake to a daze; the fight isn't over for them yet. Set directly —
            // `enter_dazed` intentionally refuses to downgrade an unconscious
            // actor, but a recovery is exactly the sanctioned way back.
            self.revive_to_daze(tick, revive_daze);
        }
        self.reconcile();
    }

    /// Restore fill (a heal / HoT tick), clamped to the cap. Can also revive an
    /// unconscious actor whose cap is intact.
    pub fn restore_fill(&mut self, amount: Milli, tick: u64, revive_daze: u32) {
        if self.stagger.is_dead() {
            return;
        }
        self.vigor = clamp(self.vigor + amount, 0, self.cap());
        if matches!(self.stagger, StaggerState::Unconscious) && self.vigor > 0 {
            self.revive_to_daze(tick, revive_daze);
        }
    }

    /// Add direct fatigue (a `CeilingDamage` curse — attacks the real life total,
    /// §6.1). May collapse the ceiling.
    pub fn add_fatigue(&mut self, amount: Milli) {
        self.fatigue += self.amplified_fatigue(amount);
        self.reconcile();
    }

    pub fn add_status(&mut self, status: Status) {
        self.statuses.push(status);
    }

    /// Drop expired statuses (spec §10 step 1). A status covering tick `t` is
    /// inclusive of effects resolving on `t`, so it survives until `tick > expires_at`.
    pub fn expire_statuses(&mut self, tick: u64) {
        self.statuses.retain(|s| tick <= s.expires_at);
    }

    /// Clear a dazed state that has elapsed (spec §10 step 1).
    pub fn expire_daze(&mut self, tick: u64) {
        if let StaggerState::Dazed { until } = self.stagger {
            if tick > until {
                self.stagger = StaggerState::Normal;
            }
        }
    }

    /// Sum of mitigation contributions at `stage` from active statuses (block
    /// windows, stances, buffs, debuffs). Caller adds armor/con separately.
    pub fn mitigation_at(&self, stage: crate::mitigation::MitigationStage) -> i64 {
        self.statuses
            .iter()
            .filter_map(|s| match s.kind {
                StatusKind::Mitigation { stage: st, amount_bp } if st == stage => Some(amount_bp),
                _ => None,
            })
            .sum()
    }

    // --- internals ---------------------------------------------------------

    /// Scale a fatigue amount by any active `FatigueAmp` statuses (§6.1).
    fn amplified_fatigue(&self, base: Milli) -> Milli {
        let amp: i64 = self
            .statuses
            .iter()
            .filter_map(|s| match s.kind {
                StatusKind::FatigueAmp { amp_bp } => Some(amp_bp),
                _ => None,
            })
            .sum();
        if amp == 0 {
            base
        } else {
            apply_bp(base, BP_ONE + amp)
        }
    }

    fn enter_dazed(&mut self, tick: u64, duration: u32) {
        // A fresh daze interrupts any cast (paid for nothing, §6) and refreshes
        // the timer; never downgrades an unconscious actor.
        if matches!(self.stagger, StaggerState::Unconscious | StaggerState::Dead) {
            return;
        }
        self.stagger = StaggerState::Dazed { until: tick + duration as u64 };
        self.casting = None;
    }

    fn enter_unconscious(&mut self) {
        if !matches!(self.stagger, StaggerState::Dead) {
            self.stagger = StaggerState::Unconscious;
            self.casting = None;
        }
    }

    /// Revive an unconscious (not dead) actor to a brief daze. The one sanctioned
    /// way to upgrade *out* of unconsciousness (recovery / heal), bypassing the
    /// `enter_dazed` downgrade guard.
    fn revive_to_daze(&mut self, tick: u64, duration: u32) {
        if matches!(self.stagger, StaggerState::Unconscious) {
            self.stagger = StaggerState::Dazed { until: tick + duration as u64 };
        }
    }

    /// Re-clamp fatigue/vigor and apply ceiling collapse (§3/§4): if the cap has
    /// reached zero, the actor self-overruns to unconscious.
    fn reconcile(&mut self) {
        self.fatigue = clamp(self.fatigue, 0, self.max_vigor);
        let cap = self.cap();
        self.vigor = clamp(self.vigor, 0, cap);
        if cap == 0 {
            self.enter_unconscious();
        }
    }
}

/// Tunables for the stagger cascade (from config).
#[derive(Clone, Copy, Debug)]
pub struct StaggerParams {
    /// `hit/capacity` above this (bp) is at least a daze. `10_000` == 1.0×.
    pub daze_threshold_bp: i64,
    /// `hit/capacity` at/above this (bp) is unconscious. `15_000` == 1.5×.
    pub unconscious_threshold_bp: i64,
    pub daze_duration: u32,
    /// Fatigue share of every hit taken (§3), bp.
    pub fatigue_ratio_bp: i64,
}

/// Tunables for regen (from config).
#[derive(Clone, Copy, Debug)]
pub struct RegenParams {
    pub dazed_regen_bp: i64,
    pub regen_floor: Milli,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Band {
    Absorbed,
    Dazed,
    Unconscious,
}

/// Which stagger band a `landed` hit falls into against `capacity`, using
/// integer cross-multiplication (no division, no f64). At `capacity == 0` any
/// positive hit is an overrun → unconscious (the cap-collapse death path, §4).
fn stagger_band(landed: Milli, capacity: Milli, p: &StaggerParams) -> Band {
    if landed <= 0 {
        return Band::Absorbed;
    }
    let lhs = landed as i128 * BP_ONE as i128;
    let daze_rhs = capacity as i128 * p.daze_threshold_bp as i128;
    let unc_rhs = capacity as i128 * p.unconscious_threshold_bp as i128;
    if lhs <= daze_rhs {
        Band::Absorbed
    } else if lhs < unc_rhs {
        Band::Dazed
    } else {
        Band::Unconscious
    }
}

/// Build a runtime [`Actor`] from a [`StatBlock`], its resolved loadout, and the
/// tick rate (for unit/sec → milli/tick regen conversion).
pub fn compile_actor(id: ActorId, sb: &StatBlock, loadout: Vec<Ability>, tick_rate: u32) -> Actor {
    let max_vigor = units(sb.max_vigor);
    let to_per_tick = |per_sec: i64| -> Milli {
        // per_sec units → milli per tick = per_sec * 1000 / tick_rate.
        mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64)
    };
    let fatigue = apply_bp(max_vigor, sb.start_fatigue_bp);
    let cap = clamp(max_vigor - fatigue, 0, max_vigor);
    let vigor = clamp(apply_bp(max_vigor, sb.start_vigor_bp), 0, cap);
    Actor {
        id,
        name: sb.name.clone(),
        team: sb.team,
        level: sb.level,
        max_vigor,
        vigor,
        fatigue,
        regen_idle: to_per_tick(sb.regen_idle_per_sec),
        regen_combat: to_per_tick(sb.regen_combat_per_sec),
        armor_bp: sb.armor_bp,
        competency: sb.competency.clone(),
        loadout,
        cooldowns: BTreeMap::new(),
        statuses: Vec::new(),
        casting: None,
        stagger: StaggerState::Normal,
        next_action_at: 0,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn params() -> StaggerParams {
        StaggerParams {
            daze_threshold_bp: 10_000,
            unconscious_threshold_bp: 15_000,
            daze_duration: 5,
            fatigue_ratio_bp: 2_000,
        }
    }

    fn dummy(max_units: i64) -> Actor {
        let sb = StatBlock {
            name: "T".into(),
            team: 0,
            level: 10,
            max_vigor: max_units,
            regen_idle_per_sec: 10,
            regen_combat_per_sec: 5,
            armor_bp: 0,
            competency: BTreeMap::new(),
            loadout: vec![],
            start_vigor_bp: 10_000,
            start_fatigue_bp: 0,
        };
        compile_actor(1, &sb, vec![], 10)
    }

    #[test]
    fn fresh_actor_is_full() {
        let a = dummy(100);
        assert_eq!(a.vigor, units(100));
        assert_eq!(a.fatigue, 0);
        assert_eq!(a.cap(), units(100));
        // 5 units/sec at 10 tps -> 500 milli/tick.
        assert_eq!(a.regen_combat, 500);
    }

    #[test]
    fn small_hit_is_absorbed_and_marks_fatigue() {
        let mut a = dummy(100);
        let out = a.take_hit(units(10), 0, &params());
        assert_eq!(out, HitOutcome::Absorbed);
        assert_eq!(a.vigor, units(90));
        // 20% of 10 units = 2 units fatigue.
        assert_eq!(a.fatigue, units(2));
        assert_eq!(a.cap(), units(98));
    }

    #[test]
    fn overrun_between_1x_and_1_5x_dazes() {
        let mut a = dummy(100);
        a.vigor = units(10); // capacity 10
        let out = a.take_hit(units(12), 3, &params()); // 1.2x capacity
        assert_eq!(out, HitOutcome::Dazed);
        assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 }));
    }

    #[test]
    fn overrun_past_1_5x_knocks_unconscious() {
        let mut a = dummy(100);
        a.vigor = units(10);
        let out = a.take_hit(units(20), 0, &params()); // 2.0x capacity
        assert_eq!(out, HitOutcome::Unconscious);
        assert!(matches!(a.stagger, StaggerState::Unconscious));
    }

    #[test]
    fn absorbed_to_exactly_zero_is_not_death() {
        let mut a = dummy(100);
        a.vigor = units(10);
        let out = a.take_hit(units(10), 0, &params()); // hit == capacity
        assert_eq!(out, HitOutcome::Absorbed);
        assert_eq!(a.vigor, 0);
        assert!(a.is_alive());
        assert!(a.is_active());
    }

    #[test]
    fn unconscious_then_follow_up_hit_dies() {
        let mut a = dummy(100);
        a.vigor = units(2);
        // First hit (5x capacity) knocks unconscious but does NOT kill (§4).
        let first = a.take_hit(units(10), 0, &params());
        assert_eq!(first, HitOutcome::Unconscious);
        assert!(matches!(a.stagger, StaggerState::Unconscious));
        assert!(a.is_alive());
        // Any follow-up hit while down grinds them out → dead.
        let second = a.take_hit(units(1), 1, &params());
        assert_eq!(second, HitOutcome::Killed);
        assert!(a.stagger.is_dead());
    }

    #[test]
    fn ceiling_collapse_knocks_unconscious() {
        let mut a = dummy(100);
        a.add_fatigue(units(100)); // cap -> 0
        assert_eq!(a.cap(), 0);
        assert!(matches!(a.stagger, StaggerState::Unconscious));
        assert!(!a.is_active());
    }

    #[test]
    fn recovery_revives_unconscious_to_daze() {
        let mut a = dummy(100);
        a.add_fatigue(units(100)); // collapse
        assert!(matches!(a.stagger, StaggerState::Unconscious));
        a.recover_fatigue(units(40), 5, 3); // recovered 40 fatigue -> cap 40
        assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 }));
        assert_eq!(a.cap(), units(40));
    }

    #[test]
    fn regen_floor_prevents_softlock() {
        let mut a = dummy(100);
        a.vigor = 0;
        a.add_status(Status {
            kind: StatusKind::RegenSabotage { reduce_bp: 10_000 }, // 100% sabotage
            expires_at: 100,
            source: 2,
        });
        let rp = RegenParams { dazed_regen_bp: 5_000, regen_floor: 50 };
        a.regen_tick(true, &rp);
        assert!(a.vigor >= 50, "regen floor must keep the actor un-stuck");
    }
}
