//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//!
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
//! Als Nächstes: OBJ-Szene (Schritt 5).
//!
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
//! anderen Zustand als die Konsole.
//!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.

mod audio;
mod camera;
mod font;
mod gpu;
mod math;
mod props;
mod scene;
mod sprite;
mod ui;

use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::time::Instant;

use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, ElementState, KeyEvent, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};

use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
use math::Mat4;
use scene::Mesh;

/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
/// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0;
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6;

pub fn run(mut session: Session) {
    // Init-Signal feuern, bevor das Fenster steht (kann bereits einen
    // Dialog öffnen — dann startet die Welt eben pausiert).
    for line in session.start() { println!("{line}"); }

    // CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
    // auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
    // getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
    // Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
    // nie fatal.
    let models = load_models(&assets::path("assets/maps/props"));

    // Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
    // denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
    let mut tex_names: Vec<String> = Vec::new();
    for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
    tex_names.sort_unstable();
    tex_names.dedup();
    let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();

    let mut mesh = Mesh::default();
    for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
    println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
        models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);

    // Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
    // einspielen — die Simulation gehört der Session, nicht dem
    // Render-Frontend.
    for (_, m) in &models { session.load_props(m); }

    // Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
    // Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
    // dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
    let audio = audio::Audio::open();
    if let Some(a) = &audio {
        let sounds: Vec<Option<audio::Sound>> = session.emitters().iter()
            .map(|e| load_sound(&e.sound))
            .collect();
        a.set_ambients(&sounds);
    }

    // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
    // FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
    // Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
    // Luminanz.
    // UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
    // ist die Installation kaputt, da darf es scheitern.
    let load_keyed = |p: &str| {
        ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
    };
    // Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
    // dann dieselben Bilder in die GPU-Texturliste übernehmen.
    let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
    let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga");
    let fonts = font::Fonts {
        ega: font::Font::ega(ui::FONT_EGA, &ega_img),
        cga: font::Font::cga(ui::FONT_CGA, &cga_img),
    };
    let ui_textures: Vec<Image> = vec![
        ui::white_pixel(),
        ega_img,
        cga_img,
        load_keyed("assets/textures/ui/cursors.tga"),
        load_keyed("assets/textures/ui/panel_ornaments.tga"),
    ];

    // stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
    // (winit) nicht. Es queren nur Strings die Thread-Grenze, kein Zustand
    // — der bleibt allein auf dem Main-Thread.
    let (tx, rx) = mpsc::channel::<String>();
    std::thread::spawn(move || {
        let stdin = std::io::stdin();
        let mut buf = String::new();
        loop {
            buf.clear();
            match stdin.read_line(&mut buf) {
                Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
                Ok(_) => {
                    if tx.send(buf.trim_end().to_string()).is_err() { break; }
                }
            }
        }
    });

    let event_loop = EventLoop::new().expect("winit: Event-Loop");
    // Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
    // pollen nebenbei den Befehls-Channel.
    event_loop.set_control_flow(ControlFlow::Poll);
    let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio);
    event_loop.run_app(&mut app).expect("winit: run");
}

/// Alle Blender-Modelle (`.glb`) eines Verzeichnisses laden (sortiert →
/// deterministische Reihenfolge). Fehlende Verzeichnisse sind kein Fehler
/// (kein Props-Ordner = keine Props); kaputte Dateien werden gemeldet und
/// übersprungen.
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
    let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
    let mut paths: Vec<std::path::PathBuf> = entries.flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("glb"))
        .collect();
    paths.sort();
    let mut models = Vec::new();
    for p in paths {
        let path = p.to_string_lossy();
        match gltf::load(&path) {
            Ok(m) => {
                let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
                let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
                println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
                    m.objects.len(), m.empties.len());
                // Custom Properties sichtbar machen — noch konsumiert sie
                // niemand, aber die Autorin sieht so, dass sie ankommen.
                for o in &m.objects {
                    for (k, v) in &o.props { println!("[model]   {} · {k} = {v}", o.name); }
                }
                for e in &m.empties {
                    println!("[model]   Empty {} @ {:?}", e.name, e.pos);
                    for (k, v) in &e.props { println!("[model]   {} · {k} = {v}", e.name); }
                }
                models.push((name, m));
            }
            Err(e) => eprintln!("[model] {e}"),
        }
    }
    models
}

/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
    match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) {
        Ok(w) => Some(audio::Sound::new(w)),
        Err(e) => {
            eprintln!("[audio] {e}");
            None
        }
    }
}

/// Welt-Textur laden. Autorinnen-Content: fehlt oder klemmt die Datei, wird
/// gemeldet und der Platzhalter genommen — der selbst ist Projekt-Asset und
/// darf hart scheitern.
fn load_texture(name: &str) -> Image {
    match tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) {
        Ok(img) => img,
        Err(e) => {
            eprintln!("[tex] {e} — Platzhalter");
            tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
                .expect("Platzhalter-Textur (Projekt-Asset)")
        }
    }
}

/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
/// den Frames akkumuliert und nach dem Anwenden genullt.
#[derive(Default)]
struct Input {
    fwd:      bool,
    back:     bool,
    left:     bool,
    right:    bool,
    up:       bool,
    down:     bool,
    /// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht.
    jump:     bool,
    mouse_dx: f32,
    mouse_dy: f32,
    /// Cursor gefangen → Maus-Look aktiv.
    grabbed:  bool,
}

struct App {
    window:  Option<Arc<Window>>,
    gpu:     Option<Gpu>,
    session: Session,
    rx:      Receiver<String>,
    /// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
    camera:  Camera,
    /// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
    eye_y:   f32,
    input:   Input,
    last:    Option<Instant>,
    /// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
    cursor_win: [f32; 2],
    /// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst.
    pending_click: bool,
    /// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die
    /// GPU geladen.
    mesh:        Mesh,
    images:      Vec<Image>,
    ui_textures: Vec<Image>,
    fonts:       font::Fonts,
    /// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der
    /// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal
    /// melden, nicht jeden Klick).
    audio:     Option<audio::Audio>,
    sfx_cache: HashMap<String, Option<audio::Sound>>,
}

impl App {
    fn new(
        session: Session,
        rx: Receiver<String>,
        mesh: Mesh,
        images: Vec<Image>,
        ui_textures: Vec<Image>,
        fonts: font::Fonts,
        audio: Option<audio::Audio>,
    ) -> Self {
        // Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
        let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
        Self {
            window:  None,
            gpu:     None,
            session,
            rx,
            // Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
            camera:  Camera::new([0.0, 1.0, 4.0]),
            eye_y,
            input:   Input::default(),
            last:    None,
            cursor_win: [0.0, 0.0],
            pending_click: false,
            mesh,
            images,
            ui_textures,
            fonts,
            audio,
            sfx_cache: HashMap::new(),
        }
    }

    // Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
    // wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
    // Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
    fn set_grab(&mut self, grab: bool) {
        let Some(window) = &self.window else { return; };
        if grab {
            // Locked (Cursor fixiert) ist am angenehmsten, wird aber nicht
            // überall unterstützt → auf Confined zurückfallen.
            let ok = window.set_cursor_grab(CursorGrabMode::Locked)
                .or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
                .is_ok();
            if ok { self.input.grabbed = true; }
        } else {
            let _ = window.set_cursor_grab(CursorGrabMode::None);
            self.input.grabbed = false;
        }
    }
}

impl ApplicationHandler for App {
    /// winit liefert das Fenster erst hier, nicht beim Loop-Start —
    /// deshalb sind `window`/`gpu` Options statt Konstruktor-Felder.
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.window.is_some() { return; }
        let attrs = Window::default_attributes()
            .with_title("wds")
            // 2× interne Auflösung als Startgröße; frei resizebar.
            .with_inner_size(winit::dpi::LogicalSize::new(
                gpu::INTERNAL_W * 2, gpu::INTERNAL_H * 2));
        let window = Arc::new(event_loop.create_window(attrs).expect("winit: Fenster"));
        // OS-Cursor dauerhaft aus — das Overlay zeichnet immer einen eigenen.
        window.set_cursor_visible(false);
        self.gpu = Some(Gpu::new(
            window.clone(),
            event_loop.owned_display_handle(),
            &self.mesh,
            &self.images,
            &self.ui_textures,
        ));
        self.window = Some(window);
    }

    /// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll,
    /// solange der Cursor gefangen ist.
    fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
        if let DeviceEvent::MouseMotion { delta } = event
            && self.input.grabbed {
            self.input.mouse_dx += delta.0 as f32;
            self.input.mouse_dy += delta.1 as f32;
        }
    }

    /// Läuft einmal pro Loop-Durchlauf, nachdem die Events abgearbeitet
    /// sind: Konsolen-Befehle einspielen und den nächsten Frame anfordern.
    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
        while let Ok(line) = self.rx.try_recv() {
            let r = self.session.exec(&line);
            for out in r.output { println!("{out}"); }
            if r.quit { event_loop.exit(); }
        }
        if let Some(w) = &self.window { w.request_redraw(); }
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        match event {
            WindowEvent::CloseRequested => event_loop.exit(),
            WindowEvent::CursorMoved { position, .. } => {
                self.cursor_win = [position.x as f32, position.y as f32];
            }
            WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
                // Klick nur vormerken; der nächste redraw entscheidet (mit der
                // frisch berechneten Hover-Aktion) zwischen UI-Klick und
                // Flycam-Greifen.
                self.pending_click = true;
            }
            WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
            WindowEvent::Resized(size) => {
                if let Some(gpu) = &mut self.gpu {
                    gpu.resize(size.width, size.height);
                }
            }
            WindowEvent::RedrawRequested => self.redraw(event_loop),
            _ => {}
        }
    }
}

impl App {
    fn on_key(&mut self, event_loop: &ActiveEventLoop, key: KeyEvent) {
        let PhysicalKey::Code(code) = key.physical_key else { return; };
        let down = key.state == ElementState::Pressed;

        // Escape togglet das Menü über denselben exec-Trichter wie Konsole
        // und Klick. Der Lock-Zustand folgt danach automatisch dem Modus
        // (siehe redraw). Im Dialog tut Escape nichts (Dialog endet über
        // seine Choices).
        if code == KeyCode::Escape && down {
            if !matches!(self.session.mode, Mode::Dialog(_)) {
                let r = self.session.exec("menu");
                for out in r.output { println!("{out}"); }
                if r.quit { event_loop.exit(); }
            }
            return;
        }

        match code {
            KeyCode::KeyW => self.input.fwd   = down,
            KeyCode::KeyS => self.input.back  = down,
            KeyCode::KeyA => self.input.left  = down,
            KeyCode::KeyD => self.input.right = down,
            // Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
            // nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
            KeyCode::Space => {
                self.input.up = down;
                if down && !key.repeat { self.input.jump = true; }
            }
            KeyCode::ShiftLeft  => self.input.down = down,
            _ => {}
        }
    }

    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
        // dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
        // während die Konsole den Dialog treibt.
        let now = Instant::now();
        let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());

        // Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
        // die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
        // gibt es keine parallele Lock-Logik.
        let fly  = matches!(self.session.mode, Mode::Free);
        let play = matches!(self.session.mode, Mode::FirstPerson);
        let grab = fly || play;
        if grab != self.input.grabbed { self.set_grab(grab); }

        if fly {
            // Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
            self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
            let fwd   = axis(self.input.fwd,   self.input.back);
            let right = axis(self.input.right, self.input.left);
            let up    = axis(self.input.up,    self.input.down);
            self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
        } else if play {
            // Eingaben einsammeln und die Simulation in der Session treiben.
            let input = FrameInput {
                fwd:     axis(self.input.fwd,   self.input.back),
                right:   axis(self.input.right, self.input.left),
                jump:    self.input.jump,
                look_dx: self.input.mouse_dx,
                look_dy: self.input.mouse_dy,
            };
            // Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
            for out in self.session.tick(&input, dt) { println!("{out}"); }
        }
        // Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
        // Blick beim Fortsetzen um die aufgestaute Bewegung.
        self.input.mouse_dx = 0.0;
        self.input.mouse_dy = 0.0;
        self.input.jump = false;

        // Audio: Distance ramps der Ambient-Emitter nachführen und
        // aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue
        // immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt.
        let sfx = self.session.take_sounds();
        if let Some(a) = &self.audio {
            a.set_ambient_gains(&self.session.emitter_gains());
            for name in sfx {
                let sound = self.sfx_cache.entry(name.clone())
                    .or_insert_with(|| load_sound(&name));
                if let Some(s) = sound { a.play(s); }
            }
        }

        // Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
        // den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
        // folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
        // In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
        // die Kamera reagieren bzw. mit der Flycam synchron bleiben.
        let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
        if play && self.session.player.grounded {
            let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
            self.eye_y += (target_eye - self.eye_y) * a;
            self.eye_y = self.eye_y.clamp(target_eye - EYE_MAX_LAG, target_eye + EYE_MAX_LAG);
        } else {
            self.eye_y = target_eye;
        }

        // View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
        // andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
        // (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
        // Sicht bleibt die First-Person-Sicht.
        let view = if fly {
            self.camera.view()
        } else {
            let p = self.session.player.pos;
            Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
        };
        // Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
        // Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
        // löst beim Klick unten das `use` aus.
        let aim: Option<String> = if play {
            let eye = self.session.player.eye();
            let dir = self.session.player.look_dir();
            self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
        } else {
            None
        };

        // Mausposition über die Letterbox in interne Pixel mappen.
        let cursor = ui::Cursor {
            pos: self.gpu.as_ref()
                .map(|g| g.map_cursor(self.cursor_win))
                .unwrap_or([0.0, 0.0]),
            grabbed: self.input.grabbed,
            hud_interact: aim.is_some(),
        };
        // Overlay state-driven bauen (verzweigt nach session.mode); die
        // Hover-Aktion kommt aus der Maus-Position.
        let screen = ui::layout(
            [gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
        );

        // Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
        // wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
        // Spielmodus das Fadenkreuz-Ziel als `use <name>`.
        if self.pending_click {
            self.pending_click = false;
            let action = screen.hover_action.clone()
                .or_else(|| aim.map(|name| format!("use {name}")));
            if let Some(action) = action {
                let r = self.session.exec(&action);
                for out in r.output { println!("{out}"); }
                if r.quit { event_loop.exit(); }
            }
        }

        if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); }
    }
}

/// Zwei gegensätzliche Tasten zu einer Achse in {-1, 0, 1}.
fn axis(positive: bool, negative: bool) -> f32 {
    (positive as i32 - negative as i32) as f32
}
