// lua.rs --- Lua VM owner. Single VM, single thread, !Send by construction.

//! The Lua boundary.
//!
//! Pmacs runs a single Lua VM on the main thread. The threading model
//! (spec §3 Checkpoint 5 and §6) requires the VM to never move across
//! threads: workers see snapshots of buffers, but the Lua state is
//! exclusively the main thread's. This module's [`LuaHost`] enforces
//! that contract at compile time.
//!
//! # Errors
//!
//! Lua chunks fail in a hundred ways. Pmacs's contract is that none of
//! those failures crash the editor or print to stderr. Every error
//! [`LuaHost::eval`] encounters is captured into the host's error log
//! and surfaced through the editor UI --- the status line for now; once
//! the `*errors*` buffer exists (T M2.8) it will be the canonical sink.

use std::cell::RefCell;
use std::marker::PhantomData;
use std::rc::Rc;
use std::time::SystemTime;

use mlua::{Lua, MultiValue, Value};

use crate::buffer::EditOp;
use crate::buffer_registry::BufferRegistry;
use crate::hook::{HookOutcome, HookRegistry};

/// Canonical name for the error log buffer. Surfacing here so callers
/// can look it up via [`crate::buffer_registry::BufferRegistry::find_by_name`].
pub const ERRORS_BUFFER_NAME: &str = "*errors*";
use crate::command::CommandRegistry;
use crate::keymap_stack::KeymapStack;
use crate::lua_bindings::{
    self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, PackageInstallOverride,
    RequestedAttach, SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack,
    SharedMenuRegistry, SharedRegistry,
};
use crate::menu::MenuRegistry;
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};

// `Rc` and `RefCell` are pulled in for the registry-owning fields.

/// Owner of the Pmacs Lua VM.
///
/// `LuaHost` is `!Send` by construction: the inner `mlua::Lua` is `!Send`
/// without mlua's `send` feature, and a [`PhantomData<Rc<()>>`] field
/// reasserts the contract so it survives any future feature reshuffling
/// or upstream change. This is verified at compile time:
///
/// ```compile_fail
/// use pmacs::lua::LuaHost;
/// fn requires_send<T: Send>(_: T) {}
/// let host = LuaHost::new().unwrap();
/// requires_send(host); // error: `LuaHost` cannot be sent between threads safely
/// ```
///
/// # Threading
///
/// Construct on the main thread; never move off it. A `LuaHost` lives
/// inside [`crate::editor::EditorState`], which itself is owned by the
/// frontend's main loop. Workers (M3) cannot touch the VM directly;
/// they communicate with the main thread via typed messages.
pub struct LuaHost {
    lua: Lua,
    /// Buffer registry (owned by the host so its lifetime tracks the
    /// VM's). Bindings on the Lua side hold their own clones of the
    /// `Rc` and resolve handles through this on every call.
    registry: SharedRegistry,
    /// Command registry: every editor action is a named, introspectable
    /// command. Lua bindings (`pmacs.command.*`) and Rust call sites
    /// (`LuaHost::invoke_command`) share this through `Rc`.
    commands: SharedCommandRegistry,
    /// Keymap stack: per-scope key bindings keyed on chord sequences.
    /// Inputs from the editor's run loop will dispatch through this
    /// once T M2.5 lands; M2.4 builds the system without yet routing
    /// the live event stream through it.
    keymaps: SharedKeymapStack,
    /// Context-menu registry (Q#CM2). Lua bindings (`pmacs.menu.*`) and
    /// the menu builder in the core share this through `Rc`.
    menus: SharedMenuRegistry,
    /// Hook registry. Stub for T M2.11 introspection; T M2.6 will wire
    /// execution at the relevant call sites.
    hooks: SharedHookRegistry,
    /// Optional handle to the editor core. Set by [`Self::attach_editor`].
    /// Required to notify windows of edits that the host applies
    /// directly (e.g. [`Self::append_to_errors_buffer`] writes to the
    /// `*errors*` buffer without going through `editor_core`'s
    /// [`crate::editor_core::EditorCore::apply_active_edit`], so any
    /// window currently displaying that buffer would otherwise hold a
    /// stale [`crate::text_view::TextView`] line cache).
    core: Option<SharedCore>,
    errors: Vec<LuaErrorRecord>,
    /// T M7.8 cancel token. Owns the [`AtomicBool`] the count hook
    /// polls. Hosts hand out [`crate::lua_isolation::CancelHandle`]
    /// clones for cross-thread C-g delivery.
    cancel: crate::lua_isolation::CancelToken,
    _not_send: PhantomData<Rc<()>>,
}

/// A captured Lua error.
#[derive(Clone, Debug)]
pub struct LuaErrorRecord {
    /// Wall-clock time the error was captured.
    pub at: SystemTime,
    /// Source label (file path or chunk name) supplied by the caller, if any.
    pub source: Option<String>,
    /// The error's `Display` rendering. May be multi-line.
    pub message: String,
}

impl LuaHost {
    /// Build a fresh Lua VM with the standard library loaded.
    ///
    /// # Errors
    ///
    /// Returns a [`mlua::Error`] if mlua fails to initialize. In practice
    /// this never happens on a working build; an error here would
    /// indicate a broken Lua C library link.
    pub fn new() -> mlua::Result<Self> {
        Self::with_registry(Rc::new(RefCell::new(BufferRegistry::new())))
    }

    /// Build a host that shares its [`BufferRegistry`] with another
    /// owner (typically [`crate::editor_core::EditorCore`], which
    /// also holds its own clone of the `Rc` so its windows can
    /// resolve buffer ids without going through the host).
    ///
    /// # Errors
    ///
    /// Returns a [`mlua::Error`] if mlua fails to initialize or the
    /// boundary closures fail to register.
    pub fn with_registry(registry: SharedRegistry) -> mlua::Result<Self> {
        let lua = Lua::new();
        let cancel = crate::lua_isolation::CancelToken::new();
        // T M7.8: install the count hook before any chunk runs so even
        // the first eval is interruptible. The hook closure captures
        // an `Arc<AtomicBool>` clone of `cancel`'s flag; subsequent
        // `cancel.cancel()` / `cancel.handle().cancel()` calls are
        // observed within `DEFAULT_INSTRUCTION_BUDGET` instructions.
        crate::lua_isolation::install_cancel_hook(
            &lua,
            &cancel,
            crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET,
        );
        let commands: SharedCommandRegistry = Rc::new(RefCell::new(CommandRegistry::new()));
        let keymaps: SharedKeymapStack = Rc::new(RefCell::new(KeymapStack::new()));
        let menus: SharedMenuRegistry = Rc::new(RefCell::new(MenuRegistry::new()));
        let hooks: SharedHookRegistry = Rc::new(RefCell::new(HookRegistry::new()));
        lua_bindings::install(&lua, &registry, &commands, &keymaps, &menus, &hooks)?;
        Ok(Self {
            lua,
            registry,
            commands,
            keymaps,
            menus,
            hooks,
            core: None,
            errors: Vec::new(),
            cancel,
            _not_send: PhantomData,
        })
    }

    /// Reference the underlying [`mlua::Lua`]. Use sparingly; prefer the
    /// curated APIs that later milestones layer on top.
    pub fn lua(&self) -> &Lua {
        &self.lua
    }

    /// Cross-thread handle for flipping this VM's cancel flag.
    ///
    /// The returned [`crate::lua_isolation::CancelHandle`] is
    /// `Send + Sync` and may be moved or cloned to other threads
    /// (e.g. an input-watching thread that maps C-g to a cancel
    /// request). The next time the count hook runs in the VM (within
    /// [`crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET`]
    /// instructions on lua54; see the LuaJIT-trace caveat in
    /// [`crate::lua_isolation`]) the running chunk aborts with an
    /// [`crate::lua_isolation::IsolationError::Cancelled`].
    #[must_use]
    pub fn cancel_handle(&self) -> crate::lua_isolation::CancelHandle {
        self.cancel.handle()
    }

    /// Flip this VM's cancel flag in-process. Equivalent to
    /// `self.cancel_handle().cancel()` for callers that already hold
    /// `&self`.
    pub fn request_cancel(&self) {
        self.cancel.cancel();
    }

    /// Shared handle to the buffer registry. Both Rust callers (e.g. the
    /// editor's file-open path) and Lua bindings (via app data) read and
    /// mutate this through the same `Rc<RefCell<...>>`.
    pub fn registry(&self) -> &SharedRegistry {
        &self.registry
    }

    /// Shared handle to the command registry.
    pub fn commands(&self) -> &SharedCommandRegistry {
        &self.commands
    }

    /// Shared handle to the keymap stack. The run loop's
    /// [`crate::keymap_stack::KeyDispatcher`] resolves chord sequences
    /// against this stack and dispatches the resulting commands.
    pub fn keymaps(&self) -> &SharedKeymapStack {
        &self.keymaps
    }

    /// Shared handle to the context-menu registry. The menu builder
    /// resolves visible items against this when a right-click opens the
    /// menu (Q#CM2).
    pub fn menus(&self) -> &SharedMenuRegistry {
        &self.menus
    }

    /// Shared handle to the hook registry. T M2.11 surfaces it via
    /// `pmacs.describe.hook`; T M2.6 will wire actual hook execution
    /// into the appropriate editor lifecycle points.
    pub fn hooks(&self) -> &SharedHookRegistry {
        &self.hooks
    }

    /// Install `pmacs.editor.*` and load the builtin command + keymap
    /// chunks.
    ///
    /// Called once by [`crate::editor::EditorState::new`] after
    /// the [`crate::editor_core::EditorCore`] is wrapped into a
    /// [`SharedCore`]. The chunks are embedded at compile time via
    /// [`include_str!`]; their failure is treated as a build defect
    /// rather than a user error and surfaces as `mlua::Error`.
    ///
    /// # Errors
    ///
    /// Propagates any mlua failure from registering the editor table
    /// or loading the builtin chunks.
    pub fn attach_editor(&mut self, core: &SharedCore) -> mlua::Result<()> {
        lua_bindings::install_editor(&self.lua, core)?;
        self.core = Some(core.clone());
        // Hooks first: command bodies in default.lua reference them.
        self.load_builtin(
            "@pmacs/builtin/hooks/default.lua",
            include_str!("../builtin/hooks/default.lua"),
        )?;
        self.load_builtin(
            "@pmacs/builtin/commands/default.lua",
            include_str!("../builtin/commands/default.lua"),
        )?;
        self.load_builtin(
            "@pmacs/builtin/keymaps/default.lua",
            include_str!("../builtin/keymaps/default.lua"),
        )?;
        // Menus last: items reference commands (and conceptually keys),
        // so both registries must be populated first.
        self.load_builtin(
            "@pmacs/builtin/menus/default.lua",
            include_str!("../builtin/menus/default.lua"),
        )?;
        Ok(())
    }

    fn load_builtin(&mut self, source: &str, chunk: &str) -> mlua::Result<()> {
        self.eval(Some(source), chunk).map(|_| ())
    }

    /// Fire the named hook from Rust. Snapshots the registry so the
    /// callbacks may freely re-enter Lua. Errors are appended to the
    /// `*errors*` buffer (same surface as [`Self::eval`]) and reported
    /// via [`HookOutcome::errors`].
    ///
    /// Returns [`None`] if no hook with that name is defined --- the
    /// caller decides whether that is fatal (typo) or no-op (optional
    /// observation point).
    pub fn run_hook(&mut self, name: &str, args: MultiValue) -> Option<HookOutcome> {
        let snapshot = self.hooks.borrow().snapshot(name);
        let (kind, callbacks) = snapshot?;
        let outcome = crate::hook::run_snapshot(kind, &callbacks, args);
        // T M7.8: if any callback observed a cancellation, the flag
        // is still set — reset before the next eval. (Callbacks
        // dispatched after the first cancel observed the still-set
        // flag and aborted as well; that matches the user-intent
        // semantics of C-g during a hook fan-out.)
        let mut saw_cancel = false;
        for err in &outcome.errors {
            if crate::lua_isolation::is_cancellation(&err.error) {
                saw_cancel = true;
            }
            let record = LuaErrorRecord {
                at: SystemTime::now(),
                source: Some(format!("hook:{name}")),
                message: format!("callback at {} raised: {}", err.source.render(), err.error),
            };
            self.append_to_errors_buffer(&record);
            self.errors.push(record);
        }
        if saw_cancel {
            self.cancel.reset();
        }
        Some(outcome)
    }

    /// Invoke a registered command by name. Used by the keymap dispatch
    /// (T M2.4) and any Rust-side call site that needs to fire a
    /// command (e.g. M-x once the minibuffer lands in T M2.7).
    ///
    /// Returns the command body's return values as `mlua::MultiValue`,
    /// or an error wrapping [`crate::command::CommandError::NotFound`]
    /// when the name doesn't resolve, or the body's own error.
    ///
    /// # Errors
    ///
    /// Returns a [`mlua::Error`] if the command is not found, or if the
    /// invocation itself raises.
    pub fn invoke_command(
        &self,
        name: &str,
        args: mlua::MultiValue,
    ) -> mlua::Result<mlua::MultiValue> {
        let body = {
            let r = self.commands.borrow();
            r.get(name)
                .ok_or_else(|| {
                    mlua::Error::external(crate::command::CommandError::NotFound {
                        name: name.to_owned(),
                    })
                })?
                .body
                .clone()
        };
        match body.call::<mlua::MultiValue>(args) {
            Ok(v) => Ok(v),
            Err(e) => {
                // T M7.8: consume the cancel signal once.
                if crate::lua_isolation::is_cancellation(&e) {
                    self.cancel.reset();
                }
                Err(e)
            }
        }
    }

    /// Evaluate a Lua chunk and return the resulting value.
    ///
    /// On failure the error is captured into the host's error log *and*
    /// returned to the caller. Callers who only need the side-effect log
    /// (e.g. running a user config file) can drop the result; callers
    /// who need the value (M-x commands, etc.) get a typed error to act
    /// on.
    ///
    /// `source` is an optional label (file path, chunk name) used in
    /// diagnostics; Lua reports it back in stack traces.
    pub fn eval(&mut self, source: Option<&str>, chunk: &str) -> mlua::Result<Value> {
        // Push the source label into the per-Lua app-data slot so
        // bindings that need the chunk's location (e.g.,
        // `pmacs.packages.install_project`'s relative-path
        // resolution) can read it back. This is the only way to
        // recover chunk source from a Rust callback in pmacs, since
        // the Lua state is built without the `debug` library
        // (`forbid(unsafe_code)` rules out `Lua::unsafe_new`).
        self.lua
            .set_app_data(crate::lua_bindings::CurrentEvalSource(
                source.map(str::to_owned),
            ));
        let mut loader = self.lua.load(chunk);
        if let Some(name) = source {
            loader = loader.set_name(name);
        }
        match loader.eval::<Value>() {
            Ok(v) => Ok(v),
            Err(e) => {
                // T M7.8: a cancellation is consumed exactly once.
                // Reset the flag here so the *next* eval starts
                // fresh. If we left it set, the very first hook tick
                // of the next chunk would abort it without anyone
                // having asked.
                if crate::lua_isolation::is_cancellation(&e) {
                    self.cancel.reset();
                }
                let record = LuaErrorRecord {
                    at: SystemTime::now(),
                    source: source.map(str::to_owned),
                    message: e.to_string(),
                };
                self.append_to_errors_buffer(&record);
                self.errors.push(record);
                Err(e)
            }
        }
    }

    /// Append `record` to the buffer named `*errors*`, creating the
    /// buffer on first use. Errors during the append are themselves
    /// dropped --- the error log is a best-effort surface, and a
    /// failure here would only happen if the buffer were itself
    /// concurrently held in a way the single-threaded contract
    /// rules out.
    fn append_to_errors_buffer(&self, record: &LuaErrorRecord) {
        let line = format!(
            "[{}] {}\n",
            record.source.as_deref().unwrap_or("[chunk]"),
            record.message
        );
        let (id, edit) = {
            let mut reg = self.registry.borrow_mut();
            let id = match reg.find_by_name(ERRORS_BUFFER_NAME) {
                Some(id) => id,
                None => reg.create(ERRORS_BUFFER_NAME),
            };
            let Ok(buf) = reg.get_mut(id) else {
                return;
            };
            let pos = buf.len();
            let Ok(edit) = buf.apply_edit(EditOp::Insert {
                pos,
                bytes: line.as_bytes(),
            }) else {
                return;
            };
            (id, edit)
        };
        // Window TextViews are not attached views on the buffer; they
        // sit on EditorCore and miss the broadcast that
        // `Buffer::apply_edit` performs on its own attached views. If a
        // window is currently displaying `*errors*` (e.g. the user
        // switched to it via C-x b), its line cache would otherwise go
        // stale on every appended error and cursor motion would stop
        // updating the screen.
        //
        // Post-audit-round-6 F32 — also queue the resulting CRDT op
        // for broadcast if the buffer is CRDT-backed. `*errors*`
        // gets upgraded to CRDT at every replica's attach via
        // `send_buffer_snapshots`, so each Lua-runtime-driven append
        // produces an `Edit::crdt_op` that must reach replica
        // `BufferMirror`s — otherwise their mirrors permanently
        // desync from daemon state for `*errors*`.
        if let Some(core) = self.core.as_ref() {
            let mut core = core.borrow_mut();
            core.notify_buffer_edit(id, &edit);
            core.queue_daemon_origin_crdt_op(id, &edit);
        }
    }

    /// `BufferId` of the `*errors*` buffer if it has been created.
    /// Returned by [`crate::editor::EditorState`] so the renderer can
    /// surface it once T M2.8 lands buffer-switching.
    #[must_use]
    pub fn errors_buffer_id(&self) -> Option<crate::buffer::BufferId> {
        self.registry.borrow().find_by_name(ERRORS_BUFFER_NAME)
    }

    /// Snapshot the full text of the `*errors*` buffer as a UTF-8
    /// string (lossy on non-UTF-8 bytes — error messages are routinely
    /// concatenations of arbitrary user data).
    ///
    /// Returns the empty string if the buffer hasn't been created
    /// yet. Used by tests and any introspection tool that needs the
    /// canonical error log without going through the buffer registry
    /// directly. Does not consume or clear the buffer.
    #[must_use]
    pub fn errors_buffer_text(&self) -> String {
        let Some(id) = self.errors_buffer_id() else {
            return String::new();
        };
        let reg = self.registry.borrow();
        let Ok(buf) = reg.get(id) else {
            return String::new();
        };
        let rope = buf.snapshot_rope();
        let len = rope.len();
        if len == 0 {
            return String::new();
        }
        let mut bytes = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
        rope.slice(0, len, &mut bytes);
        String::from_utf8_lossy(&bytes).into_owned()
    }

    /// All captured errors, in arrival order.
    pub fn errors(&self) -> &[LuaErrorRecord] {
        &self.errors
    }

    /// Most recently captured error, if any.
    pub fn last_error(&self) -> Option<&LuaErrorRecord> {
        self.errors.last()
    }

    /// Discard the captured error log.
    ///
    /// Useful once errors have been drained into the `*errors*` buffer
    /// (T M2.8) so the in-memory log doesn't grow without bound.
    pub fn clear_errors(&mut self) {
        self.errors.clear();
    }

    /// Mark the init phase complete.
    ///
    /// Called by [`crate::editor::EditorState::new`] after
    /// [`crate::config::load_user_config`] returns. Lifecycle-affecting
    /// Lua APIs (currently `pmacs.attach`; M5.6d+) gate on this flag
    /// via [`crate::lua_bindings::require_init_phase`] and refuse to
    /// run mid-session.
    ///
    /// Idempotent — calling twice is safe. If [`crate::lua_bindings::install`]
    /// was never called (the flag is missing from app data), this is a
    /// silent no-op rather than a panic; the missing-flag case is
    /// reported the next time a binding tries to read it.
    pub fn set_init_complete(&self) {
        if let Some(flag) = self.lua.app_data_ref::<InitCompleteFlag>() {
            flag.set_complete();
        }
    }

    /// Re-open the init phase (test/dev only). Counterpart to
    /// [`Self::set_init_complete`]: integration tests that exercise
    /// init-only Lua APIs against a fully-constructed
    /// [`crate::editor::EditorState`] use this to reset the flag
    /// the editor flips during startup. Marked `#[doc(hidden)]` to
    /// keep it out of the user-facing surface; production code
    /// never re-opens init phase after a single startup flip.
    #[doc(hidden)]
    pub fn reopen_init_phase_for_testing(&self) {
        if let Some(flag) = self.lua.app_data_ref::<InitCompleteFlag>() {
            flag.reopen_for_testing();
        }
    }

    /// Whether the init phase has finished. Mirrors the
    /// [`InitCompleteFlag`] state for callers that need to introspect
    /// without going through Lua app data themselves.
    #[must_use]
    pub fn is_init_complete(&self) -> bool {
        self.lua
            .app_data_ref::<InitCompleteFlag>()
            .is_some_and(|f| f.is_complete())
    }

    /// Consume the attach target requested by `pmacs.attach{...}` from
    /// init.lua, if any.
    ///
    /// Called by [`crate::editor::EditorState::new`] (or its eventual
    /// dispatcher) after [`crate::config::load_user_config`] returns,
    /// to decide whether to run the local editor or hand off to attach
    /// mode against a remote daemon.
    ///
    /// Returns `None` when init.lua did not call `pmacs.attach{...}`,
    /// or when the request has already been consumed.
    #[must_use]
    pub fn take_requested_attach(&self) -> Option<AttachTarget> {
        self.lua
            .app_data_ref::<RequestedAttach>()
            .and_then(|r| r.take())
    }

    /// Record the current outbound attachment so `pmacs.current_attachment()`
    /// returns a populated handle.
    ///
    /// In v0.1 there is no production-side caller — Local mode never
    /// completes a remote attach (the process hands off to attach mode,
    /// which has no `LuaHost`), and Daemon mode is the *target* of
    /// attachments. The setter exists for tests and for the future
    /// dispatcher (M5.6g+) to wire up.
    pub fn set_current_attachment(&self, handle: AttachmentHandle) {
        if let Some(slot) = self.lua.app_data_ref::<CurrentAttachmentSlot>() {
            slot.set(handle);
        }
    }

    /// Clear any recorded current attachment. No-op if already empty
    /// or if [`crate::lua_bindings::install`] was never called.
    pub fn clear_current_attachment(&self) {
        if let Some(slot) = self.lua.app_data_ref::<CurrentAttachmentSlot>() {
            slot.clear();
        }
    }

    /// Read the current attachment without consuming it. Mirrors what
    /// `pmacs.current_attachment()` exposes to Lua, but returns the
    /// Rust struct instead of a Lua table.
    #[must_use]
    pub fn current_attachment(&self) -> Option<AttachmentHandle> {
        self.lua
            .app_data_ref::<CurrentAttachmentSlot>()
            .and_then(|s| s.get())
    }

    /// Install a [`PackageInstallOverride`] so subsequent
    /// `pmacs.packages.install{...}` calls redirect their cache and
    /// install roots away from `$XDG_*` defaults.
    ///
    /// Production code does not call this. Tests use it because the
    /// project's `forbid(unsafe_code)` rules out `std::env::set_var`
    /// (which has been `unsafe` since Rust 2024).
    pub fn set_package_install_override(&self, override_: PackageInstallOverride) {
        self.lua.set_app_data(override_);
    }

    /// Override the instance name reported by `pmacs.instance.identity()`
    /// (M5.6f).
    ///
    /// Daemon mode calls this with the value the user passed via
    /// `--socket NAME`; Local mode never calls it (the slot's default
    /// `None` is correct for an in-process editor with no remote name).
    /// No-op if [`crate::lua_bindings::install`] was never called.
    pub fn set_instance_name(&self, name: Option<String>) {
        if let Some(info) = self.lua.app_data_ref::<LocalInstanceInfo>() {
            info.set_name(name);
        }
    }

    /// Override the `started` anchor used by `pmacs.instance.identity()`
    /// to compute uptime.
    ///
    /// Daemon mode calls this with its own `DaemonState::started` so
    /// the uptime reported via `pmacs.instance.*` matches what the
    /// daemon hands back over its `Hello`. Local mode leaves the
    /// install-time default in place.
    pub fn set_instance_started(&self, started: std::time::Instant) {
        if let Some(info) = self.lua.app_data_ref::<LocalInstanceInfo>() {
            info.set_started(started);
        }
    }

    /// Build an [`InstanceIdentity`] from the [`LocalInstanceInfo`]
    /// slot. Mirrors what `pmacs.instance.identity()` returns to Lua,
    /// for Rust-side callers (tests, the future M5.6g dispatcher).
    /// Returns `None` if [`crate::lua_bindings::install`] was never
    /// called on this state.
    #[must_use]
    pub fn local_instance_identity(&self) -> Option<InstanceIdentity> {
        self.lua
            .app_data_ref::<LocalInstanceInfo>()
            .map(|info| info.build_identity())
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn trivial_script_runs() {
        let mut host = LuaHost::new().unwrap();
        let v = host.eval(None, "return 1 + 2").unwrap();
        match v {
            Value::Integer(n) => assert_eq!(n, 3),
            other => panic!("expected integer, got {other:?}"),
        }
        assert!(host.errors().is_empty());
    }

    #[test]
    fn syntax_error_is_captured_not_panicked() {
        let mut host = LuaHost::new().unwrap();
        let result = host.eval(Some("bad_chunk"), "this is not valid lua )");
        assert!(result.is_err());
        let last = host.last_error().expect("error captured");
        assert_eq!(last.source.as_deref(), Some("bad_chunk"));
        assert!(!last.message.is_empty());
    }

    #[test]
    fn runtime_error_is_captured() {
        let mut host = LuaHost::new().unwrap();
        let result = host.eval(None, "error('boom')");
        assert!(result.is_err());
        assert_eq!(host.errors().len(), 1);
        assert!(host.last_error().unwrap().message.contains("boom"));
    }

    #[test]
    fn multiple_errors_accumulate_in_order() {
        let mut host = LuaHost::new().unwrap();
        let _ = host.eval(None, "error('first')");
        let _ = host.eval(None, "error('second')");
        let errs = host.errors();
        assert_eq!(errs.len(), 2);
        assert!(errs[0].message.contains("first"));
        assert!(errs[1].message.contains("second"));
    }

    #[test]
    fn clear_errors_drains_the_log() {
        let mut host = LuaHost::new().unwrap();
        let _ = host.eval(None, "error('x')");
        assert_eq!(host.errors().len(), 1);
        host.clear_errors();
        assert!(host.errors().is_empty());
    }

    #[test]
    fn captured_error_lands_in_errors_buffer() {
        let mut host = LuaHost::new().unwrap();
        let _ = host.eval(Some("usercfg"), "error('boom')");
        let id = host.errors_buffer_id().expect("*errors* buffer created");
        let reg = host.registry().borrow();
        let buf = reg.get(id).unwrap();
        let mut bytes = vec![0u8; buf.len() as usize];
        buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
        let body = String::from_utf8(bytes).unwrap();
        assert!(body.contains("[usercfg]"), "errors-buffer body: {body}");
        assert!(body.contains("boom"), "errors-buffer body: {body}");
        assert!(body.ends_with('\n'), "errors-buffer body: {body:?}");
    }

    #[test]
    fn errors_buffer_appends_each_error() {
        // Lua errors carry a multi-line traceback, so the buffer body
        // grows by more than one line per error. Verify by message
        // content and entry-prefix count rather than total line count.
        let mut host = LuaHost::new().unwrap();
        let _ = host.eval(None, "error('first')");
        let _ = host.eval(None, "error('second')");
        let id = host.errors_buffer_id().expect("buffer exists");
        let reg = host.registry().borrow();
        let buf = reg.get(id).unwrap();
        let mut bytes = vec![0u8; buf.len() as usize];
        buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
        let body = String::from_utf8(bytes).unwrap();
        assert!(body.contains("first"), "missing first: {body}");
        assert!(body.contains("second"), "missing second: {body}");
        // Two entries: each begins with `[chunk]` (since `source` is None).
        let entries = body.matches("[chunk]").count();
        assert_eq!(entries, 2, "expected 2 entries; body: {body}");
    }

    #[test]
    fn standard_library_is_loaded() {
        // Confirms mlua opened the stdlib --- string.upper, math.pi, etc.
        // are accessible to user chunks.
        let mut host = LuaHost::new().unwrap();
        let v = host.eval(None, "return string.upper('hi')").unwrap();
        match v {
            Value::String(s) => assert_eq!(s.to_str().unwrap(), "HI"),
            other => panic!("expected string, got {other:?}"),
        }
    }

    // ----- M5.6c: init-complete plumbing on the host -----

    #[test]
    fn host_starts_in_init_phase() {
        // Fresh host = init phase in progress; lifecycle-gated bindings
        // should permit calls.
        let host = LuaHost::new().unwrap();
        assert!(!host.is_init_complete());
    }

    #[test]
    fn host_set_init_complete_flips_the_flag() {
        let host = LuaHost::new().unwrap();
        host.set_init_complete();
        assert!(host.is_init_complete());
    }

    #[test]
    fn host_set_init_complete_is_idempotent() {
        // EditorState::new could in principle be re-entered through a
        // test path; the flip must remain safe to call repeatedly.
        let host = LuaHost::new().unwrap();
        host.set_init_complete();
        host.set_init_complete();
        assert!(host.is_init_complete());
    }

    #[test]
    fn host_eval_does_not_implicitly_flip_init_complete() {
        // Builtin chunk loads (async / syntax / lsp / hooks / commands /
        // keymaps) all go through `eval` *before* user init.lua runs.
        // None of those should flip the gate; only `set_init_complete`
        // does.
        let mut host = LuaHost::new().unwrap();
        let _ = host.eval(None, "return 1");
        let _ = host.eval(Some("@builtin/example"), "x = 42");
        assert!(
            !host.is_init_complete(),
            "ordinary eval must not flip init-complete"
        );
    }

    #[test]
    fn host_load_user_config_does_not_flip_init_complete() {
        // The flip is the responsibility of EditorState::new (after
        // load_user_config_at returns), not of load_user_config_at
        // itself. Pin this so a future refactor that moves the flip
        // into load_user_config_at would force reconciliation with
        // tests' explicit `set_init_complete` calls.
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("init.lua"), "USER_FLAG = 1").unwrap();
        let mut host = LuaHost::new().unwrap();
        crate::config::load_user_config_at(&mut host, dir.path());
        assert!(
            !host.is_init_complete(),
            "load_user_config_at alone must not flip init-complete"
        );
    }

    // ----- M5.6d: take_requested_attach plumbing -----

    #[test]
    fn take_requested_attach_returns_none_with_no_call() {
        let host = LuaHost::new().unwrap();
        assert!(host.take_requested_attach().is_none());
    }

    #[test]
    fn take_requested_attach_returns_target_set_from_init_lua() {
        // End-to-end: write an init.lua that calls pmacs.attach, run
        // it through load_user_config_at, then take the request from
        // the host.
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(
            dir.path().join("init.lua"),
            r#"pmacs.attach{ target = "local:/run/p.sock" }"#,
        )
        .unwrap();
        let mut host = LuaHost::new().unwrap();
        crate::config::load_user_config_at(&mut host, dir.path());
        let target = host.take_requested_attach().expect("request recorded");
        match target {
            AttachTarget::LocalSocket(p) => {
                assert_eq!(p, std::path::PathBuf::from("/run/p.sock"));
            }
            other => panic!("expected LocalSocket, got {other:?}"),
        }
        // Subsequent take is None.
        assert!(host.take_requested_attach().is_none());
    }

    #[test]
    fn init_lua_attach_error_does_not_record_request() {
        // A bad pmacs.attach call inside init.lua errors and is
        // captured into the error log, but no request is recorded
        // (the slot stays empty so the post-init dispatcher runs the
        // editor normally rather than handing off to a doomed attach).
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(
            dir.path().join("init.lua"),
            r#"pmacs.attach{ kind = "smtp" }"#,
        )
        .unwrap();
        let mut host = LuaHost::new().unwrap();
        crate::config::load_user_config_at(&mut host, dir.path());
        assert!(host.last_error().is_some(), "error captured");
        assert!(
            host.take_requested_attach().is_none(),
            "no request should be recorded for a failed pmacs.attach call"
        );
    }

    // ----- M5.6e: current attachment plumbing on the host -----

    fn sample_handle() -> AttachmentHandle {
        AttachmentHandle::new(
            crate::protocol::FrontendId(7),
            crate::protocol::InstanceIdentity {
                pmacs_version: "0.1.0".into(),
                build_hash: None,
                instance_name: None,
                uptime_secs: 0,
                working_directory: "/tmp".into(),
            },
            AttachTarget::LocalSocket(std::path::PathBuf::from("/run/p.sock")),
        )
    }

    #[test]
    fn host_current_attachment_returns_none_by_default() {
        let host = LuaHost::new().unwrap();
        assert!(host.current_attachment().is_none());
    }

    #[test]
    fn host_set_current_attachment_round_trips() {
        let host = LuaHost::new().unwrap();
        let h = sample_handle();
        host.set_current_attachment(h.clone());
        assert_eq!(host.current_attachment().unwrap(), h);
    }

    #[test]
    fn host_clear_current_attachment_empties_slot() {
        let host = LuaHost::new().unwrap();
        host.set_current_attachment(sample_handle());
        host.clear_current_attachment();
        assert!(host.current_attachment().is_none());
    }

    #[test]
    fn host_set_current_attachment_overwrites_prior() {
        // Unlike RequestedAttach.try_set, the current-attachment slot
        // accepts overwrites — the dispatcher may reattach over time
        // (forward-compat for v0.2 reconnect flow). Pin this so we
        // notice if either contract drifts.
        let host = LuaHost::new().unwrap();
        let first = sample_handle();
        let second = AttachmentHandle::new(
            crate::protocol::FrontendId(99),
            first.identity.clone(),
            first.target.clone(),
        );
        host.set_current_attachment(first);
        host.set_current_attachment(second.clone());
        assert_eq!(host.current_attachment().unwrap(), second);
    }

    // -----------------------------------------------------------------------
    // T M5.6f --- LuaHost local instance accessors
    // -----------------------------------------------------------------------

    #[test]
    fn host_local_instance_identity_present_after_install() {
        let host = LuaHost::new().unwrap();
        let id = host
            .local_instance_identity()
            .expect("install populates the slot");
        assert_eq!(id.pmacs_version, env!("CARGO_PKG_VERSION"));
        assert!(id.instance_name.is_none(), "default name is None");
    }

    #[test]
    fn host_set_instance_name_round_trips_through_identity() {
        let host = LuaHost::new().unwrap();
        host.set_instance_name(Some("work".into()));
        let id = host.local_instance_identity().unwrap();
        assert_eq!(id.instance_name.as_deref(), Some("work"));
        host.set_instance_name(None);
        let id = host.local_instance_identity().unwrap();
        assert!(id.instance_name.is_none());
    }

    #[test]
    fn host_set_instance_started_changes_uptime() {
        let host = LuaHost::new().unwrap();
        let earlier = std::time::Instant::now()
            .checked_sub(std::time::Duration::from_secs(7_777))
            .expect("clock supports 7777s subtraction");
        host.set_instance_started(earlier);
        let id = host.local_instance_identity().unwrap();
        assert!(
            id.uptime_secs >= 7_777,
            "expected uptime >= 7777 after rewinding `started`; got {}",
            id.uptime_secs
        );
    }
}
