//! Trigger aus Blender-Modellen: Objekte mit `signal`-Custom-Property
//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage —
//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**:
//!
//!   - `collide` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich
//!     das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]).
//!     Feuern läuft über den `use <name>`-Trichter in `Session::exec`.
//!   - nur `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten
//!     feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`],
//!     Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer"
//!     baut die Autorin über KV-Flags/Ink, nicht hier.
//!
//! In beiden Fällen ist der Signal-Name der Property-*Wert*, `$self` der
//! Objektname. Volumen-Mathe kommt aus `collision` (dieselben konvexen
//! Ebenen-Sets wie die Proxies); gefeuert wird hier nichts — die Session
//! zieht Events/Treffer und schickt sie durch ihren Dispatch, damit der
//! Eingabe-Trichter der einzige Weg in den State bleibt.

use crate::engine::collision::{self, Plane};
use crate::engine::model::{Model, SIGNAL_PROP};

struct Clickable {
    name:   String,
    signal: String,
    planes: Vec<Plane>,
}

struct Zone {
    name:   String,
    signal: String,
    planes: Vec<Plane>,
    /// Flanken-Zustand: war der Spieler im letzten Check im Volumen?
    inside: bool,
}

#[derive(Default)]
pub struct Triggers {
    clickables: Vec<Clickable>,
    zones:      Vec<Zone>,
}

impl Triggers {
    pub fn new() -> Self { Self::default() }

    /// Trigger eines Modells übernehmen: alle Objekte mit `signal`-Property.
    pub fn add_model(&mut self, model: &Model) {
        for o in &model.objects {
            let Some(signal) = o.props.get(SIGNAL_PROP) else { continue; };
            let Some(planes) = collision::object_planes(o) else {
                eprintln!("[trigger] {}: kein geschlossenes konvexes Volumen — ignoriert", o.name);
                continue;
            };
            let (name, signal) = (o.name.clone(), signal.clone());
            if o.collider {
                self.clickables.push(Clickable { name, signal, planes });
            } else {
                self.zones.push(Zone { name, signal, planes, inside: false });
            }
        }
    }

    /// Signal eines Klick-Ziels (für die `use <name>`-Auflösung der Session).
    pub fn signal_for(&self, name: &str) -> Option<&str> {
        self.clickables.iter()
            .find(|c| c.name == name)
            .map(|c| c.signal.as_str())
    }

    /// Nächstes Klick-Ziel entlang `start→end` (Punktstrahl): Name und
    /// Trefferbruch. Ob Weltgeometrie davor liegt, prüft der Aufrufer.
    pub fn pick(&self, start: [f32; 3], end: [f32; 3]) -> Option<(&str, f32)> {
        let mut nearest: Option<(&str, f32)> = None;
        for c in &self.clickables {
            if let Some(hit) = collision::trace_planes(&c.planes, start, end, [0.0; 3])
                && nearest.is_none_or(|(_, f)| hit.frac < f) {
                nearest = Some((&c.name, hit.frac));
            }
        }
        nearest
    }

    /// Zonen gegen die Spieler-AABB prüfen und die Eintritts-Flanken melden:
    /// `(Objektname, Signal)` je Zone, die diesen Check betreten wurde.
    pub fn enter_events(&mut self, center: [f32; 3], half: [f32; 3]) -> Vec<(String, String)> {
        let mut events = Vec::new();
        for z in &mut self.zones {
            let inside = collision::box_touches(&z.planes, center, half);
            if inside && !z.inside {
                events.push((z.name.clone(), z.signal.clone()));
            }
            z.inside = inside;
        }
        events
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::model::{test_cube as cube, Object};

    fn model(objects: Vec<Object>) -> Model {
        Model { objects, empties: Vec::new(), materials: vec![String::new()] }
    }

    #[test]
    fn splits_by_collider_and_zone_is_invisible() {
        let m = model(vec![
            cube("Kiste",    [0.0, 0.0, 0.0], Some("1"), Some("kiste")),
            cube("Zone.001", [5.0, 0.0, 0.0], None, Some("betreten")),
            cube("Deko",     [9.0, 0.0, 0.0], None, None),
        ]);
        assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden");

        let mut t = Triggers::new();
        t.add_model(&m);
        assert_eq!(t.clickables.len(), 1);
        assert_eq!(t.zones.len(), 1);
        assert_eq!(t.signal_for("Kiste"), Some("kiste"));
        assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel");
    }

    #[test]
    fn pick_finds_nearest_clickable() {
        let mut t = Triggers::new();
        t.add_model(&model(vec![
            cube("Fern", [4.0, -0.5, -0.5], Some("1"), Some("f")),
            cube("Nah",  [1.0, -0.5, -0.5], Some("1"), Some("n")),
        ]));
        // Strahl entlang +X durch beide Würfel → der nahe gewinnt.
        let (name, frac) = t.pick([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]).unwrap();
        assert_eq!(name, "Nah");
        assert!((frac - 0.1).abs() < 0.01, "frac={frac}");
        // Strahl daneben → nichts.
        assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none());
    }

    #[test]
    fn zone_fires_on_entry_edge_and_rearms_after_exit() {
        let mut t = Triggers::new();
        t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], None, Some("betreten"))]));
        let half = [0.3, 0.9, 0.3];

        // Außerhalb: nichts.
        assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
        // Betreten: genau ein Event …
        let ev = t.enter_events([0.5, 0.5, 0.5], half);
        assert_eq!(ev, vec![("Zone".to_string(), "betreten".to_string())]);
        // … und beim Verweilen keins.
        assert!(t.enter_events([0.6, 0.5, 0.5], half).is_empty());
        // Verlassen und wieder betreten: feuert erneut.
        assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
        assert_eq!(t.enter_events([0.5, 0.5, 0.5], half).len(), 1);
    }
}
