//! Signal → Action Dispatcher.
//!
//! Triggers (Objekt-Interaktion, Zone-Enter, Ink-Tag, Konsole) feuern
//! *Signals* als Strings. Eine zentrale Table in `assets/signals.toml` mappt
//! jedes Signal auf eine Liste von Actions. Actions sind selbst wieder
//! Strings im Format `"verb args"` — dieselbe Vokabular-Familie wie
//! Ink-Tags, sodass beide auf dieselbe Execute-Pipeline laufen.
//!
//! Builtin-Verben:
//!   `start_ink   <script>`        Story aus `assets/interactions/` in den
//!                                 Story-Slot laden; das Frontend treibt sie
//!                                 dann über `story_ctrl::advance`.
//!   `debug_log   <text>`          eprintln!
//!   `set         <name> <value>`  KV setzen (true/false/i32/f32/string)
//!   `inc         <name> [<delta>]` KV-Integer inkrementieren (Default +1)
//!   `clear       <name>`          KV-Eintrag entfernen
//!   `play_sound  <name>`          → Action::PlaySound (deferred): SFX aus
//!                                 `assets/sounds/{name}.wav`
//!   `mode        <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
//!                                 Eingabemodus wechseln
//!
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
//! (z.B. `set $self true`) für viele duplizierte Instances wirken, ohne pro
//! Instance einen Eintrag in signals.toml zu brauchen.
//!
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
//! laufen alle gemappten Actions. Sonst wird der String direkt als
//! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true`
//! ohne Umweg über signals.toml.
//!
//! Unbekannte Verben: aus einer signals.toml-*Action* sind sie immer ein
//! Tippfehler und werden nach stderr gemeldet. Im Ink-Tag-Fall-Through bleiben
//! sie hingegen still — Tags dürfen bewusst reine Metadaten sein, die (noch)
//! kein Verb konsumiert (z.B. ein späteres `# speaker …`).

use std::collections::HashMap;
use std::fs::read_to_string;

use crate::engine::game::{Action, ActionCtx, ModeTarget, Signals};
use crate::engine::kv;
use crate::engine::story_ctrl;

/// Strippt Blenders Duplikat-Suffix (`.NNN` mit reinen Ziffern am Ende),
/// damit `Mushroom.001`, `Mushroom.042` etc. alle auf `[Mushroom]` in
/// signals.toml mappen. Andere Punkte im Namen bleiben unberührt, solange
/// der letzte Token nicht rein numerisch ist.
pub fn signal_key(name: &str) -> &str {
    if let Some(dot) = name.rfind('.') {
        let tail = &name[dot + 1..];
        if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
            return &name[..dot];
        }
    }
    name
}

pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
    // Signal-Name in der Table? Dann alle gemappten Actions ausführen. Ein
    // unbekanntes Verb in einer Action ist hier immer ein Tippfehler → melden.
    if let Some(actions) = ctx.signals.get(signal) {
        let actions = actions.clone();  // entkoppelt vom ctx.signals-Borrow
        for a in &actions {
            if !execute(a, ctx) {
                eprintln!("[signal] {signal:?}: unbekanntes Verb in Action {a:?} \
                           — Tippfehler in signals.toml?");
            }
        }
        return;
    }
    // Sonst direkt als Builtin probieren (Ink-Tag-Pfad). Unbekanntes bleibt
    // hier still (Tags dürfen bloße Metadaten sein) — Rückgabe verworfen.
    let _ = execute(signal, ctx);
}

/// Führt einen einzelnen Befehl (`verb args`) aus. Rückgabe: `true`, wenn das
/// Verb bekannt war und lief; `false` bei unbekanntem Verb (der Aufrufer
/// entscheidet, ob das eine Meldung wert ist — siehe `dispatch`).
fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
    // $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
    // damit Tokens wie `set $self true` einheitlich funktionieren. Ohne
    // instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
    // zielt dann ins Leere, was für deferred Actions ein No-Op beim
    // Konsumenten ist.
    let cmd_owned: String;
    let cmd_ref: &str = if cmd.contains("$self") {
        if let Some(name) = ctx.instance_name.as_deref() {
            cmd_owned = cmd.replace("$self", name);
            &cmd_owned
        } else { cmd }
    } else { cmd };
    let (verb, args) = cmd_ref.split_once(char::is_whitespace).unwrap_or((cmd_ref, ""));
    let args = args.trim();
    match verb {
        "start_ink"    => story_ctrl::start(args, ctx),
        "debug_log"    => eprintln!("[signal] {args}"),
        "set"          => kv::apply_set(args, ctx.kv),
        "inc"          => kv::apply_inc(args, ctx.kv),
        "clear"        => kv::apply_clear(args, ctx.kv),
        "play_sound"   => ctx.actions.push(Action::PlaySound(args.to_string())),
        "mode"         => if let Some(t) = ModeTarget::parse(args) {
            ctx.actions.push(Action::SetMode(t));
        },
        _ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
    }
    true
}

/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
/// das Format ist trivial und die Engine bleibt dependency-arm.
pub fn parse_signals(src: &str) -> Signals {
    let mut map: Signals = HashMap::new();
    let mut cur = String::new();
    let mut in_arr = false;
    for line in src.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') { continue; }
        if !in_arr && line.starts_with('[') {
            cur = line.trim_matches(|c| c == '[' || c == ']').to_string();
            map.entry(cur.clone()).or_default();
            continue;
        }
        if !in_arr && line.starts_with("actions") && line.contains('[') {
            in_arr = true;
            continue;
        }
        if in_arr {
            if line.starts_with(']') { in_arr = false; continue; }
            let s = line.trim_end_matches(',').trim().trim_matches('"');
            if s.is_empty() { continue; }
            map.entry(cur.clone()).or_default().push(s.to_string());
        }
    }
    map
}

/// Lädt `signals.toml`. Fehlende Datei → leere Table (kein Fehler), damit
/// ein Projekt ohne Signals lauffähig bleibt.
pub fn load_signals(path: &str) -> Signals {
    match read_to_string(path) {
        Ok(src) => parse_signals(&src),
        Err(_)  => Signals::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::game::Game;

    #[test]
    fn signal_key_strips_blender_suffix() {
        assert_eq!(signal_key("Mushroom.005"), "Mushroom");
        assert_eq!(signal_key("Mushroom"), "Mushroom");
        assert_eq!(signal_key("my.thing.with.dots"), "my.thing.with.dots");
        assert_eq!(signal_key("name."), "name.");
    }

    #[test]
    fn parse_signals_table() {
        let src = r#"
            # Kommentar
            [init]
            actions = [
                "set foo 1",
                "debug_log hi",
            ]

            [Empty]
            actions = [
            ]
        "#;
        let s = parse_signals(src);
        assert_eq!(s["init"], vec!["set foo 1", "debug_log hi"]);
        assert!(s["Empty"].is_empty());
    }

    #[test]
    fn dispatch_runs_actions_and_queues_deferred() {
        let mut signals = Signals::new();
        signals.insert("pickup".into(), vec![
            "inc items".into(),
            "play_sound $self".into(), // $self-Substitution in deferred Action
        ]);
        let mut game = Game::new(signals);

        let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
        dispatch("pickup", &mut ctx);

        assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
        assert_eq!(game.actions, vec![
            Action::PlaySound("Mushroom.005".into()),
        ]);
    }

    #[test]
    fn mode_verb_queues_setmode_action() {
        let mut game = Game::new(Signals::new());
        let mut ctx = game.action_ctx(None);
        dispatch("mode free", &mut ctx);
        assert_eq!(game.actions, vec![Action::SetMode(ModeTarget::Free)]);

        // Unbekanntes Ziel → kein Action (stilles No-Op).
        game.actions.clear();
        let mut ctx = game.action_ctx(None);
        dispatch("mode wat", &mut ctx);
        assert!(game.actions.is_empty());
    }

    #[test]
    fn typo_verb_in_table_is_tolerated_and_reported() {
        // Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
        // bleibt ohne Wirkung, während die gültige Nachbar-Action weiter läuft.
        let mut signals = Signals::new();
        signals.insert("thing".into(), vec![
            "st has_key true".into(), // Tippfehler für `set` → No-Op + Meldung
            "set ok true".into(),     // gültig → wirkt
        ]);
        let mut game = Game::new(signals);
        let mut ctx = game.action_ctx(None);
        dispatch("thing", &mut ctx);
        assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
        assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
    }

    #[test]
    fn unknown_signal_falls_through_to_builtin() {
        let mut game = Game::new(Signals::new());
        let mut ctx = game.action_ctx(None);
        // Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
        dispatch("set has_key true", &mut ctx);
        assert!(game.kv["has_key"].coerce_to_bool().unwrap());

        // Unbekanntes Verb ist ein stilles No-Op.
        let mut ctx = game.action_ctx(None);
        dispatch("color story_a", &mut ctx);
        assert!(game.actions.is_empty());
    }
}
