//! The [`ScriptHost`] — the runtime that compiles and runs each entity's script.
//!
//! The host is the scripting counterpart to physics' `PhysicsWorld`: a transient
//! [`App`] resource that holds the per-entity runtime state (compiled AST +
//! persistent scope) the authored [`Script`] components do **not**. A single
//! system, [`run_scripts`], drives it each frame:
//!
//! 1. find every entity with an enabled [`Script`];
//! 2. resolve each one's `.rhai` source through the [`AssetDatabase`] +
//!    [`AssetServer`](oxide_engine::asset::AssetServer);
//! 3. (re)compile and `start` any script that is new or whose source changed;
//! 4. stage the entity's [`Transform`] into the engine, call `update(dt)`, and
//!    write the (possibly mutated) transform back.
//!
//! Because runtime state lives here keyed by entity — never on the component —
//! play-mode snapshot/restore is unaffected. **Live reload** falls out of step
//! 3: the host keeps each script's asset [`Handle`] alive, so when the file
//! watcher reruns the loader in place on a disk edit, the next frame reads the
//! new source through that handle and recompiles — no restart, scene state
//! preserved (see `examples/script_spin`). A script that fails to compile or
//! raises a runtime error is **paused** (its error remembered) rather than
//! retried every frame or allowed to crash the host.

use std::collections::HashMap;

use oxide_engine::app::App;
use oxide_engine::asset::{AssetDatabase, AssetUid, Handle};
use oxide_engine::math::Transform;
use oxide_engine::scene::{DespawnPolicy, Entity};
use rhai::Scope;

use crate::bridge::{EntityHandle, ScriptCommand};
use crate::{CompiledScript, Script, ScriptAsset, ScriptEngine};

/// The compiled, runnable form of a started script: its AST and the persistent
/// scope that carries top-level `let` state across frames.
struct Runnable {
    compiled: CompiledScript,
    scope: Scope<'static>,
}

/// Per-entity script runtime state.
struct ScriptState {
    /// Which asset is compiled here (recompile when it changes).
    uid: AssetUid,
    /// The live handle to the script asset. Held so the asset stays cached and
    /// the file watcher can reload fresh source **into it in place** — that
    /// in-place update is what makes live reload work: the next frame reads the
    /// new source through this handle and recompiles.
    handle: Handle<ScriptAsset>,
    /// The exact source compiled, so a content change (e.g. from a live reload)
    /// triggers a recompile.
    source: String,
    /// The runnable script, or `None` if it failed to compile/start.
    runnable: Option<Runnable>,
    /// If set, the script is paused after this error and skipped until its
    /// source changes. Surfaced to the editor console in a later piece.
    paused_error: Option<String>,
}

impl ScriptState {
    /// Whether the script should run its per-frame `update` this frame.
    fn is_runnable(&self) -> bool {
        self.paused_error.is_none() && self.runnable.is_some()
    }
}

/// The scripting runtime resource. Add it via
/// [`ScriptModule`](crate::ScriptModule); [`run_scripts`] drives it each frame.
pub struct ScriptHost {
    engine: ScriptEngine,
    states: HashMap<Entity, ScriptState>,
}

impl ScriptHost {
    /// A host with a fresh engine and no live scripts.
    pub fn new() -> Self {
        Self {
            engine: ScriptEngine::new(),
            states: HashMap::new(),
        }
    }

    /// The number of scripts currently live (compiled or paused) in the host.
    pub fn live_count(&self) -> usize {
        self.states.len()
    }

    /// The remembered error for `entity`'s script, if it is paused.
    pub fn error_of(&self, entity: Entity) -> Option<&str> {
        self.states
            .get(&entity)
            .and_then(|s| s.paused_error.as_deref())
    }

    /// Compiles `asset` and runs its `start` (top level + `init`), yielding a
    /// [`Runnable`] or the error string that paused it.
    fn compile_and_start(engine: &ScriptEngine, asset: &ScriptAsset) -> Result<Runnable, String> {
        let compiled = engine.compile(asset).map_err(|e| e.to_string())?;
        let mut scope = Scope::new();
        engine
            .start(&mut scope, &compiled)
            .map_err(|e| e.to_string())?;
        Ok(Runnable { compiled, scope })
    }

    /// Drives one frame: (re)compiles changed scripts and runs `update(dt)` for
    /// each live one, applying transform changes back to the scene.
    fn run_frame(&mut self, app: &mut App, dt: f32) {
        // 1. Snapshot the enabled, source-bearing scripts (immutable scene
        //    borrow), so we can mutate the scene later without a borrow clash.
        let scripted: Vec<(Entity, AssetUid)> = app
            .scene
            .world()
            .query::<&Script>()
            .iter()
            .filter_map(|(e, s)| {
                if s.enabled {
                    s.source.uid().map(|uid| (e, uid))
                } else {
                    None
                }
            })
            .collect();

        // Forget state for entities that lost or disabled their script.
        let live: std::collections::HashSet<Entity> = scripted.iter().map(|(e, _)| *e).collect();
        self.states.retain(|e, _| live.contains(e));

        // 2. Resolve each script to a live handle + its current source text
        //    (still an immutable App borrow). A handle already held for the same
        //    asset is reused — keeping it cached so the watcher's in-place reload
        //    reaches it — so only a brand-new script touches the database/disk.
        //    No database ⇒ nothing to run.
        let mut resolved: Vec<(Entity, AssetUid, Handle<ScriptAsset>, ScriptAsset)> = Vec::new();
        let db = app.get_resource::<AssetDatabase>();
        for (e, uid) in &scripted {
            let handle = match self.states.get(e) {
                Some(st) if st.uid == *uid => Some(st.handle.clone()),
                _ => db.and_then(|db| db.load::<ScriptAsset>(&app.assets, *uid)),
            };
            if let Some(handle) = handle {
                if let Some(asset) = handle.wait() {
                    resolved.push((*e, *uid, handle, (*asset).clone()));
                }
            }
        }

        // 3 + 4. Compile/start as needed, then run update — staging the entity's
        //    pose into the engine around each call and writing it back so both
        //    `init` and `update` transform changes land in the scene.
        for (e, uid, handle, asset) in resolved {
            // The entity's current local pose; mutated by start/update below.
            let mut transform = app.scene.local_transform(e).unwrap_or(Transform::IDENTITY);
            let mut dirty = false;
            // Scene mutations (spawn/despawn/component edits) the script requested
            // this frame, applied after its transform is written back.
            let mut pending = Vec::new();

            // (Re)start when the script is new, points at a different asset, or
            // its source changed (the live-reload trigger).
            let needs_start = match self.states.get(&e) {
                Some(st) => st.uid != uid || st.source != asset.source,
                None => true,
            };
            if needs_start {
                self.engine.set_context(e, transform, dt);
                let (runnable, paused_error) = match Self::compile_and_start(&self.engine, &asset) {
                    Ok(r) => {
                        // `init` may have moved the entity / issued commands —
                        // carry both out.
                        transform = self.engine.take_transform();
                        pending.append(&mut self.engine.take_commands());
                        dirty = true;
                        (Some(r), None)
                    }
                    Err(err) => {
                        // Discard any commands the failed start partially buffered.
                        let _ = self.engine.take_commands();
                        log::warn!(target: "oxide_script", "script '{}' paused: {err}", asset.name);
                        (None, Some(err))
                    }
                };
                self.states.insert(
                    e,
                    ScriptState {
                        uid,
                        handle,
                        source: asset.source.clone(),
                        runnable,
                        paused_error,
                    },
                );
            }

            let state = self
                .states
                .get_mut(&e)
                .expect("just inserted or pre-existing");
            if state.is_runnable() {
                let runnable = state.runnable.as_mut().expect("runnable when is_runnable");
                self.engine.set_context(e, transform, dt);
                match self
                    .engine
                    .run_update(&mut runnable.scope, &runnable.compiled, dt)
                {
                    Ok(()) => {
                        transform = self.engine.take_transform();
                        pending.append(&mut self.engine.take_commands());
                        dirty = true;
                    }
                    Err(err) => {
                        let _ = self.engine.take_commands();
                        log::warn!(target: "oxide_script", "script '{}' paused: {err}", runnable.compiled.name);
                        state.paused_error = Some(err.to_string());
                    }
                }
            }

            if dirty {
                app.scene.set_local_transform(e, transform);
            }
            // Apply structural changes last, so an explicit component edit (e.g.
            // a `set_component(entity(), "Transform", …)`) wins over the staged
            // transform write above.
            if !pending.is_empty() {
                Self::apply_commands(app, pending);
            }
        }
    }

    /// Applies a script's buffered [`ScriptCommand`]s against the scene and the
    /// reflection registry (`app.types`), resolving each provisional `spawn` id
    /// to the real [`Entity`] it created. Commands run in issue order, so a
    /// script can spawn an entity and immediately configure it. Component edits
    /// that name an unregistered type or fail to parse are logged and skipped —
    /// one bad command never aborts the rest or crashes the host.
    fn apply_commands(app: &mut App, commands: Vec<ScriptCommand>) {
        // Maps a `spawn`'s provisional id to the entity it created this frame.
        let mut spawned: HashMap<u64, Entity> = HashMap::new();

        // Resolves a handle to a live entity, or `None` if it names a provisional
        // id that was never spawned (a script bug — skipped, not fatal).
        let resolve = |target: EntityHandle, spawned: &HashMap<u64, Entity>| match target {
            EntityHandle::Real(e) => Some(e),
            EntityHandle::Provisional(id) => spawned.get(&id).copied(),
        };

        for cmd in commands {
            match cmd {
                ScriptCommand::Spawn { provisional, name } => {
                    let e = app.scene.spawn(name, Transform::IDENTITY);
                    spawned.insert(provisional, e);
                }
                ScriptCommand::Despawn { target } => {
                    if let Some(e) = resolve(target, &spawned) {
                        app.scene.despawn(e, DespawnPolicy::Recursive);
                    }
                }
                ScriptCommand::AddComponent { target, type_name } => {
                    if let Some(e) = resolve(target, &spawned) {
                        if let Err(err) =
                            app.types.add_default(app.scene.world_mut(), e, &type_name)
                        {
                            log::warn!(target: "oxide_script", "add_component({type_name}) failed: {err}");
                        }
                    }
                }
                ScriptCommand::SetComponent {
                    target,
                    type_name,
                    ron,
                } => {
                    if let Some(e) = resolve(target, &spawned) {
                        if let Err(err) =
                            app.types
                                .set_ron(app.scene.world_mut(), e, &type_name, &ron)
                        {
                            log::warn!(target: "oxide_script", "set_component({type_name}) failed: {err}");
                        }
                    }
                }
                ScriptCommand::RemoveComponent { target, type_name } => {
                    if let Some(e) = resolve(target, &spawned) {
                        if let Err(err) = app.types.remove(app.scene.world_mut(), e, &type_name) {
                            log::warn!(target: "oxide_script", "remove_component({type_name}) failed: {err}");
                        }
                    }
                }
            }
        }
    }
}

impl Default for ScriptHost {
    fn default() -> Self {
        Self::new()
    }
}

/// The per-frame system: takes the [`ScriptHost`] out, drives it, puts it back.
///
/// Mirrors physics' `step_physics`: removing the resource hands the system
/// exclusive ownership of the host while it borrows the [`App`] (scene + assets)
/// mutably, then it is re-inserted.
pub(crate) fn run_scripts(app: &mut App) {
    let Some(mut host) = app.remove_resource::<ScriptHost>() else {
        return;
    };
    let dt = app.time.delta;
    host.run_frame(app, dt);
    app.insert_resource(host);
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxide_engine::app::{App, DefaultModules, Schedule};
    use oxide_engine::asset::AssetDatabase;
    use oxide_engine::math::Vec3;

    /// Builds an app with a temp project whose `assets/scripts/` holds `source`
    /// under `name.rhai`, the script module, and an entity running that script.
    /// Returns the app and the entity.
    fn app_with_script(name: &str, source: &str) -> (App, Entity, tempdir::TempProject) {
        let project = tempdir::TempProject::new();
        let rel = format!("scripts/{name}.rhai");
        project.write_asset(&rel, source);

        let mut db = AssetDatabase::new(project.root());
        let uid = db.register(&rel);

        let mut app = App::new();
        app.add_modules(DefaultModules);
        app.add_module(crate::ScriptModule);
        app.insert_resource(db);

        let e = app
            .scene
            .spawn("scripted", Transform::from_translation(Vec3::ZERO));
        app.scene
            .world_mut()
            .insert_one(e, Script::new(oxide_engine::asset::AssetRef::new(uid)))
            .unwrap();

        (app, e, project)
    }

    #[test]
    fn update_moves_the_entity_transform_each_frame() {
        // Moves +1 on X per second; with dt = 0.5 that is +0.5 per frame.
        let (mut app, e, _p) =
            app_with_script("move", "fn update(dt) { translate(dt, 0.0, 0.0); }");

        app.update(0.5);
        let x1 = app.scene.local_transform(e).unwrap().translation.x;
        assert!((x1 - 0.5).abs() < 1e-6, "after one frame x = {x1}");

        app.update(0.5);
        let x2 = app.scene.local_transform(e).unwrap().translation.x;
        assert!((x2 - 1.0).abs() < 1e-6, "after two frames x = {x2}");
    }

    #[test]
    fn init_runs_once_before_update() {
        // init sets x to 10; update adds 1 each frame. After 2 frames: 12.
        let (mut app, e, _p) = app_with_script(
            "initd",
            "fn init() { set_position(vec3(10.0, 0.0, 0.0)); } \
             fn update(dt) { translate(1.0, 0.0, 0.0); }",
        );
        app.update(0.016);
        app.update(0.016);
        let x = app.scene.local_transform(e).unwrap().translation.x;
        assert!((x - 12.0).abs() < 1e-6, "x = {x}");
    }

    #[test]
    fn a_disabled_script_does_not_run() {
        let (mut app, e, _p) =
            app_with_script("dis", "fn update(dt) { translate(1.0, 0.0, 0.0); }");
        // Disable it before any frame.
        app.scene.get_mut::<Script>(e).unwrap().enabled = false;
        app.update(0.5);
        let x = app.scene.local_transform(e).unwrap().translation.x;
        assert_eq!(x, 0.0);
    }

    #[test]
    fn a_runtime_error_pauses_the_script_and_keeps_the_app_alive() {
        let (mut app, e, _p) = app_with_script("boom", "fn update(dt) { throw \"nope\"; }");
        app.update(0.5); // raises, must not panic
        let host = app.get_resource::<ScriptHost>().unwrap();
        assert!(host.error_of(e).is_some(), "script should be paused");
        // A second frame is a no-op (still paused), app stays alive.
        app.update(0.5);
    }

    #[test]
    fn editing_the_script_file_live_reloads_behavior() {
        use oxide_engine::watch::{reload_changed_assets, ChangeEvent, ChangeKind};

        // Starts moving on +X.
        let (mut app, e, p) =
            app_with_script("spin", "fn update(dt) { translate(1.0, 0.0, 0.0); }");
        app.update(0.5);
        let after_first = app.scene.local_transform(e).unwrap().translation;
        assert!((after_first.x - 1.0).abs() < 1e-6, "x = {}", after_first.x);
        assert_eq!(after_first.y, 0.0);

        // Edit the script on disk to move on +Y instead, then reload it the way
        // the editor's file watcher does: rerun the loader in place for the
        // changed path. The host holds the handle alive, so this updates the
        // very asset the next frame reads — no restart, scene state preserved.
        let abs = {
            let db = app.get_resource::<AssetDatabase>().unwrap();
            let uid = db.uid_of("scripts/spin.rhai").unwrap();
            db.absolute_path(uid).unwrap()
        };
        p.write_asset(
            "scripts/spin.rhai",
            "fn update(dt) { translate(0.0, 1.0, 0.0); }",
        );
        let reloaded = reload_changed_assets(
            &app.assets,
            [ChangeEvent {
                path: abs,
                kind: ChangeKind::Modified,
            }],
        );
        assert_eq!(reloaded, 1, "the live script handle should reload in place");

        // The entity keeps its position (state preserved) and now moves on +Y.
        app.update(0.5);
        let after_reload = app.scene.local_transform(e).unwrap().translation;
        assert!(
            (after_reload.x - 1.0).abs() < 1e-6,
            "x preserved across reload, got {}",
            after_reload.x
        );
        assert!(
            (after_reload.y - 1.0).abs() < 1e-6,
            "now moving on Y, got {}",
            after_reload.y
        );
    }

    #[test]
    fn a_script_spawns_configures_and_despawns_through_ron() {
        use serde::{Deserialize, Serialize};

        #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
        struct Health {
            current: i64,
            max: i64,
        }

        // On init the script spawns an enemy and authors its Health via RON in
        // the same frame (the provisional handle is resolved by the host), and
        // spawns + immediately despawns a throwaway entity.
        let (mut app, _e, _p) = app_with_script(
            "spawner",
            r#"
            fn init() {
                let enemy = spawn_entity("Enemy");
                set_component(enemy, "Health", "(current: 30, max: 100)");

                let temp = spawn_entity("Temp");
                set_component(temp, "Health", "(current: 1, max: 1)");
                despawn(temp);
            }
            "#,
        );
        app.types.register::<Health>("Health");

        app.update(0.016);

        // Exactly one entity carries Health — the enemy; the temp was despawned —
        // with the RON-authored values.
        let healths: Vec<(Entity, Health)> = app
            .scene
            .world()
            .query::<&Health>()
            .iter()
            .map(|(e, h)| (e, h.clone()))
            .collect();
        assert_eq!(
            healths.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
            vec![Health {
                current: 30,
                max: 100
            }]
        );

        // And it round-trips back out through the registry as RON.
        let enemy = healths[0].0;
        let ron = app
            .types
            .get_ron(app.scene.world(), enemy, "Health")
            .unwrap();
        assert!(
            ron.contains("30") && ron.contains("100"),
            "round-trip RON: {ron}"
        );
    }

    #[test]
    fn a_script_can_add_then_remove_a_component_across_frames() {
        use serde::{Deserialize, Serialize};

        #[derive(Clone, Serialize, Deserialize)]
        struct Tag {
            v: i64,
        }

        // Frame 1 adds the tag to the script's own entity; frame 2 removes it.
        // The `added` flag lives in the persistent scope across frames.
        let (mut app, e, _p) = app_with_script(
            "tagger",
            r#"
            let added = false;
            fn update(dt) {
                if !added { set_component(entity(), "Tag", "(v: 7)"); added = true; }
                else { remove_component(entity(), "Tag"); }
            }
            "#,
        );
        app.types.register::<Tag>("Tag");

        app.update(0.016);
        assert!(
            app.types.has(app.scene.world(), e, "Tag").unwrap(),
            "frame 1 should have added Tag"
        );

        app.update(0.016);
        assert!(
            !app.types.has(app.scene.world(), e, "Tag").unwrap(),
            "frame 2 should have removed Tag"
        );
    }

    #[test]
    fn scripts_run_on_the_update_schedule() {
        // Sanity: the module registered the system on Update (covered indirectly
        // by the moving-entity test, but assert the phase wiring directly).
        let mut app = App::new();
        app.add_module(crate::ScriptModule);
        let before = app.system_count();
        assert!(before >= 1);
        let _ = Schedule::Update;
    }

    /// A throwaway project directory for tests, cleaned up on drop.
    mod tempdir {
        use std::path::{Path, PathBuf};

        pub struct TempProject {
            root: PathBuf,
        }

        impl TempProject {
            pub fn new() -> Self {
                let mut root = std::env::temp_dir();
                let unique = format!(
                    "oxide-script-host-{}-{}",
                    std::process::id(),
                    COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
                );
                root.push(unique);
                std::fs::create_dir_all(root.join("assets")).unwrap();
                Self { root }
            }

            pub fn root(&self) -> &Path {
                &self.root
            }

            pub fn write_asset(&self, relative: &str, contents: &str) {
                let path = self.root.join("assets").join(relative);
                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
                std::fs::write(path, contents).unwrap();
            }
        }

        impl Drop for TempProject {
            fn drop(&mut self) {
                let _ = std::fs::remove_dir_all(&self.root);
            }
        }

        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    }
}
