//! Serialization of the run for the browser: one JSON payload carrying
//! everything the front-end renders, with per-call event streams drained into
//! it. The combat views mirror combat-web's shapes so that UI carries over.

use crate::manifest::DIRS;
use crate::run::{GameEvent, GameRun, RunStats};
use combat_core::ability::Ability;
use combat_core::actor::{Actor, StaggerState};
use combat_core::effect::EffectKind;
use combat_core::event::Event;
use serde::Serialize;

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

// --------------------------------------------------------------------- //
// Combat views (combat-web's contract).
// --------------------------------------------------------------------- //

#[derive(Serialize)]
struct CastView {
    ability: String,
    remaining: u64,
}

#[derive(Serialize)]
struct CooldownView {
    id: String,
    remaining: u64,
}

#[derive(Serialize)]
struct ActorView {
    id: u32,
    name: String,
    archetype: Option<String>,
    team: u8,
    level: i32,
    vigor: f64,
    cap: f64,
    fatigue: f64,
    max_vigor: f64,
    stagger: String,
    casting: Option<CastView>,
    statuses: Vec<String>,
    cooldowns: Vec<CooldownView>,
}

#[derive(Serialize)]
#[serde(tag = "t", rename_all = "snake_case")]
enum EvView {
    Hit {
        src: u32,
        tgt: u32,
        dmg: f64,
        outcome: String,
    },
    Ceiling {
        src: u32,
        tgt: u32,
        fatigue: f64,
    },
    Recovery {
        tgt: u32,
        heal: f64,
        revived: bool,
    },
    Cast {
        actor: u32,
        ability: String,
    },
    Fizzle {
        actor: u32,
        ability: String,
    },
    Interrupt {
        actor: u32,
        ability: String,
    },
    Death {
        actor: u32,
    },
}

#[derive(Serialize)]
struct CombatView {
    tick: u64,
    outcome: Option<String>,
    actors: Vec<ActorView>,
    events: Vec<EvView>,
}

#[derive(Serialize)]
pub struct AbilityView {
    id: String,
    name: String,
    school: String,
    fill_cost: f64,
    fatigue_cost: f64,
    cast_time: u32,
    cooldown: u32,
    kind: String,
    magnitude_pct: f64,
}

// --------------------------------------------------------------------- //
// Game state view.
// --------------------------------------------------------------------- //

#[derive(Serialize)]
struct PlayerView {
    archetype: String,
    name: String,
    level: i32,
    vigor: f64,
    cap: f64,
    fatigue: f64,
    max_vigor: f64,
    gold: i64,
    draughts: i32,
    tonics: i32,
    relics: i32,
    auto_attack: bool,
}

#[derive(Serialize)]
struct MonsterPeek {
    archetype: String,
    name: String,
    level: i32,
}

#[derive(Serialize)]
struct StateView {
    mode: &'static str,
    floor: usize,
    floors_total: usize,
    floor_seed: u64,
    room: usize,
    facing: &'static str,
    ahead_open: bool,
    exit_room: usize,
    at_exit: bool,
    visited: Vec<usize>,
    /// Rooms the player has seen that still hold live monsters (fled fights).
    known_monsters: Vec<usize>,
    /// Living monsters left on this floor (HUD flavor).
    monsters_left: usize,
    /// A live monster waits in an adjacent room — audio/UI dread hook.
    adjacent_danger: bool,
    player: PlayerView,
    room_monsters: Vec<MonsterPeek>,
    events: Vec<GameEvent>,
    stats: RunStats,
    combat: Option<CombatView>,
}

pub fn state_json(run: &mut GameRun) -> String {
    let events: Vec<GameEvent> = run.events.drain(..).collect();
    let combat_events: Vec<Event> = run.combat_events.drain(..).collect();

    let floor = run.floor();
    let by_id = floor.manifest.by_id();
    let here = by_id.get(&run.room);

    let ahead_open = here
        .map(|r| r.nav.get(run.facing).is_some())
        .unwrap_or(false);
    let adjacent_danger = here
        .map(|r| {
            DIRS.iter().any(|d| {
                r.nav
                    .get(*d)
                    .map(|n| floor.monsters.contains_key(&n))
                    .unwrap_or(false)
            })
        })
        .unwrap_or(false);

    let known_monsters: Vec<usize> = floor
        .monsters
        .keys()
        .filter(|id| floor.visited.contains(id))
        .copied()
        .collect();

    let room_monsters: Vec<MonsterPeek> = floor
        .monsters
        .get(&run.room)
        .map(|pack| {
            pack.iter()
                .map(|s| MonsterPeek {
                    archetype: s.archetype.clone(),
                    name: s.name.clone(),
                    level: s.level,
                })
                .collect()
        })
        .unwrap_or_default();

    let sb = &run.config.stat_blocks[&run.player.archetype];
    let max_vigor = run.player_max_vigor() as f64;
    let cap_bp = (10_000 - run.player.fatigue_bp).max(0);
    let player = PlayerView {
        archetype: run.player.archetype.clone(),
        name: sb.name.clone(),
        level: run.player_level(),
        vigor: max_vigor * run.player.vigor_bp as f64 / 10_000.0,
        cap: max_vigor * cap_bp as f64 / 10_000.0,
        fatigue: max_vigor * run.player.fatigue_bp as f64 / 10_000.0,
        max_vigor,
        gold: run.player.gold,
        draughts: run.player.draughts,
        tonics: run.player.tonics,
        relics: run.player.relics,
        auto_attack: run.player.auto_attack,
    };

    let combat = run.battle.as_ref().map(|b| CombatView {
        tick: b.enc.current_tick(),
        outcome: run.last_outcome.clone(),
        actors: b
            .enc
            .actors
            .iter()
            .map(|a| actor_view(a, b.enc.current_tick(), run))
            .collect(),
        events: combat_events.iter().filter_map(ev_view).collect(),
    });

    let view = StateView {
        mode: run.mode.label(),
        floor: run.floor_idx,
        floors_total: run.floors.len(),
        floor_seed: floor.manifest.seed,
        room: run.room,
        facing: run.facing.letter(),
        ahead_open,
        exit_room: floor.exit_room,
        at_exit: run.room == floor.exit_room,
        visited: floor.visited.iter().copied().collect(),
        known_monsters,
        monsters_left: floor.monsters.len(),
        adjacent_danger,
        player,
        room_monsters,
        events,
        stats: run.stats.clone(),
        combat,
    };

    // A finished battle has now rendered its final frame; release it so the
    // next payload is clean exploration state.
    run.settle();

    serde_json::to_string(&view).unwrap_or_else(|_| "{}".into())
}

/// The fight's actor → view, tagging enemies with their archetype so the
/// front-end can pick portraits.
fn actor_view(a: &Actor, tick: u64, run: &GameRun) -> ActorView {
    let stagger = match a.stagger {
        StaggerState::Normal => "normal",
        StaggerState::Dazed { .. } => "dazed",
        StaggerState::Unconscious => "unconscious",
        StaggerState::Dead => "dead",
    }
    .to_string();
    let casting = a.casting.as_ref().map(|c| CastView {
        ability: c.ability_id.clone(),
        remaining: c.completes_at.saturating_sub(tick),
    });
    let statuses = a
        .statuses
        .iter()
        .map(|s| status_label(&s.kind).to_string())
        .collect();
    let cooldowns = a
        .cooldowns
        .iter()
        .filter(|(_, &ready)| ready > tick)
        .map(|(id, &ready)| CooldownView {
            id: id.clone(),
            remaining: ready - tick,
        })
        .collect();

    // Actor 0 is the player; enemies are battle spawns in slot order.
    let archetype = if a.team == 0 {
        Some(run.player.archetype.clone())
    } else {
        run.battle.as_ref().and_then(|b| {
            run.floor()
                .monsters
                .get(&b.room)
                .and_then(|pack| pack.get((a.id as usize).saturating_sub(1)))
                .map(|s| s.archetype.clone())
        })
    };

    ActorView {
        id: a.id,
        name: a.name.clone(),
        archetype,
        team: a.team,
        level: a.level,
        vigor: to_units(a.vigor),
        cap: to_units(a.cap()),
        fatigue: to_units(a.fatigue),
        max_vigor: to_units(a.max_vigor),
        stagger,
        casting,
        statuses,
        cooldowns,
    }
}

fn status_label(kind: &combat_core::actor::StatusKind) -> &'static str {
    use combat_core::actor::StatusKind::*;
    match kind {
        RegenSabotage { .. } => "regen_sabotage",
        FatigueAmp { .. } => "fatigue_amp",
        Dot { .. } => "dot",
        Hot { .. } => "hot",
        Mitigation { .. } => "mitigation",
    }
}

fn ev_view(e: &Event) -> Option<EvView> {
    Some(match e {
        Event::Hit {
            source,
            target,
            landed,
            outcome,
            ..
        } => EvView::Hit {
            src: *source,
            tgt: *target,
            dmg: to_units(*landed),
            outcome: format!("{outcome:?}").to_lowercase(),
        },
        Event::Ceiling {
            source,
            target,
            fatigue_added,
            ..
        } => EvView::Ceiling {
            src: *source,
            tgt: *target,
            fatigue: to_units(*fatigue_added),
        },
        Event::Recovery {
            target,
            fatigue_healed,
            revived,
            ..
        } => EvView::Recovery {
            tgt: *target,
            heal: to_units(*fatigue_healed),
            revived: *revived,
        },
        Event::CastStarted { actor, ability, .. } => EvView::Cast {
            actor: *actor,
            ability: ability.clone(),
        },
        Event::Fail {
            actor,
            ability,
            reason,
            ..
        } => {
            use combat_core::event::FailReason::*;
            match reason {
                CompetencyFizzle => EvView::Fizzle {
                    actor: *actor,
                    ability: ability.clone(),
                },
                Interrupted => EvView::Interrupt {
                    actor: *actor,
                    ability: ability.clone(),
                },
            }
        }
        Event::Death { actor, .. } => EvView::Death { actor: *actor },
        _ => return None,
    })
}

/// The player's slotted abilities, for the action bar.
pub fn abilities_json(run: &GameRun) -> String {
    let sb = &run.config.stat_blocks[&run.player.archetype];
    let (loadout, _) = run.rules.resolve_loadout(&sb.loadout);
    let views: Vec<AbilityView> = loadout.iter().map(ability_view).collect();
    serde_json::to_string(&views).unwrap_or_else(|_| "[]".into())
}

fn ability_view(ab: &Ability) -> AbilityView {
    let kind = classify(ab).to_string();
    let magnitude_pct = ab
        .effects
        .iter()
        .filter_map(|e| match e.kind {
            EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp),
            EffectKind::CeilingDamage { magnitude_bp } => Some(magnitude_bp),
            EffectKind::Recovery { magnitude_bp } => Some(magnitude_bp),
            _ => None,
        })
        .map(|bp| bp as f64 / 100.0)
        .next()
        .unwrap_or(0.0);
    AbilityView {
        id: ab.id.clone(),
        name: ab.name.clone(),
        school: ab.school.clone(),
        fill_cost: to_units(ab.cost.fill),
        fatigue_cost: to_units(ab.cost.fatigue),
        cast_time: ab.cast_time,
        cooldown: ab.cooldown,
        kind,
        magnitude_pct,
    }
}

fn classify(ab: &Ability) -> &'static str {
    if ab.is_support() {
        if ab
            .effects
            .iter()
            .any(|e| matches!(e.kind, EffectKind::MitigationBuff { .. }))
        {
            return "defense";
        }
        return "recovery";
    }
    if ab
        .effects
        .iter()
        .any(|e| matches!(e.kind, EffectKind::CeilingDamage { .. }))
    {
        return "curse";
    }
    if ab.effects.iter().any(|e| {
        matches!(
            e.kind,
            EffectKind::RegenSabotage { .. } | EffectKind::FatigueAmp { .. }
        )
    }) {
        return "hex";
    }
    if ab
        .effects
        .iter()
        .any(|e| matches!(e.kind, EffectKind::Dot { .. }))
    {
        return "dot";
    }
    if ab.cost.fill == 0 {
        return "auto";
    }
    if ab.cast_time > 0 {
        return "spell";
    }
    "skill"
}
