use crate::engine::audio::Emitters;
use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player};
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};

/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;

/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;

/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
pub const INTERACT_REACH: f32 = 3.0;

pub enum Mode {
    FirstPerson,
    Free,
    Menu,
    Dialog(Dialog),
}

pub struct Dialog {
    pub text:    String,
    pub choices: Vec<String>,
}

pub struct Session {
    pub game: Game,
    pub mode: Mode,
    pub player: Player,
    pub collision: CollisionWorld,
    /// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
    /// (`signal`-Property, siehe engine::trigger).
    triggers: Triggers,
    /// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
    /// Empties, siehe engine::audio).
    emitters: Emitters,
    /// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
    /// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
    /// sie liegen (ihr reicht die `[action]`-Textzeile).
    pending_sounds: Vec<String>,
    signals_path: String,
    /// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
    sim_accum: f32,
    /// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
    /// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
    /// nächsten Sub-Schritt konsumiert.
    pending_jump: bool,
}

/// 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-Spawn, bis ein Modell einen `role=spawn`-Empty
            // mitbringt (die CLI braucht keine Welt — sie tickt nie).
            player: Player::new([0.0, 1.0, 0.0]),
            collision: CollisionWorld::empty(),
            triggers: Triggers::new(),
            emitters: Emitters::new(),
            pending_sounds: Vec::new(),
            signals_path,
            sim_accum: 0.0,
            pending_jump: false,
        }
    }

    /// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
    /// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
    /// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
    /// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
    /// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
    /// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
    /// desselben Modells ist Sache des Renderers (render::props).
    pub fn load_props(&mut self, model: &model::Model) {
        self.collision.add_model(model);
        self.triggers.add_model(model);
        self.emitters.add_model(model);
        for e in &model.empties {
            if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
                self.player = Player::new(e.pos);
            }
        }
    }

    /// Die Ambient-Emitter in fester Reihenfolge — der Audio-Treiber legt
    /// beim Start je Emitter eine Loop-Voice an.
    pub fn emitters(&self) -> &[crate::engine::audio::Emitter] {
        self.emitters.list()
    }

    /// Aktuelle Emitter-Gains (parallel zu [`Session::emitters`]) für die
    /// Ohrposition des Spielers — jeden Frame vom Audio-Treiber gezogen.
    pub fn emitter_gains(&self) -> Vec<f32> {
        self.emitters.gains(self.player.eye())
    }

    /// Aufgelaufene Einmal-SFX (`play_sound`-Actions) abholen — jeden Frame
    /// vom Fenster-Frontend gerufen, das sie an den Audio-Treiber gibt.
    pub fn take_sounds(&mut self) -> Vec<String> {
        std::mem::take(&mut self.pending_sounds)
    }

    /// 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). Rückgabe:
    /// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
    /// zeigt sie wie `exec`-Ausgaben an.
    pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
        if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }

        // Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
        // einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
        // Sub-Schritte und die Empfindlichkeit schwankte mit der Framerate.
        self.player.look(input.look_dx, input.look_dy, LOOK_SENS);

        // Sprung-Flanke puffern, bis ein Sub-Schritt sie verbraucht.
        self.pending_jump |= input.jump;

        // Physik in festen FIXED_DT-Schritten treiben; Rest-Zeit über Frames
        // tragen. Der Deckel verhindert die Spirale bei einem einzelnen Hänger.
        self.sim_accum = (self.sim_accum + dt).min(MAX_ACCUM);
        while self.sim_accum >= FIXED_DT {
            let jump = std::mem::take(&mut self.pending_jump);
            self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
            self.sim_accum -= FIXED_DT;
        }

        // Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je
        // Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann
        // den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames
        // ist da schon abgeschlossen.
        let center = [
            self.player.pos[0],
            self.player.pos[1] + player::HALF_EXTENTS[1],
            self.player.pos[2],
        ];
        let events = self.triggers.enter_events(center, player::HALF_EXTENTS);
        let mut out = Vec::new();
        for (name, signal) in events {
            out.extend(self.fire(&signal, Some(name)));
        }
        out
    }

    /// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang
    /// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere
    /// Weltgeometrie davor liegt. Liefert den Objektnamen für `use <name>`.
    pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> {
        let end = [
            origin[0] + dir[0] * reach,
            origin[1] + dir[1] * reach,
            origin[2] + dir[2] * reach,
        ];
        let (name, frac) = self.triggers.pick(origin, end)?;
        // Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier
        // Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN).
        let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac);
        (frac <= world + 1e-3).then_some(name)
    }

    /// 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::FirstPerson | 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::FirstPerson => 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 (LMB-Klick und Konsole laufen beide
                    // hier durch): Klick-Ziele mit `signal`-Property feuern
                    // dieses Signal; sonst den vom Blender-Suffix befreiten
                    // Objektnamen. $self ist immer der volle Name.
                    let name = name.trim();
                    let key = self.triggers.signal_for(name)
                        .unwrap_or_else(|| 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::FirstPerson) {
            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::FirstPerson;
            }
            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::PlaySound(n)  => {
                    out.push(format!("[action] play_sound {n}"));
                    self.pending_sounds.push(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::FirstPerson,
            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()
    }
}

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::FirstPerson));
        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::FirstPerson));
        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::FirstPerson));
    }

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

    #[test]
    fn fixed_timestep_is_framerate_independent() {
        // Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
        // Frames → dieselbe Zahl fester Sim-Schritte, also (bis auf FP-Rauschen)
        // dasselbe Ergebnis. Die leere Kollisionswelt lässt den Spieler frei
        // fallen — genügt, um die Integration zu vergleichen.
        let mut coarse = empty_session();
        coarse.mode = Mode::FirstPerson;
        let mut fine = empty_session();
        fine.mode = Mode::FirstPerson;

        let input = FrameInput::default();
        coarse.tick(&input, 0.1);
        for _ in 0..60 { fine.tick(&input, 0.1 / 60.0); }

        assert!((coarse.player.pos[1] - fine.player.pos[1]).abs() < 1e-3,
            "framerate-unabhängig: {} vs {}", coarse.player.pos[1], fine.player.pos[1]);
        assert!((coarse.player.vel[1] - fine.player.vel[1]).abs() < 1e-3,
            "gleiche Fallgeschwindigkeit: {} vs {}", coarse.player.vel[1], fine.player.vel[1]);
        assert!(coarse.player.pos[1] < 1.0, "sollte gefallen sein: {}", coarse.player.pos[1]);
    }

    #[test]
    fn jump_press_survives_a_substepless_frame() {
        // Sprungtaste in einem Frame kürzer als FIXED_DT: es läuft noch kein
        // Sim-Schritt, die Flanke darf aber nicht verfallen, sondern muss beim
        // nächsten Schritt zünden.
        let mut s = empty_session();
        s.mode = Mode::FirstPerson;
        s.player.grounded = true;
        let jump = FrameInput { jump: true, ..Default::default() };
        s.tick(&jump, FIXED_DT * 0.5);
        assert!(s.player.vel[1] <= 0.0, "noch kein Schritt → noch kein Absprung");

        s.tick(&FrameInput::default(), FIXED_DT * 0.6);
        assert!(s.player.vel[1] > 0.0,
            "gepufferter Sprung sollte abheben: {}", s.player.vel[1]);
    }

    #[test]
    fn tick_only_runs_in_first_person() {
        // Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
        let mut s = empty_session();
        s.mode = Mode::Menu;
        let before = s.player.pos;
        s.tick(&FrameInput::default(), 0.1);
        assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
    }

    /// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
    /// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen.
    fn trigger_model() -> crate::engine::model::Model {
        crate::engine::model::Model {
            objects: vec![
                crate::engine::model::test_cube(
                    "Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")),
                crate::engine::model::test_cube(
                    "Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
            ],
            empties: Vec::new(),
            materials: vec![String::new()],
        }
    }

    #[test]
    fn walking_into_zone_fires_signal_once() {
        let mut s = empty_session();
        s.load_props(&trigger_model());
        s.mode = Mode::FirstPerson;
        s.player.grounded = true;

        // Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
        // Flanke genau einmal (danach: drin = still).
        for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
        assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");

        // Weiterlaufen in der Zone darf nicht erneut feuern.
        s.game.kv.remove("zone_hit");
        for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
        assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt");
    }

    #[test]
    fn use_resolves_signal_property_before_name() {
        let mut s = empty_session();
        s.load_props(&trigger_model());
        s.mode = Mode::FirstPerson;
        // Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
        s.exec("use Kiste");
        assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
        // Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
        // unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
        let r = s.exec("use Irgendwas.003");
        assert!(!r.quit);
    }

    #[test]
    fn pick_hits_clickable_within_reach() {
        let mut s = empty_session();
        s.load_props(&trigger_model());
        // Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
        let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
        assert_eq!(hit, Some("Kiste"));
        // Außer Reichweite → nichts.
        assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
    }

    #[test]
    fn play_sound_action_queues_for_frontend() {
        let mut s = empty_session();
        // Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag.
        s.exec("signal play_sound klick");
        assert_eq!(s.take_sounds(), vec!["klick".to_string()]);
        assert!(s.take_sounds().is_empty(), "take leert die Queue");
    }

    #[test]
    fn spawn_empty_places_player() {
        let mut s = empty_session();
        let mut props = crate::engine::model::Props::new();
        props.insert("role".into(), "spawn".into());
        s.load_props(&crate::engine::model::Model {
            objects: Vec::new(),
            empties: vec![
                crate::engine::model::Empty { name: "start".into(), pos: [3.0, 0.5, -2.0], props },
                crate::engine::model::Empty {
                    name: "deko".into(), pos: [9.0, 9.0, 9.0],
                    props: crate::engine::model::Props::new(),
                },
            ],
            materials: Vec::new(),
        });
        assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
    }

    #[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));
    }
}
