// desktop.rs --- session desktop-save (Arc 3 phase 2).

//! Serialize the open file buffers + window layout + per-window
//! positions so a session survives a restart. Emacs `desktop.el`,
//! opt-in via `pmacs.session.desktop_mode(true)`.
//!
//! This module owns the whole feature: the serde mirror types (the core
//! window enums are not serde and stay that way), the SHA-256 session
//! key, the save-side snapshot, and the restore orchestration that opens
//! files, prunes/rebuilds windows, and fires `buffer.after-load`.
//! [`save_session`] / [`restore_session`] take the `&Lua` that carries
//! the editor's `SharedCore` / `StateDir` / `LocalInstanceInfo`
//! app-data, so they run identically from a `pmacs.session.*` binding
//! and from the `RunLocal` startup trigger.
//!
//! Framing: docs/desktop-save-framing.md.

use std::collections::HashMap;
use std::path::Path;

use mlua::Lua;
use serde::{Deserialize, Serialize};

use crate::buffer::BufferId;
use crate::editor_core::EditorCore;
use crate::hash::sha256_hex;
use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook};
use crate::protocol::FrontendId;
use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowId};

/// Bump when the on-disk shape changes incompatibly. Restore ignores a
/// desktop whose `version` it does not recognize.
pub const DESKTOP_VERSION: u32 = 1;

/// A serializable snapshot of a session: every open file buffer, the
/// window layout, and which leaf was focused.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedDesktop {
    /// Format version ([`DESKTOP_VERSION`]).
    pub version: u32,
    /// The [`session_key`] this was saved under; restore refuses a
    /// mismatch (defense in depth — the filename already encodes it).
    pub session_key: String,
    /// **Every** open file buffer (visible or hidden), so a file opened
    /// then switched away from survives restore — not just layout
    /// leaves.
    pub buffers: Vec<SavedBuffer>,
    /// The window layout tree.
    pub root: SavedNode,
    /// Preorder index (into the surviving leaf sequence) of the focused
    /// leaf. Resolved with a nearest-neighbor fallback if the focused
    /// leaf did not survive (Q#DS10).
    pub active_leaf: usize,
}

/// One open file buffer. Contents are never saved (Q#DS6); `modified`
/// only drives the restore-time "unsaved changes" warning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedBuffer {
    /// Absolute-or-relative path exactly as the buffer holds it.
    pub path: String,
    /// Whether the buffer had unsaved edits at save time.
    pub modified: bool,
}

/// Mirror of [`LayoutNode`] — a `Leaf` carries the window's file +
/// position; a `Split` carries orientation, weights, and children.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SavedNode {
    /// A window showing a file.
    Leaf(SavedLeaf),
    /// A proportional split.
    Split {
        /// Split axis.
        orientation: SavedOrientation,
        /// Per-child weights (same length as `children`).
        weights: Vec<u32>,
        /// Children in display order.
        children: Vec<SavedNode>,
    },
}

/// A restored window: which file, and where the cursor / viewport sit.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedLeaf {
    /// File path (always a file buffer — scratch/special are dropped).
    pub path: String,
    /// Cursor byte offset.
    pub cursor: u64,
    /// First visible source line.
    pub view_top: usize,
}

/// Serde mirror of [`Orientation`] (which is not itself serde).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavedOrientation {
    /// Children stacked top-to-bottom.
    Horizontal,
    /// Children side-by-side.
    Vertical,
}

impl From<Orientation> for SavedOrientation {
    fn from(o: Orientation) -> Self {
        match o {
            Orientation::Horizontal => SavedOrientation::Horizontal,
            Orientation::Vertical => SavedOrientation::Vertical,
        }
    }
}

impl From<SavedOrientation> for Orientation {
    fn from(o: SavedOrientation) -> Self {
        match o {
            SavedOrientation::Horizontal => Orientation::Horizontal,
            SavedOrientation::Vertical => Orientation::Vertical,
        }
    }
}

/// The state-store key a session's desktop is saved under (Q#DS5).
///
/// `name.<sha256hex>` keyed on the instance/socket name when set, else
/// `cwd.<sha256hex>` keyed on the working directory (Emacs's
/// per-directory model). Hashing both uniformly sidesteps odd
/// characters and satisfies the `pmacs.state` key charset (a raw `:` or
/// `/` would be rejected); the `desktop/` prefix is added by the store
/// key, not here.
#[must_use]
pub fn session_key(instance_name: Option<&str>, working_directory: &str) -> String {
    match instance_name {
        Some(name) => format!("name.{}", sha256_hex(name)),
        None => format!("cwd.{}", sha256_hex(working_directory)),
    }
}

/// The `pmacs.state` key (under a `desktop/` subdir) for a session key.
#[must_use]
pub fn desktop_state_key(session_key: &str) -> String {
    format!("desktop/{session_key}")
}

/// Build the serializable layout tree from a core [`LayoutNode`],
/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a
/// non-file leaf, which is dropped and its split collapsed).
///
/// Returns the mirror node plus the **surviving leaf window-ids in
/// preorder** — the caller uses that list to compute `active_leaf`
/// (with the Q#DS10 fallback). Returns `None` when nothing survives.
pub fn build_saved_node(
    node: &LayoutNode,
    resolve: &impl Fn(WindowId) -> Option<SavedLeaf>,
    surviving: &mut Vec<WindowId>,
) -> Option<SavedNode> {
    match node {
        LayoutNode::Leaf(id) => resolve(*id).map(|leaf| {
            surviving.push(*id);
            SavedNode::Leaf(leaf)
        }),
        LayoutNode::Split {
            orientation,
            weights,
            children,
        } => {
            let mut kept: Vec<(SavedNode, u32)> = Vec::new();
            for (i, child) in children.iter().enumerate() {
                if let Some(saved) = build_saved_node(child, resolve, surviving) {
                    let w = weights.get(i).copied().unwrap_or(1).max(1);
                    kept.push((saved, w));
                }
            }
            match kept.len() {
                0 => None,
                // A split with a single surviving child collapses to
                // that child (the sibling that carried the other pane is
                // gone), so the tree never has a one-child split.
                1 => Some(kept.into_iter().next().unwrap().0),
                _ => {
                    let (nodes, weights): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
                    Some(SavedNode::Split {
                        orientation: (*orientation).into(),
                        weights,
                        children: nodes,
                    })
                }
            }
        }
    }
}

/// Given the full preorder leaf list (before dropping) with a survived
/// flag, and the focused window, return the `active_leaf` index into
/// the *surviving* sequence — the focused leaf if it survived, else the
/// nearest surviving preorder neighbor (later preferred), else 0
/// (Q#DS10). `surviving_ids` is the preorder list of leaves that
/// survived, in the same order they appear in `full`.
#[must_use]
pub fn resolve_active_leaf(
    full: &[(WindowId, bool)],
    surviving_ids: &[WindowId],
    focused: WindowId,
) -> usize {
    // Direct hit: focused survived.
    if let Some(i) = surviving_ids.iter().position(|&id| id == focused) {
        return i;
    }
    // Focused was dropped: find its position in the full preorder list,
    // then the nearest survivor (scan right, then left).
    let Some(fpos) = full.iter().position(|&(id, _)| id == focused) else {
        return 0;
    };
    let neighbor = full
        .iter()
        .enumerate()
        .filter(|(_, (_, survived))| *survived)
        .min_by_key(|(pos, _)| {
            // Prefer later leaves on ties: right distance rounds down.
            let dist = pos.abs_diff(fpos);
            (dist, i32::from(*pos < fpos))
        })
        .map(|(_, (id, _))| *id);
    neighbor
        .and_then(|id| surviving_ids.iter().position(|&s| s == id))
        .unwrap_or(0)
}

// ---------------------------------------------------------------------------
// Save / restore orchestration (driven from `pmacs.session.*` and the
// RunLocal startup trigger; both hand us the `&Lua` that carries the
// SharedCore / StateDir / LocalInstanceInfo app-data).
// ---------------------------------------------------------------------------

/// True when this process is a daemon (multi-frontend). Desktop
/// save/restore is local-only in v1 (Q#DS9); this is the **reliable**
/// enforcement — the `DaemonMode` marker is set right after the daemon's
/// `EditorState::new()`, so it is present for every save/restore that
/// can run after startup (the before-quit hook, manual commands, direct
/// binding calls), even though `init.lua` runs before it is set. Both
/// `save_session` and `restore_session` no-op when it holds.
fn is_daemon(lua: &Lua) -> bool {
    lua.app_data_ref::<crate::lua_bindings::DaemonMode>()
        .is_some()
}

/// The desktop session key for this process (`cwd.<hash>` in local mode).
fn session_key_from_lua(lua: &Lua) -> String {
    match lua
        .app_data_ref::<LocalInstanceInfo>()
        .map(|i| i.build_identity())
    {
        Some(id) => session_key(id.instance_name.as_deref(), &id.working_directory),
        None => session_key(None, ""),
    }
}

/// Snapshot the LOCAL frontend as a [`SavedDesktop`] (Q#DS2). `None`
/// when no file window survives (nothing worth saving).
#[must_use]
pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop> {
    let view = core.views.get(&FrontendId::LOCAL)?;
    let focused = view.active;
    let reg = core.registry.borrow();

    let resolve = |wid: WindowId| -> Option<SavedLeaf> {
        let win = core.windows.get(&wid)?;
        // Bottom-panel arc (Q#BP10): side windows are transient display
        // policy, never desktop state. Dropping the leaf here makes the
        // existing single-surviving-child collapse remove the root
        // wrapper too, so the saved tree is the document tree exactly —
        // no `SavedLeaf` shape change and no `DESKTOP_VERSION` bump.
        if win.is_side() {
            return None;
        }
        let path = reg.get(win.buffer_id).ok()?.file_path()?;
        Some(SavedLeaf {
            path: path.display().to_string(),
            cursor: win.cursor,
            view_top: win.view_top,
        })
    };

    let mut surviving = Vec::new();
    let root = build_saved_node(&view.layout.root, &resolve, &mut surviving)?;

    let full: Vec<(WindowId, bool)> = view
        .layout
        .iter_ids()
        .into_iter()
        .map(|id| (id, surviving.contains(&id)))
        .collect();
    let active_leaf = resolve_active_leaf(&full, &surviving, focused);

    let buffers = reg
        .ids()
        .iter()
        .filter_map(|&id| {
            let b = reg.get(id).ok()?;
            let path = b.file_path()?;
            Some(SavedBuffer {
                path: path.display().to_string(),
                modified: b.is_modified(),
            })
        })
        .collect();

    Some(SavedDesktop {
        version: DESKTOP_VERSION,
        session_key,
        buffers,
        root,
        active_leaf,
    })
}

/// Serialize the current session to the desktop state file (Q#DS1).
/// `Ok(false)` when the state dir is unconfigured or nothing is worth
/// saving. Never fails hard for the before-quit path (Q#DS8) — the
/// caller may ignore the error.
///
/// # Errors
/// A state-write / serialization failure (surfaced for manual save).
pub fn save_session(lua: &Lua) -> Result<bool, String> {
    if is_daemon(lua) {
        return Ok(false); // local-only in v1 (Q#DS9)
    }
    let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
        return Ok(false);
    };
    let core = lua
        .app_data_ref::<SharedCore>()
        .ok_or("no editor core")?
        .clone();
    let key = session_key_from_lua(lua);
    let snap = snapshot(&core.borrow(), key);
    let Some(snap) = snap else {
        return Ok(false);
    };
    let json = serde_json::to_string(&snap).map_err(|e| e.to_string())?;
    let state_key = desktop_state_key(&snap.session_key);
    crate::state::write(&base, &state_key, json.as_bytes()).map_err(|e| e.to_string())?;
    Ok(true)
}

/// Rebuild the LOCAL session from the desktop state file (Q#DS3). A
/// no-op when no desktop is saved for this key / version / session
/// mismatches. Fires `buffer.after-load` via `lua` with each restored
/// leaf active.
///
/// # Errors
/// Parse / state-read failures; a missing individual file collapses its
/// leaf rather than failing the whole restore.
pub fn restore_session(lua: &Lua) -> Result<(), String> {
    if is_daemon(lua) {
        return Ok(()); // local-only in v1 (Q#DS9)
    }
    let key = session_key_from_lua(lua);
    let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
        return Ok(());
    };
    let state_key = desktop_state_key(&key);
    let Some(json) = crate::state::read(&base, &state_key).map_err(|e| e.to_string())? else {
        return Ok(()); // no desktop saved
    };
    let saved: SavedDesktop = serde_json::from_str(&json).map_err(|e| e.to_string())?;
    if saved.version != DESKTOP_VERSION || saved.session_key != key {
        return Ok(()); // unrecognized / wrong session
    }
    let core = lua
        .app_data_ref::<SharedCore>()
        .ok_or("no editor core")?
        .clone();
    let modified = restore_into(&core, &saved, || fire_after_load_hook(lua));
    if modified > 0 {
        core.borrow_mut().status =
            format!("desktop restored; {modified} buffer(s) had unsaved changes when saved");
    }
    Ok(())
}

/// One restored leaf window, carried from the tree build to the
/// activate-then-fire pass (Q#DS3).
struct RestoreLeaf {
    window: WindowId,
    cursor: u64,
    view_top: usize,
}

/// Do the structural rebuild: open buffers, prune the old LOCAL layout,
/// build the new tree + windows, install the view, then fire
/// `buffer.after-load` (via `fire_after_load`) with each newly-loaded
/// leaf active and apply the exact per-leaf `cursor`/`view_top` afterward
/// (desktop wins over saveplace, Q#DS3). Returns the count of buffers
/// that were modified at save time (for the Q#DS6 warning).
///
/// Takes `&SharedCore` (not a borrow) so it can release the core borrow
/// around each hook fire — `buffer.after-load` re-enters `pmacs.editor.*`
/// which re-borrows the core.
pub fn restore_into(
    core: &SharedCore,
    saved: &SavedDesktop,
    mut fire_after_load: impl FnMut(),
) -> usize {
    // (2) Open every buffer up front (hidden ones survive), keyed by the
    // raw saved path. A missing file is absent → its leaves collapse.
    let mut modified_count = 0usize;
    let mut opened: HashMap<String, (BufferId, bool)> = HashMap::new();
    for sb in &saved.buffers {
        if sb.modified {
            modified_count += 1;
        }
        if let Ok(res) = core.borrow_mut().get_or_load_buffer(Path::new(&sb.path)) {
            opened.insert(sb.path.clone(), res);
        }
    }

    // (3-5) Build + prune + install the new LOCAL view.
    let mut leaves: Vec<RestoreLeaf> = Vec::new();
    let mut save_slots: Vec<Option<WindowId>> = Vec::new();
    let active_wid = {
        let mut c = core.borrow_mut();
        let old_ids = c.views.get(&FrontendId::LOCAL).map(|v| v.layout.iter_ids());
        let Some(root) =
            build_restore_node(&mut c, &saved.root, &opened, &mut leaves, &mut save_slots)
        else {
            return modified_count; // nothing survived → keep current session
        };
        // Prune every window of the old LOCAL layout (not just scratch)
        // so none linger orphaned in `core.windows`.
        if let Some(old_ids) = old_ids {
            for id in old_ids {
                c.windows.remove(&id);
            }
        }
        let active = pick_active_window(&save_slots, saved.active_leaf)
            .or_else(|| leaves.first().map(|l| l.window));
        let Some(active) = active else {
            return modified_count;
        };
        c.active_frontend = FrontendId::LOCAL;
        c.views.insert(
            FrontendId::LOCAL,
            FrontendView {
                layout: Layout { root },
                active,
                // Desktop restore rebuilds LOCAL's grid view (Q#FD21).
                fold_projection: true,
                // …which renders side windows natively (Q#BP13). Every
                // field is spelled explicitly, preserving folding's
                // non-`Default` discipline.
                panel_capable: true,
                frame_geometry: None,
                panel_hidden: false,
            },
        );
        active
    };

    // (5) activate-then-fire, once **per leaf** (per window). after-load
    // must observe the restored leaf as active (saveplace/recentf/syntax/
    // LSP read active state). Firing per leaf — not per buffer — gives
    // each pane its own per-window overlay (syntax attaches to the active
    // window), while LSP's `attach_buffer` is idempotent, so the same
    // file in two panes still attaches LSP once but syntax to both.
    // Writing the exact per-leaf cursor/view_top *after* the hook lets
    // desktop win over saveplace.
    //
    // NOTE (Q#DS3, hidden buffers): a restored buffer with NO leaf (open
    // but hidden) is loaded into the registry but does not fire
    // after-load here — it attaches syntax on first visit (after-switch)
    // and LSP when it is next shown/opened. Registry-only in v1.
    for leaf in &leaves {
        core.borrow_mut().set_active_window_id(leaf.window);
        fire_after_load();
        let mut c = core.borrow_mut();
        if let Some(win) = c.windows.get_mut(&leaf.window) {
            win.cursor = leaf.cursor;
            win.view_top = leaf.view_top;
        }
    }
    core.borrow_mut().set_active_window_id(active_wid);
    modified_count
}

/// Recursively rebuild a [`LayoutNode`] from a [`SavedNode`], creating a
/// `Window` in `core.windows` per surviving leaf. A leaf whose file
/// failed to open (absent from `opened`) is dropped and its split
/// collapsed — mirroring the save-side collapse.
fn build_restore_node(
    core: &mut EditorCore,
    node: &SavedNode,
    opened: &HashMap<String, (BufferId, bool)>,
    leaves: &mut Vec<RestoreLeaf>,
    save_slots: &mut Vec<Option<WindowId>>,
) -> Option<LayoutNode> {
    match node {
        SavedNode::Leaf(leaf) => {
            let Some(&(buffer_id, _newly)) = opened.get(&leaf.path) else {
                save_slots.push(None); // file missing → leaf collapses
                return None;
            };
            let text_view = {
                let reg = core.registry.borrow();
                TextView::new(reg.get(buffer_id).ok()?)
            };
            let buf_len = core
                .registry
                .borrow()
                .get(buffer_id)
                .map_or(0, crate::buffer::Buffer::len);
            let cursor = leaf.cursor.min(buf_len);
            let view_top = leaf.view_top.min(text_view.line_count().saturating_sub(1));
            let wid = WindowId::next();
            let mut win = Window::new(wid, buffer_id, text_view);
            win.cursor = cursor;
            win.view_top = view_top;
            core.windows.insert(wid, win);
            leaves.push(RestoreLeaf {
                window: wid,
                cursor,
                view_top,
            });
            save_slots.push(Some(wid));
            Some(LayoutNode::Leaf(wid))
        }
        SavedNode::Split {
            orientation,
            weights,
            children,
        } => {
            let mut kept: Vec<(LayoutNode, u32)> = Vec::new();
            for (i, child) in children.iter().enumerate() {
                if let Some(n) = build_restore_node(core, child, opened, leaves, save_slots) {
                    kept.push((n, weights.get(i).copied().unwrap_or(1).max(1)));
                }
            }
            match kept.len() {
                0 => None,
                1 => Some(kept.into_iter().next().unwrap().0),
                _ => {
                    let (nodes, ws): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
                    Some(LayoutNode::Split {
                        orientation: (*orientation).into(),
                        weights: ws,
                        children: nodes,
                    })
                }
            }
        }
    }
}

/// Resolve the focused window from the save-time `active_leaf` index
/// against the restore survivors: direct hit, else nearest surviving
/// preorder neighbor (later preferred) — Q#DS10.
fn pick_active_window(slots: &[Option<WindowId>], want: usize) -> Option<WindowId> {
    if let Some(Some(w)) = slots.get(want) {
        return Some(*w);
    }
    (0..slots.len())
        .filter(|&i| slots[i].is_some())
        .min_by_key(|&i| (i.abs_diff(want), usize::from(i < want)))
        .and_then(|i| slots[i])
}

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

    #[test]
    fn session_key_distinguishes_name_and_cwd() {
        let by_name = session_key(Some("work"), "/home/u/proj");
        let by_cwd = session_key(None, "/home/u/proj");
        assert!(by_name.starts_with("name."));
        assert!(by_cwd.starts_with("cwd."));
        assert_ne!(by_name, by_cwd);
        // Deterministic + charset-safe (state key validates it).
        assert_eq!(by_name, session_key(Some("work"), "/elsewhere"));
        assert!(crate::state::validate_name(&desktop_state_key(&by_name)).is_ok());
        assert!(crate::state::validate_name(&desktop_state_key(&by_cwd)).is_ok());
    }

    #[test]
    fn saved_desktop_json_round_trips() {
        let d = SavedDesktop {
            version: DESKTOP_VERSION,
            session_key: "cwd.abc".into(),
            buffers: vec![SavedBuffer {
                path: "/a.rs".into(),
                modified: true,
            }],
            root: SavedNode::Split {
                orientation: SavedOrientation::Vertical,
                weights: vec![2, 1],
                children: vec![
                    SavedNode::Leaf(SavedLeaf {
                        path: "/a.rs".into(),
                        cursor: 10,
                        view_top: 2,
                    }),
                    SavedNode::Leaf(SavedLeaf {
                        path: "/b.rs".into(),
                        cursor: 0,
                        view_top: 0,
                    }),
                ],
            },
            active_leaf: 1,
        };
        let json = serde_json::to_string(&d).unwrap();
        assert_eq!(serde_json::from_str::<SavedDesktop>(&json).unwrap(), d);
    }

    fn leaf(path: &str) -> SavedLeaf {
        SavedLeaf {
            path: path.into(),
            cursor: 0,
            view_top: 0,
        }
    }

    #[test]
    fn split_with_one_survivor_collapses() {
        // A 2-way split where only the first child is a file leaf →
        // collapses to that leaf, no one-child split.
        let (a, b) = (WindowId::next(), WindowId::next());
        let node = LayoutNode::Split {
            orientation: Orientation::Vertical,
            weights: vec![1, 1],
            children: vec![LayoutNode::Leaf(a), LayoutNode::Leaf(b)],
        };
        let resolve = |id: WindowId| (id == a).then(|| leaf("/a.rs"));
        let mut surviving = Vec::new();
        let saved = build_saved_node(&node, &resolve, &mut surviving).unwrap();
        assert_eq!(saved, SavedNode::Leaf(leaf("/a.rs")));
        assert_eq!(surviving, vec![a]);
    }

    #[test]
    fn active_leaf_falls_back_to_neighbor() {
        let (a, b, c) = (WindowId::next(), WindowId::next(), WindowId::next());
        // b was focused but dropped; survivors are [a, c] in preorder.
        let full = vec![(a, true), (b, false), (c, true)];
        let surviving = vec![a, c];
        // Nearest neighbor to b (pos 1) preferring later → c (index 1).
        assert_eq!(resolve_active_leaf(&full, &surviving, b), 1);
        // Direct hit still works.
        assert_eq!(resolve_active_leaf(&full, &surviving, a), 0);
    }
}
