//! Session: der geteilte Anwendungszustand über allen Frontends.
//!
//! Es existiert genau eine `Session` pro Lauf. CLI und Fenster steuern
//! dieselbe — das Fenster besitzt sie, die Terminal-Eingabe schickt nur
//! Befehlszeilen hinein. Dadurch kann es keinen Mismatch geben: ein
//! `Game`-Zustand, ein `Mode`, beide Frontends stellen ihn nur dar.
//!
//! Schicht: `engine ← session ← { cli, render }`. Die Session kennt kein
//! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen
//! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt
//! (Terminal jetzt, In-Fenster-Konsole später).
//!
//! Die Session ist auch der alleinige Besitzer der **Lauf-Simulation**:
//! `Player` und `CollisionWorld`. Sie hat zwei Eingabe-Tore — `exec` für
//! diskrete Befehle/Dialog und `tick` für die kontinuierliche Physik pro
//! Frame. Die Frontends sammeln nur Eingaben und stellen den Zustand dar;
//! die Flycam dagegen ist reine Frontend-Sicht (kein Sim-Zustand) und bleibt
//! im `render`-Modul.

use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::map::{self, Map};
use crate::engine::player::Player;
use crate::engine::{kv, signals, story_ctrl};

/// Maus-Empfindlichkeit der Spieler-Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025;

/// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI
/// nie uneins sind, ob ein Dialog läuft.
pub enum Mode {
    /// First-Person-Spiel — der Player bewegt sich mit Physik durch die Welt
    /// (siehe engine::player). Der Renderer leitet seine View aus dem Player
    /// ab. Das ist der eigentliche Spielmodus.
    Play,
    /// Noclip-Debug-Flycam — frei schwebende Kamera ohne Physik. Per
    /// `noclip`-Befehl gegen `Play` getauscht.
    Free,
    /// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die
    /// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine
    /// andere Darstellung). Per `menu`-Befehl getoggelt; „Fortsetzen" führt
    /// ins Spiel (`Play`).
    Menu,
    /// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels;
    /// Eingaben treiben den Dialog statt Befehle.
    Dialog(Dialog),
}

/// Aktueller Dialogschritt, frontend-neutral. `choices` leer = reiner Text,
/// der auf „weiter" (Leerzeile/Enter) wartet.
pub struct Dialog {
    // Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
    // Dialogtext über die Konsolen-Ausgabe (siehe `step`).
    #[allow(dead_code)]
    pub text:    String,
    pub choices: Vec<String>,
}

pub struct Session {
    pub game: Game,
    pub mode: Mode,
    pub player: Player,
    pub collision: CollisionWorld,
    signals_path: String,
}

/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
#[derive(Default)]
pub struct FrameInput {
    pub fwd:     f32,
    pub right:   f32,
    pub jump:    bool,
    pub look_dx: f32,
    pub look_dy: f32,
}

/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
/// beenden soll (`quit`/`exit`).
pub struct ExecResult {
    pub output: Vec<String>,
    pub quit:   bool,
}

impl ExecResult {
    fn lines(output: Vec<String>) -> Self { Self { output, quit: false } }
    fn empty() -> Self { Self { output: Vec::new(), quit: false } }
}

impl Session {
    pub fn new(signals_path: String) -> Self {
        let game = Game::new(signals::load_signals(&signals_path));
        Self {
            game,
            mode: Mode::Menu,
            // Default-Welt, bis `load_world` eine Map einspielt (die CLI
            // braucht keine — sie tickt nie).
            player: Player::new([0.0, 1.0, 0.0]),
            collision: CollisionWorld::empty(),
            signals_path,
        }
    }

    /// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den
    /// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim
    /// Start gerufen.
    pub fn load_world(&mut self, map: &Map) {
        self.collision = CollisionWorld::build(map);
        self.player = Player::new(player_spawn(map));
    }

    /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
    /// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
    /// pausieren die Sim (die Flycam ist reine Frontend-Sicht).
    pub fn tick(&mut self, input: &FrameInput, dt: f32) {
        if matches!(self.mode, Mode::Play) {
            self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
            self.player.step(&self.collision, input.fwd, input.right, input.jump, dt);
        }
    }

    /// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
    /// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe
    /// darstellen kann — und damit `init` bereits einen Dialog öffnen darf.
    pub fn start(&mut self) -> Vec<String> {
        self.fire("init", None)
    }

    /// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog,
    /// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach
    /// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später)
    /// Klick denselben Dialog ohne Sonderpfad.
    pub fn exec(&mut self, line: &str) -> ExecResult {
        match self.mode {
            Mode::Dialog(_)                      => self.dialog_input(line),
            Mode::Play | Mode::Free | Mode::Menu => self.command(line),
        }
    }

    fn command(&mut self, line: &str) -> ExecResult {
        let line = line.trim();
        match line {
            "" => ExecResult::empty(),
            "quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
            "menu" => {
                // Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
                // Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
                // Free öffnet es das Menü. (Dialog wird anders geroutet.)
                let t = match self.mode {
                    Mode::Menu => ModeTarget::Play,
                    _          => ModeTarget::Menu,
                };
                self.set_mode(t);
                ExecResult::empty()
            }
            "noclip" => {
                // Debug: zwischen First-Person (Play) und freier Flycam (Free)
                // umschalten.
                let t = match self.mode {
                    Mode::Play => ModeTarget::Free,
                    _          => ModeTarget::Play,
                };
                self.set_mode(t);
                ExecResult::empty()
            }
            "help" => ExecResult::lines(help()),
            "kv"   => ExecResult::lines(self.dump_kv()),
            "reload" => {
                self.game.signals = signals::load_signals(&self.signals_path);
                ExecResult::lines(vec![
                    format!("{} Signale geladen.", self.game.signals.len()),
                ])
            }
            _ => {
                if let Some(m) = line.strip_prefix("mode ") {
                    // Modus explizit setzen — über dieselbe Action wie das
                    // `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
                    // Signals genau einen Wechsel-Pfad teilen.
                    match ModeTarget::parse(m) {
                        Some(t) => {
                            self.game.actions.push(Action::SetMode(t));
                            ExecResult::lines(self.drain_actions())
                        }
                        None => ExecResult::lines(vec![
                            "Modus: play | free | menu".into(),
                        ]),
                    }
                } else if let Some(sig) = line.strip_prefix("signal ") {
                    ExecResult::lines(self.fire(sig.trim(), None))
                } else if let Some(name) = line.strip_prefix("use ") {
                    // Objekt-Interaktion simulieren: Signal ist der gestrippte
                    // Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
                    let name = name.trim();
                    let key = signals::signal_key(name).to_string();
                    ExecResult::lines(self.fire(&key, Some(name.to_string())))
                } else {
                    ExecResult::lines(vec![
                        format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
                    ])
                }
            }
        }
    }

    fn dialog_input(&mut self, line: &str) -> ExecResult {
        let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
        let sel = if d.choices.is_empty() {
            None // reiner Text → Leerzeile/Enter blättert weiter
        } else {
            match line.trim().parse::<usize>() {
                Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1),
                _ => return ExecResult::lines(vec![format!("  (1..{})", d.choices.len())]),
            }
        };
        let mut out = self.step(sel);
        // Ein start_ink-Tag kann während step() eine neue Story in den Slot
        // geladen haben, während die alte auf End lief (mode → Play). Falls
        // das passiert ist, sofort den ersten Schritt der neuen Story ziehen
        // statt den Spieler im Play-Modus hängen zu lassen.
        if self.game.story.is_some() && matches!(self.mode, Mode::Play) {
            out.extend(self.step(None));
        }
        ExecResult::lines(out)
    }

    /// Signal dispatchen, deferred Actions einsammeln und — falls dadurch
    /// eine Story startete — den ersten Dialogschritt ziehen.
    fn fire(&mut self, signal: &str, instance: Option<String>) -> Vec<String> {
        {
            let mut ctx = self.game.action_ctx(instance);
            signals::dispatch(signal, &mut ctx);
        }
        let mut out = self.drain_actions();
        if self.game.story.is_some() {
            out.extend(self.step(None));
        }
        out
    }

    /// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die
    /// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern.
    /// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl).
    fn step(&mut self, sel: Option<usize>) -> Vec<String> {
        let state = {
            let mut ctx = self.game.action_ctx(None);
            let (state, tags) = story_ctrl::advance(sel, &mut ctx);
            for t in &tags { signals::dispatch(t, &mut ctx); }
            state
        };
        let mut out = self.drain_actions();
        match state {
            None | Some(StoryState::End) => {
                self.mode = Mode::Play;
            }
            Some(StoryState::Text(text)) => {
                out.push(text.clone());
                self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() });
            }
            Some(StoryState::Choice { prompt, options }) => {
                if !prompt.is_empty() { out.push(prompt.clone()); }
                for (i, o) in options.iter().enumerate() {
                    out.push(format!("  {}) {o}", i + 1));
                }
                self.mode = Mode::Dialog(Dialog { text: prompt, choices: options });
            }
        }
        out
    }

    fn drain_actions(&mut self) -> Vec<String> {
        // Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei),
        // dann anwenden — `SetMode` braucht `&mut self.mode`.
        let actions: Vec<Action> = self.game.actions.drain(..).collect();
        let mut out = Vec::with_capacity(actions.len());
        for a in actions {
            match a {
                Action::HideObject(n) => out.push(format!("[action] hide_object {n}")),
                Action::PlaySound(n)  => out.push(format!("[action] play_sound {n}")),
                Action::SetMode(t)    => {
                    self.set_mode(t);
                    out.push(format!("[action] mode {}", t.label()));
                }
            }
        }
        out
    }

    /// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
    /// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
    /// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
    /// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
    fn set_mode(&mut self, t: ModeTarget) {
        self.mode = match t {
            ModeTarget::Play => Mode::Play,
            ModeTarget::Free => Mode::Free,
            ModeTarget::Menu => Mode::Menu,
        };
    }

    fn dump_kv(&self) -> Vec<String> {
        let mut keys: Vec<&String> = self.game.kv.keys().collect();
        keys.sort();
        keys.into_iter()
            .map(|k| format!("  {k} = {}", kv::format_value(&self.game.kv[k])))
            .collect()
    }
}

/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
fn player_spawn(map: &Map) -> [f32; 3] {
    for e in &map.entities {
        if e.classname() == Some("info_player_start") {
            if let Some(origin) = e.props.get("origin") {
                let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
                if let [x, y, z] = v[..] {
                    return map::to_engine([x, y, z]);
                }
            }
        }
    }
    [0.0, 1.0, 0.0]
}

fn help() -> Vec<String> {
    vec![
        "  signal <s>        Signal feuern oder Action direkt ausführen".into(),
        "                    (z.B. `signal Cube`, `signal set has_key true`)".into(),
        "  use <instance>    Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
        "                    feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
        "  kv                KV-Store anzeigen".into(),
        "  menu              Menü öffnen / ins Spiel zurück".into(),
        "  noclip            zwischen First-Person und Debug-Flycam wechseln".into(),
        "  mode <m>          Modus direkt setzen: play | free | menu".into(),
        "  reload            signals.toml neu laden".into(),
        "  quit              beenden".into(),
    ]
}

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

    // Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
    // also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
    // im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`.
    fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) }

    #[test]
    fn starts_in_menu() {
        assert!(matches!(empty_session().mode, Mode::Menu));
    }

    #[test]
    fn quit_sets_flag() {
        let mut s = empty_session();
        assert!(s.exec("quit").quit);
    }

    #[test]
    fn builtin_command_mutates_kv_and_stays_free() {
        let mut s = empty_session();
        s.mode = Mode::Free;
        // Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
        let r = s.exec("signal set has_key true");
        assert!(!r.quit);
        assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
        assert!(matches!(s.mode, Mode::Free));
    }

    #[test]
    fn menu_command_opens_and_resumes_to_play() {
        let mut s = empty_session();
        s.mode = Mode::Free;
        s.exec("menu");
        assert!(matches!(s.mode, Mode::Menu));
        s.exec("menu"); // „Fortsetzen" → ins Spiel
        assert!(matches!(s.mode, Mode::Play));
        s.exec("menu"); // aus dem Spiel wieder ins Menü
        assert!(matches!(s.mode, Mode::Menu));
        // Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
        s.exec("signal set in_menu true");
        assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
    }

    #[test]
    fn mode_command_sets_mode_directly() {
        let mut s = empty_session();
        s.exec("mode play");
        assert!(matches!(s.mode, Mode::Play));
        s.exec("mode free");
        assert!(matches!(s.mode, Mode::Free));
        s.exec("mode menu");
        assert!(matches!(s.mode, Mode::Menu));
        // Müll-Argument lässt den Modus stehen und meldet die Nutzung.
        let r = s.exec("mode dialog");
        assert!(matches!(s.mode, Mode::Menu));
        assert_eq!(r.output.len(), 1);
    }

    #[test]
    fn signal_can_switch_mode() {
        let mut s = empty_session();
        s.mode = Mode::Free;
        // `mode`-Verb direkt als Action (Ink-Tag-/signals.toml-Pfad).
        s.exec("signal mode play");
        assert!(matches!(s.mode, Mode::Play));
    }

    #[test]
    fn noclip_toggles_play_and_free() {
        let mut s = empty_session();
        s.mode = Mode::Play;
        s.exec("noclip");
        assert!(matches!(s.mode, Mode::Free));
        s.exec("noclip");
        assert!(matches!(s.mode, Mode::Play));
    }

    #[test]
    fn unknown_command_reports_and_stays_free() {
        let mut s = empty_session();
        s.mode = Mode::Free;
        let r = s.exec("frobnicate");
        assert_eq!(r.output.len(), 1);
        assert!(matches!(s.mode, Mode::Free));
    }
}
