//! Workspace persistence integration for the Editor
//!
//! This module provides conversion between live Editor state and serialized Workspace data.
//!
//! # Role in Incremental Streaming Architecture
//!
//! This module handles workspace save/restore for terminals.
//! See `crate::services::terminal` for the full architecture diagram.
//!
//! ## Workspace Save
//!
//! [`Editor::save_workspace`] calls [`Editor::sync_all_terminal_backing_files`] to ensure
//! all terminal backing files contain complete state (scrollback + visible screen)
//! before serializing workspace metadata.
//!
//! ## Workspace Restore
//!
//! [`Editor::restore_terminal_from_workspace`] loads the backing file directly as a
//! read-only buffer, skipping the expensive log replay. The user starts in scrollback
//! mode viewing the last workspace state. A new PTY is spawned when they re-enter
//! terminal mode.
//!
//! Performance: O(1) ≈ 10ms (lazy load) vs O(n) ≈ 1000ms (log replay)

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;

use crate::state::EditorState;

use crate::model::event::{BufferId, LeafId, SplitDirection, SplitId};
use crate::services::terminal::TerminalId;
use crate::state::ViewMode;
use crate::view::split::{SplitNode, SplitViewState};
use crate::workspace::{
    FileExplorerState, PersistedFileWorkspace, SearchOptions, SerializedBookmark, SerializedCursor,
    SerializedFileState, SerializedFoldRange, SerializedScroll, SerializedSplitDirection,
    SerializedSplitNode, SerializedSplitViewState, SerializedTabRef, SerializedTerminalWorkspace,
    SerializedViewMode, UnnamedBufferRef, Workspace, WorkspaceConfigOverrides, WorkspaceError,
    WorkspaceHistories, WORKSPACE_VERSION,
};

use super::bookmarks::{Bookmark, BookmarkState};
use super::Editor;

/// Resolve a saved fold's header_line against the current buffer, using
/// `header_text` to detect drift from external edits (issue #1568).
///
/// - If no `header_text` is available (older session files), trust the saved
///   line number.
/// - If the text at the saved line still matches, use that line.
/// - Otherwise, search a small window above and below the saved line for the
///   same text (trimmed) — lines may have shifted by a few either way after a
///   local external edit.
/// - If still not found, return `None` so the caller drops the fold rather
///   than re-attaching it to unrelated content.
fn resolve_fold_header_line(
    buffer: &crate::model::buffer::Buffer,
    saved_line: usize,
    header_text: Option<&str>,
) -> Option<usize> {
    let Some(expected) = header_text else {
        // Backward compatibility: no recorded text, trust the line number.
        return Some(saved_line);
    };
    let expected_trimmed = expected.trim();
    let line_matches = |line: usize| -> bool {
        buffer
            .get_line(line)
            .map(|bytes| {
                let text = String::from_utf8_lossy(&bytes);
                text.trim_end_matches('\n').trim_end_matches('\r').trim() == expected_trimmed
            })
            .unwrap_or(false)
    };
    if line_matches(saved_line) {
        return Some(saved_line);
    }
    // Search nearby (expanding outward) for the displaced header.
    const SEARCH_WINDOW: usize = 32;
    for delta in 1..=SEARCH_WINDOW {
        let above = saved_line.checked_sub(delta);
        if let Some(l) = above {
            if line_matches(l) {
                return Some(l);
            }
        }
        let below = saved_line.saturating_add(delta);
        if line_matches(below) {
            return Some(below);
        }
    }
    None
}

/// Workspace persistence state tracker
///
/// Tracks dirty state and handles debounced saving for crash resistance.
pub struct WorkspaceTracker {
    /// Whether workspace has unsaved changes
    dirty: bool,
    /// Last save time
    last_save: Instant,
    /// Minimum interval between saves (debounce)
    save_interval: std::time::Duration,
    /// Whether workspace persistence is enabled
    enabled: bool,
}

impl WorkspaceTracker {
    /// Create a new workspace tracker
    pub fn new(enabled: bool) -> Self {
        Self {
            dirty: false,
            last_save: Instant::now(),
            save_interval: std::time::Duration::from_secs(5),
            enabled,
        }
    }

    /// Check if workspace tracking is enabled
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Mark workspace as needing save
    pub fn mark_dirty(&mut self) {
        if self.enabled {
            self.dirty = true;
        }
    }

    /// Check if a save is needed and enough time has passed
    pub fn should_save(&self) -> bool {
        self.enabled && self.dirty && self.last_save.elapsed() >= self.save_interval
    }

    /// Record that a save was performed
    pub fn record_save(&mut self) {
        self.dirty = false;
        self.last_save = Instant::now();
    }

    /// Check if there are unsaved changes (for shutdown)
    pub fn is_dirty(&self) -> bool {
        self.dirty
    }
}

impl Editor {
    /// Capture current editor state into a Workspace
    pub fn capture_workspace(&self) -> Workspace {
        tracing::debug!("Capturing workspace for {:?}", self.working_dir);

        // Collect terminal metadata for workspace restore
        let mut terminals = Vec::new();
        let mut terminal_indices: HashMap<TerminalId, usize> = HashMap::new();
        let mut seen = HashSet::new();
        for terminal_id in self.terminal_buffers.values().copied() {
            if seen.insert(terminal_id) {
                // Ephemeral terminals (plugin-created tool UIs — rebuilds,
                // exec shells, build output) do not belong in the persisted
                // workspace. Skipping them here prevents their backing files
                // from being serialized, which is what used to cause a newly
                // spawned plugin terminal to come back with scrollback from
                // the prior run.
                if self.ephemeral_terminals.contains(&terminal_id) {
                    continue;
                }
                let idx = terminals.len();
                terminal_indices.insert(terminal_id, idx);
                let handle = self.terminal_manager.get(terminal_id);
                let (cols, rows) = handle
                    .map(|h| h.size())
                    .unwrap_or((self.terminal_width, self.terminal_height));
                let cwd = handle.and_then(|h| h.cwd());
                let shell = handle
                    .map(|h| h.shell().to_string())
                    .unwrap_or_else(crate::services::terminal::detect_shell);
                let log_path = self
                    .terminal_log_files
                    .get(&terminal_id)
                    .cloned()
                    .unwrap_or_else(|| {
                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
                        root.join(format!("fresh-terminal-{}.log", terminal_id.0))
                    });
                let backing_path = self
                    .terminal_backing_files
                    .get(&terminal_id)
                    .cloned()
                    .unwrap_or_else(|| {
                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
                        root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
                    });

                terminals.push(SerializedTerminalWorkspace {
                    terminal_index: idx,
                    cwd,
                    shell,
                    cols,
                    rows,
                    log_path,
                    backing_path,
                });
            }
        }

        let split_layout = serialize_split_node(
            self.split_manager.root(),
            &self.buffer_metadata,
            &self.working_dir,
            &self.terminal_buffers,
            &terminal_indices,
            self.split_manager.labels(),
        );

        // Build a map of leaf_id -> active_buffer_id from the split tree
        // This tells us which buffer's cursor/scroll to save for each split
        let active_buffers: HashMap<LeafId, BufferId> = self
            .split_manager
            .root()
            .get_leaves_with_rects(ratatui::layout::Rect::default())
            .into_iter()
            .map(|(leaf_id, buffer_id, _)| (leaf_id, buffer_id))
            .collect();

        let mut split_states = HashMap::new();
        for (leaf_id, view_state) in &self.split_view_states {
            let active_buffer = active_buffers.get(leaf_id).copied();
            let serialized = serialize_split_view_state(
                view_state,
                &self.buffers,
                &self.buffer_metadata,
                &self.working_dir,
                active_buffer,
                &self.terminal_buffers,
                &terminal_indices,
            );
            tracing::trace!(
                "Split {:?}: {} open tabs, active_buffer={:?}",
                leaf_id,
                serialized.open_tabs.len(),
                active_buffer
            );
            split_states.insert(leaf_id.0 .0, serialized);
        }

        tracing::debug!(
            "Captured {} split states, active_split={}",
            split_states.len(),
            SplitId::from(self.split_manager.active_split()).0
        );

        // Capture file explorer state
        let file_explorer = if let Some(ref explorer) = self.file_explorer {
            // Get expanded directories from the tree
            let expanded_dirs = get_expanded_dirs(explorer, &self.working_dir);
            FileExplorerState {
                visible: self.file_explorer_visible,
                width: self.file_explorer_width,
                expanded_dirs,
                scroll_offset: explorer.get_scroll_offset(),
                show_hidden: explorer.ignore_patterns().show_hidden(),
                show_gitignored: explorer.ignore_patterns().show_gitignored(),
            }
        } else {
            FileExplorerState {
                visible: self.file_explorer_visible,
                width: self.file_explorer_width,
                expanded_dirs: Vec::new(),
                scroll_offset: 0,
                show_hidden: false,
                show_gitignored: false,
            }
        };

        // Capture config overrides (only store deviations from defaults)
        let config_overrides = WorkspaceConfigOverrides {
            line_numbers: Some(self.config.editor.line_numbers),
            relative_line_numbers: Some(self.config.editor.relative_line_numbers),
            line_wrap: Some(self.config.editor.line_wrap),
            syntax_highlighting: Some(self.config.editor.syntax_highlighting),
            enable_inlay_hints: Some(self.config.editor.enable_inlay_hints),
            mouse_enabled: Some(self.mouse_enabled),
            menu_bar_hidden: Some(!self.menu_bar_visible),
        };

        // Capture histories using the items() accessor from the prompt_histories HashMap
        let histories = WorkspaceHistories {
            search: self
                .prompt_histories
                .get("search")
                .map(|h| h.items().to_vec())
                .unwrap_or_default(),
            replace: self
                .prompt_histories
                .get("replace")
                .map(|h| h.items().to_vec())
                .unwrap_or_default(),
            command_palette: Vec::new(), // Future: when command palette has history
            goto_line: self
                .prompt_histories
                .get("goto_line")
                .map(|h| h.items().to_vec())
                .unwrap_or_default(),
            open_file: Vec::new(), // Future: when file open prompt has history
        };
        tracing::trace!(
            "Captured histories: {} search, {} replace",
            histories.search.len(),
            histories.replace.len()
        );

        // Capture search options
        let search_options = SearchOptions {
            case_sensitive: self.search_case_sensitive,
            whole_word: self.search_whole_word,
            use_regex: self.search_use_regex,
            confirm_each: self.search_confirm_each,
        };

        // Capture bookmarks
        let bookmarks =
            serialize_bookmarks(&self.bookmarks, &self.buffer_metadata, &self.working_dir);

        // Capture external files (files outside working_dir)
        // These are stored as absolute paths since they can't be made relative
        let external_files: Vec<PathBuf> = self
            .buffer_metadata
            .values()
            .filter_map(|meta| meta.file_path())
            .filter(|abs_path| abs_path.strip_prefix(&self.working_dir).is_err())
            .cloned()
            .collect();
        if !external_files.is_empty() {
            tracing::debug!("Captured {} external files", external_files.len());
        }

        // Capture read-only file paths. Store relative when inside
        // working_dir (matches how open_tabs paths are stored), otherwise
        // absolute — mirrors external_files.
        let read_only_files: Vec<PathBuf> = self
            .buffer_metadata
            .values()
            .filter(|meta| meta.read_only)
            .filter_map(|meta| meta.file_path().cloned())
            .filter(|p| !p.as_os_str().is_empty())
            .map(|p| {
                p.strip_prefix(&self.working_dir)
                    .map(|rel| rel.to_path_buf())
                    .unwrap_or(p)
            })
            .collect();

        // Capture unnamed buffer references (for hot_exit)
        let unnamed_buffers: Vec<UnnamedBufferRef> = if self.config.editor.hot_exit {
            self.buffer_metadata
                .iter()
                .filter_map(|(buffer_id, meta)| {
                    // Only file-backed buffers with empty path (unnamed)
                    let path = meta.file_path()?;
                    if !path.as_os_str().is_empty() {
                        return None;
                    }
                    // Skip composite/hidden buffers
                    if meta.hidden_from_tabs || meta.is_virtual() {
                        return None;
                    }
                    // Skip if buffer has no content
                    let state = self.buffers.get(buffer_id)?;
                    if state.buffer.total_bytes() == 0 {
                        return None;
                    }
                    // Get or generate recovery ID
                    let recovery_id = meta.recovery_id.clone()?;
                    Some(UnnamedBufferRef {
                        recovery_id,
                        display_name: meta.display_name.clone(),
                    })
                })
                .collect()
        } else {
            Vec::new()
        };
        if !unnamed_buffers.is_empty() {
            tracing::debug!("Captured {} unnamed buffers", unnamed_buffers.len());
        }

        Workspace {
            version: WORKSPACE_VERSION,
            working_dir: self.working_dir.clone(),
            split_layout,
            active_split_id: SplitId::from(self.split_manager.active_split()).0,
            split_states,
            config_overrides,
            file_explorer,
            histories,
            search_options,
            bookmarks,
            terminals,
            external_files,
            read_only_files,
            unnamed_buffers,
            plugin_global_state: self.plugin_global_state.clone(),
            saved_at: std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs(),
        }
    }

    /// Save the current workspace to disk
    ///
    /// Ensures all active terminals have their visible screen synced to
    /// backing files before capturing the workspace.
    /// Also saves global file states (scroll/cursor positions per file).
    pub fn save_workspace(&mut self) -> Result<(), WorkspaceError> {
        // Ensure all terminal backing files have complete state before saving
        self.sync_all_terminal_backing_files();

        // Save global file states for all open file buffers
        self.save_all_global_file_states();

        let workspace = self.capture_workspace();

        // For named sessions, save to session-scoped workspace file
        if let Some(ref session_name) = self.session_name {
            workspace.save_session(session_name)
        } else {
            workspace.save()
        }
    }

    /// Save global file states for all open file buffers
    fn save_all_global_file_states(&self) {
        // Collect all file states from all splits
        for (leaf_id, view_state) in &self.split_view_states {
            // Get the active buffer for this split
            let active_buffer = self
                .split_manager
                .root()
                .get_leaves_with_rects(ratatui::layout::Rect::default())
                .into_iter()
                .find(|(sid, _, _)| *sid == *leaf_id)
                .map(|(_, buffer_id, _)| buffer_id);

            if let Some(buffer_id) = active_buffer {
                self.save_buffer_file_state(buffer_id, view_state);
            }
        }
    }

    /// Save file state for a specific buffer (used when closing files and saving workspace)
    fn save_buffer_file_state(&self, buffer_id: BufferId, view_state: &SplitViewState) {
        // Get the file path for this buffer
        let abs_path = match self.buffer_metadata.get(&buffer_id) {
            Some(metadata) => match metadata.file_path() {
                Some(path) => path.to_path_buf(),
                None => return, // Not a file buffer
            },
            None => return,
        };

        // Capture the current state
        let primary_cursor = view_state.cursors.primary();
        let file_state = SerializedFileState {
            cursor: SerializedCursor {
                position: primary_cursor.position,
                anchor: primary_cursor.anchor,
                sticky_column: primary_cursor.sticky_column,
            },
            additional_cursors: view_state
                .cursors
                .iter()
                .skip(1)
                .map(|(_, cursor)| SerializedCursor {
                    position: cursor.position,
                    anchor: cursor.anchor,
                    sticky_column: cursor.sticky_column,
                })
                .collect(),
            scroll: SerializedScroll {
                top_byte: view_state.viewport.top_byte,
                top_view_line_offset: view_state.viewport.top_view_line_offset,
                left_column: view_state.viewport.left_column,
            },
            view_mode: Default::default(),
            compose_width: None,
            plugin_state: std::collections::HashMap::new(),
            folds: Vec::new(),
        };

        // Save to disk immediately
        PersistedFileWorkspace::save(&abs_path, file_state);
    }

    /// Sync all active terminal visible screens to their backing files.
    ///
    /// Called before workspace save to ensure backing files contain complete
    /// terminal state (scrollback + visible screen).
    fn sync_all_terminal_backing_files(&mut self) {
        use std::io::BufWriter;

        // Collect terminal IDs and their backing paths
        let terminals_to_sync: Vec<_> = self
            .terminal_buffers
            .values()
            .copied()
            .filter_map(|terminal_id| {
                self.terminal_backing_files
                    .get(&terminal_id)
                    .map(|path| (terminal_id, path.clone()))
            })
            .collect();

        for (terminal_id, backing_path) in terminals_to_sync {
            if let Some(handle) = self.terminal_manager.get(terminal_id) {
                if let Ok(state) = handle.state.lock() {
                    // Append visible screen to backing file
                    if let Ok(mut file) = self
                        .authority
                        .filesystem
                        .open_file_for_append(&backing_path)
                    {
                        let mut writer = BufWriter::new(&mut *file);
                        if let Err(e) = state.append_visible_screen(&mut writer) {
                            tracing::warn!(
                                "Failed to sync terminal {:?} to backing file: {}",
                                terminal_id,
                                e
                            );
                        }
                    }
                }
            }
        }
    }

    /// Try to load and apply a workspace for the current working directory
    ///
    /// Returns true if a workspace was successfully loaded and applied.
    pub fn try_restore_workspace(&mut self) -> Result<bool, WorkspaceError> {
        tracing::debug!("Attempting to restore workspace for {:?}", self.working_dir);

        // For named sessions, load from session-scoped workspace file
        let workspace = if let Some(ref session_name) = self.session_name {
            Workspace::load_session(session_name, &self.working_dir)?
        } else {
            Workspace::load(&self.working_dir)?
        };

        match workspace {
            Some(workspace) => {
                tracing::info!("Found workspace, applying...");
                self.apply_workspace(&workspace)?;
                Ok(true)
            }
            None => {
                tracing::debug!("No workspace found for {:?}", self.working_dir);
                Ok(false)
            }
        }
    }

    /// Apply hot exit recovery to all currently open file-backed buffers.
    ///
    /// This restores unsaved changes from recovery files for buffers that were
    /// opened via CLI (without workspace restore). Returns the number of buffers
    /// recovered.
    pub fn apply_hot_exit_recovery(&mut self) -> anyhow::Result<usize> {
        if !self.config.editor.hot_exit {
            return Ok(0);
        }

        let entries = self.recovery_service.list_recoverable()?;
        if entries.is_empty() {
            return Ok(0);
        }

        // Collect buffer IDs and their file paths
        let buffer_files: Vec<_> = self
            .buffers
            .iter()
            .filter_map(|(buffer_id, state)| {
                let path = state.buffer.file_path()?.to_path_buf();
                if path.as_os_str().is_empty() {
                    return None; // Skip unnamed buffers
                }
                Some((*buffer_id, path))
            })
            .collect();

        let mut recovered = 0;
        for (buffer_id, file_path) in buffer_files {
            let recovery_id = self.recovery_service.get_buffer_id(Some(&file_path));
            let entry = entries.iter().find(|e| e.id == recovery_id);
            if let Some(entry) = entry {
                match self.recovery_service.load_recovery(entry) {
                    Ok(crate::services::recovery::RecoveryResult::Recovered {
                        content, ..
                    }) => {
                        let mut mutated = false;
                        if let Some(state) = self.buffers.get_mut(&buffer_id) {
                            let current_len = state.buffer.total_bytes();
                            let text = String::from_utf8_lossy(&content).into_owned();
                            let current = state.buffer.get_text_range_mut(0, current_len).ok();
                            let current_text = current
                                .as_ref()
                                .map(|b| String::from_utf8_lossy(b).into_owned());
                            if current_text.as_deref() != Some(&text) {
                                state.buffer.delete(0..current_len);
                                state.buffer.insert(0, &text);
                                state.buffer.set_modified(true);
                                state.buffer.set_recovery_pending(false);
                                // Invalidate saved position so undo can't
                                // incorrectly clear the modified flag
                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
                                    log.clear_saved_position();
                                }
                                mutated = true;
                                recovered += 1;
                                tracing::info!(
                                    "Restored unsaved changes for {:?} from hot exit recovery",
                                    file_path
                                );
                            }
                        }
                        if mutated {
                            self.sync_lsp_after_recovery_replay(buffer_id);
                        }
                    }
                    Ok(crate::services::recovery::RecoveryResult::RecoveredChunks {
                        chunks,
                        ..
                    }) => {
                        let mut mutated = false;
                        if let Some(state) = self.buffers.get_mut(&buffer_id) {
                            for chunk in chunks.into_iter().rev() {
                                let text = String::from_utf8_lossy(&chunk.content).into_owned();
                                if chunk.original_len > 0 {
                                    state
                                        .buffer
                                        .delete(chunk.offset..chunk.offset + chunk.original_len);
                                }
                                state.buffer.insert(chunk.offset, &text);
                            }
                            state.buffer.set_modified(true);
                            state.buffer.set_recovery_pending(false);
                            // Invalidate saved position so undo can't
                            // incorrectly clear the modified flag
                            if let Some(log) = self.event_logs.get_mut(&buffer_id) {
                                log.clear_saved_position();
                            }
                            mutated = true;
                            recovered += 1;
                            tracing::info!(
                                "Restored unsaved changes (chunked) for {:?} from hot exit recovery",
                                file_path
                            );
                        }
                        if mutated {
                            self.sync_lsp_after_recovery_replay(buffer_id);
                        }
                    }
                    Ok(crate::services::recovery::RecoveryResult::OriginalFileModified {
                        original_path,
                        ..
                    }) => {
                        let name = original_path
                            .file_name()
                            .unwrap_or_default()
                            .to_string_lossy();
                        tracing::warn!("{} changed on disk; unsaved changes not restored", name);
                        self.set_status_message(format!(
                            "{} changed on disk; unsaved changes not restored",
                            name
                        ));
                    }
                    Ok(_) => {} // Corrupted, NotFound - skip
                    Err(e) => {
                        tracing::debug!(
                            "Failed to load hot exit recovery for {:?}: {}",
                            file_path,
                            e
                        );
                    }
                }
            }
        }

        Ok(recovered)
    }

    /// Apply a loaded workspace to the editor
    pub fn apply_workspace(&mut self, workspace: &Workspace) -> Result<(), WorkspaceError> {
        tracing::debug!(
            "Applying workspace with {} split states",
            workspace.split_states.len()
        );

        // 1. Apply config overrides
        if let Some(line_numbers) = workspace.config_overrides.line_numbers {
            self.config_mut().editor.line_numbers = line_numbers;
        }
        if let Some(relative_line_numbers) = workspace.config_overrides.relative_line_numbers {
            self.config_mut().editor.relative_line_numbers = relative_line_numbers;
        }
        if let Some(line_wrap) = workspace.config_overrides.line_wrap {
            self.config_mut().editor.line_wrap = line_wrap;
        }
        if let Some(syntax_highlighting) = workspace.config_overrides.syntax_highlighting {
            self.config_mut().editor.syntax_highlighting = syntax_highlighting;
        }
        if let Some(enable_inlay_hints) = workspace.config_overrides.enable_inlay_hints {
            self.config_mut().editor.enable_inlay_hints = enable_inlay_hints;
        }
        if let Some(mouse_enabled) = workspace.config_overrides.mouse_enabled {
            self.mouse_enabled = mouse_enabled;
        }
        if let Some(menu_bar_hidden) = workspace.config_overrides.menu_bar_hidden {
            self.menu_bar_visible = !menu_bar_hidden;
        }

        // 2. Restore plugin global state
        if !workspace.plugin_global_state.is_empty() {
            tracing::debug!(
                "Restoring plugin global state for {} plugins",
                workspace.plugin_global_state.len()
            );
            self.plugin_global_state = workspace.plugin_global_state.clone();
        }

        // 3. Restore search options
        self.search_case_sensitive = workspace.search_options.case_sensitive;
        self.search_whole_word = workspace.search_options.whole_word;
        self.search_use_regex = workspace.search_options.use_regex;
        self.search_confirm_each = workspace.search_options.confirm_each;

        // 3. Restore histories (merge with any existing)
        tracing::debug!(
            "Restoring histories: {} search, {} replace, {} goto_line",
            workspace.histories.search.len(),
            workspace.histories.replace.len(),
            workspace.histories.goto_line.len()
        );
        for item in &workspace.histories.search {
            self.get_or_create_prompt_history("search")
                .push(item.clone());
        }
        for item in &workspace.histories.replace {
            self.get_or_create_prompt_history("replace")
                .push(item.clone());
        }
        for item in &workspace.histories.goto_line {
            self.get_or_create_prompt_history("goto_line")
                .push(item.clone());
        }

        // 4. Restore file explorer state
        self.file_explorer_visible = workspace.file_explorer.visible;
        self.file_explorer_width = workspace.file_explorer.width;

        // Store pending show_hidden and show_gitignored settings (fixes #569)
        // These will be applied when the file explorer is initialized (async)
        if workspace.file_explorer.show_hidden {
            self.pending_file_explorer_show_hidden = Some(true);
        }
        if workspace.file_explorer.show_gitignored {
            self.pending_file_explorer_show_gitignored = Some(true);
        }

        // Initialize file explorer if it was visible in the workspace
        // Note: We keep key_context as Normal so the editor has focus, not the explorer
        if self.file_explorer_visible && self.file_explorer.is_none() {
            self.init_file_explorer();
        }

        // 5. Open files from the workspace and build buffer mappings
        // Collect all unique file paths from split_states (which tracks all open files per split)
        let file_paths = collect_file_paths_from_states(&workspace.split_states);
        tracing::debug!(
            "Workspace has {} files to restore: {:?}",
            file_paths.len(),
            file_paths
        );
        let mut path_to_buffer: HashMap<PathBuf, BufferId> = HashMap::new();

        for rel_path in file_paths {
            let abs_path = self.working_dir.join(&rel_path);
            tracing::trace!(
                "Checking file: {:?} (exists: {})",
                abs_path,
                abs_path.exists()
            );
            if abs_path.exists() {
                // Open the file (this will reuse existing buffer if already open)
                match self.open_file_internal(&abs_path) {
                    Ok(buffer_id) => {
                        tracing::debug!("Opened file {:?} as buffer {:?}", rel_path, buffer_id);
                        path_to_buffer.insert(rel_path, buffer_id);
                    }
                    Err(e) => {
                        tracing::warn!("Failed to open file {:?}: {}", abs_path, e);
                    }
                }
            } else {
                tracing::debug!("Skipping non-existent file: {:?}", abs_path);
            }
        }

        tracing::debug!("Opened {} files from workspace", path_to_buffer.len());

        // 5b. Restore external files (files outside the working directory)
        // These are stored as absolute paths
        if !workspace.external_files.is_empty() {
            tracing::debug!(
                "Restoring {} external files: {:?}",
                workspace.external_files.len(),
                workspace.external_files
            );
            for abs_path in &workspace.external_files {
                if abs_path.exists() {
                    match self.open_file_internal(abs_path) {
                        Ok(buffer_id) => {
                            // Add to path_to_buffer so open_tabs with absolute paths resolve
                            path_to_buffer.insert(abs_path.clone(), buffer_id);
                            tracing::debug!(
                                "Restored external file {:?} as buffer {:?}",
                                abs_path,
                                buffer_id
                            );
                        }
                        Err(e) => {
                            tracing::warn!("Failed to restore external file {:?}: {}", abs_path, e);
                        }
                    }
                } else {
                    tracing::debug!("Skipping non-existent external file: {:?}", abs_path);
                }
            }
        }

        // Re-apply read-only flag for files that were locked in the saved
        // session. Paths in read_only_files are relative (under working_dir)
        // or absolute — try both lookups.
        for ro_path in &workspace.read_only_files {
            let buffer_id = path_to_buffer
                .get(ro_path)
                .copied()
                .or_else(|| path_to_buffer.get(&self.working_dir.join(ro_path)).copied());
            if let Some(id) = buffer_id {
                self.mark_buffer_read_only(id, true);
            }
        }

        // 5b2. Apply hot exit recovery: restore unsaved changes to file-backed buffers
        if self.config.editor.hot_exit {
            let entries = self.recovery_service.list_recoverable().unwrap_or_default();
            if !entries.is_empty() {
                for &buffer_id in path_to_buffer.values() {
                    let file_path = self
                        .buffers
                        .get(&buffer_id)
                        .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()));
                    let file_path = match file_path {
                        Some(p) => p,
                        None => continue,
                    };

                    // Look for a recovery entry matching this file
                    let recovery_id = self.recovery_service.get_buffer_id(Some(&file_path));
                    let entry = entries.iter().find(|e| e.id == recovery_id);
                    if let Some(entry) = entry {
                        match self.recovery_service.load_recovery(entry) {
                            Ok(crate::services::recovery::RecoveryResult::Recovered {
                                content,
                                ..
                            }) => {
                                // Small file: replace buffer content with full recovered version
                                let mut mutated = false;
                                if let Some(state) = self.buffers.get_mut(&buffer_id) {
                                    let current_len = state.buffer.total_bytes();
                                    let text = String::from_utf8_lossy(&content).into_owned();
                                    let current =
                                        state.buffer.get_text_range_mut(0, current_len).ok();
                                    let current_text = current
                                        .as_ref()
                                        .map(|b| String::from_utf8_lossy(b).into_owned());
                                    if current_text.as_deref() != Some(&text) {
                                        state.buffer.delete(0..current_len);
                                        state.buffer.insert(0, &text);
                                        state.buffer.set_modified(true);
                                        state.buffer.set_recovery_pending(false);
                                        mutated = true;
                                        tracing::info!(
                                            "Restored unsaved changes for {:?} from hot exit recovery",
                                            file_path
                                        );
                                    }
                                }
                                // Invalidate saved position so undo can't
                                // incorrectly clear the modified flag
                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
                                    log.clear_saved_position();
                                }
                                if mutated {
                                    self.sync_lsp_after_recovery_replay(buffer_id);
                                }
                            }
                            Ok(crate::services::recovery::RecoveryResult::RecoveredChunks {
                                chunks,
                                ..
                            }) => {
                                // Large file: apply diff chunks on top of on-disk content
                                let mut mutated = false;
                                if let Some(state) = self.buffers.get_mut(&buffer_id) {
                                    for chunk in chunks.into_iter().rev() {
                                        let text =
                                            String::from_utf8_lossy(&chunk.content).into_owned();
                                        if chunk.original_len > 0 {
                                            state.buffer.delete(
                                                chunk.offset..chunk.offset + chunk.original_len,
                                            );
                                        }
                                        state.buffer.insert(chunk.offset, &text);
                                    }
                                    state.buffer.set_modified(true);
                                    state.buffer.set_recovery_pending(false);
                                    mutated = true;
                                    tracing::info!(
                                        "Restored unsaved changes (chunked) for {:?} from hot exit recovery",
                                        file_path
                                    );
                                }
                                // Invalidate saved position so undo can't
                                // incorrectly clear the modified flag
                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
                                    log.clear_saved_position();
                                }
                                if mutated {
                                    self.sync_lsp_after_recovery_replay(buffer_id);
                                }
                            }
                            Ok(
                                crate::services::recovery::RecoveryResult::OriginalFileModified {
                                    original_path,
                                    ..
                                },
                            ) => {
                                let name = original_path
                                    .file_name()
                                    .unwrap_or_default()
                                    .to_string_lossy();
                                tracing::warn!(
                                    "{} changed on disk; unsaved changes not restored",
                                    name
                                );
                                self.set_status_message(format!(
                                    "{} changed on disk; unsaved changes not restored",
                                    name
                                ));
                            }
                            Ok(_) => {} // Corrupted, NotFound - skip
                            Err(e) => {
                                tracing::debug!(
                                    "Failed to load hot exit recovery for {:?}: {}",
                                    file_path,
                                    e
                                );
                            }
                        }
                    }
                }
            }
        }

        // 5c. Restore unnamed buffers from recovery files
        let mut unnamed_buffer_map: HashMap<String, BufferId> = HashMap::new();
        if self.config.editor.hot_exit && !workspace.unnamed_buffers.is_empty() {
            tracing::debug!(
                "Restoring {} unnamed buffers from recovery",
                workspace.unnamed_buffers.len()
            );
            for unnamed_ref in &workspace.unnamed_buffers {
                // Try to load content from recovery files
                let entries = match self.recovery_service.list_recoverable() {
                    Ok(e) => e,
                    Err(e) => {
                        tracing::warn!("Failed to list recovery entries: {}", e);
                        continue;
                    }
                };

                let entry = entries.iter().find(|e| e.id == unnamed_ref.recovery_id);
                if let Some(entry) = entry {
                    match self.recovery_service.load_recovery(entry) {
                        Ok(crate::services::recovery::RecoveryResult::Recovered {
                            content,
                            ..
                        }) => {
                            let text = String::from_utf8_lossy(&content).into_owned();
                            let buffer_id = self.new_buffer();
                            {
                                let state = self.active_state_mut();
                                state.buffer.insert(0, &text);
                                // Mark as modified so it shows the dot indicator
                                state.buffer.set_modified(true);
                                state.buffer.set_recovery_pending(false);
                            }
                            // Invalidate saved position so undo can't
                            // incorrectly clear the modified flag
                            self.active_event_log_mut().clear_saved_position();

                            // Store recovery ID in metadata for future saves
                            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
                                meta.recovery_id = Some(unnamed_ref.recovery_id.clone());
                                meta.display_name = unnamed_ref.display_name.clone();
                            }

                            unnamed_buffer_map.insert(unnamed_ref.recovery_id.clone(), buffer_id);
                            tracing::info!(
                                "Restored unnamed buffer '{}' (recovery_id={})",
                                unnamed_ref.display_name,
                                unnamed_ref.recovery_id
                            );
                        }
                        Ok(other) => {
                            tracing::warn!(
                                "Unexpected recovery result for unnamed buffer {}: {:?}",
                                unnamed_ref.recovery_id,
                                std::mem::discriminant(&other)
                            );
                        }
                        Err(e) => {
                            tracing::warn!(
                                "Failed to load recovery for unnamed buffer {}: {}",
                                unnamed_ref.recovery_id,
                                e
                            );
                        }
                    }
                } else {
                    tracing::debug!(
                        "Recovery file not found for unnamed buffer {}",
                        unnamed_ref.recovery_id
                    );
                }
            }
        }

        // Restore terminals and build index -> buffer map
        let mut terminal_buffer_map: HashMap<usize, BufferId> = HashMap::new();
        if !workspace.terminals.is_empty() {
            if let Some(ref bridge) = self.async_bridge {
                self.terminal_manager.set_async_bridge(bridge.clone());
            }
            for terminal in &workspace.terminals {
                if let Some(buffer_id) = self.restore_terminal_from_workspace(terminal) {
                    terminal_buffer_map.insert(terminal.terminal_index, buffer_id);
                }
            }
        }

        // 6. Rebuild split layout from the saved tree
        // Map old split IDs to new ones as we create splits
        let mut split_id_map: HashMap<usize, SplitId> = HashMap::new();
        self.restore_split_node(
            &workspace.split_layout,
            &path_to_buffer,
            &terminal_buffer_map,
            &unnamed_buffer_map,
            &workspace.split_states,
            &mut split_id_map,
            true, // is_first_leaf - the first leaf reuses the existing split
        );

        // Set the active split based on the saved active_split_id
        // NOTE: active_buffer is now derived from split_manager, which was already
        // correctly set up by restore_split_view_state() via set_split_buffer()
        if let Some(&new_active_split) = split_id_map.get(&workspace.active_split_id) {
            self.split_manager
                .set_active_split(LeafId(new_active_split));
        }

        // 7. Restore bookmarks
        for (key, bookmark) in &workspace.bookmarks {
            if let Some(&buffer_id) = path_to_buffer.get(&bookmark.file_path) {
                // Verify position is valid
                if let Some(buffer) = self.buffers.get(&buffer_id) {
                    let pos = bookmark.position.min(buffer.buffer.len());
                    self.bookmarks.set(
                        *key,
                        Bookmark {
                            buffer_id,
                            position: pos,
                        },
                    );
                }
            }
        }

        // Clean up orphaned buffers: the initial empty buffer created at startup
        // may no longer be referenced by any split after workspace restore.
        let referenced: HashSet<BufferId> = self
            .split_view_states
            .values()
            .flat_map(|vs| vs.buffer_tab_ids())
            .collect();
        let orphans: Vec<BufferId> =
            self.buffers
                .keys()
                .copied()
                .filter(|id| {
                    !referenced.contains(id)
                        && self.buffers.get(id).is_some_and(|s| {
                            s.buffer.file_path().is_none() && !s.buffer.is_modified()
                        })
                })
                .collect();
        for id in orphans {
            tracing::debug!("Removing orphaned empty unnamed buffer {:?}", id);
            self.buffers.remove(&id);
            self.event_logs.remove(&id);
            self.buffer_metadata.remove(&id);
        }

        // Count restored buffers (excluding hidden/virtual)
        let restored_count = self
            .buffers
            .keys()
            .filter(|id| {
                self.buffer_metadata
                    .get(id)
                    .is_some_and(|m| !m.hidden_from_tabs && !m.is_virtual())
            })
            .count();
        if restored_count > 0 {
            let session_label = self
                .session_name
                .as_ref()
                .map(|n| format!("session '{}'", n));
            let msg = if let Some(label) = session_label {
                format!("Restored {} ({} buffer(s))", label, restored_count)
            } else {
                format!(
                    "Restored {} buffer(s) from previous session",
                    restored_count
                )
            };
            self.set_status_message(msg);
        }

        tracing::debug!(
            "Workspace restore complete: {} splits, {} buffers",
            self.split_view_states.len(),
            self.buffers.len()
        );

        // Fire buffer_activated for the active buffer so plugins can
        // re-enable compose mode (the plugin's composeBuffers set is empty
        // after restart). Only fires for the active buffer — other buffers
        // will get buffer_activated when the user switches to them.
        #[cfg(feature = "plugins")]
        {
            let buffer_id = self.active_buffer();
            self.update_plugin_state_snapshot();
            tracing::debug!(
                "Firing buffer_activated for active buffer {:?} after workspace restore",
                buffer_id
            );
            self.plugin_manager.run_hook(
                "buffer_activated",
                crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
            );
        }

        Ok(())
    }

    /// Restore a terminal from serialized workspace metadata.
    ///
    /// Uses the incremental streaming architecture for fast restore:
    /// 1. Load backing file directly as read-only buffer (lazy load)
    /// 2. Skip log replay entirely - user sees last workspace state immediately
    /// 3. Spawn new PTY for live terminal when user re-enters terminal mode
    ///
    /// Performance: O(1) for restore vs O(total_history) with log replay
    fn restore_terminal_from_workspace(
        &mut self,
        terminal: &SerializedTerminalWorkspace,
    ) -> Option<BufferId> {
        // Resolve paths (accept absolute; otherwise treat as relative to terminals dir)
        let terminals_root = self.dir_context.terminal_dir_for(&self.working_dir);
        let log_path = if terminal.log_path.is_absolute() {
            terminal.log_path.clone()
        } else {
            terminals_root.join(&terminal.log_path)
        };
        let backing_path = if terminal.backing_path.is_absolute() {
            terminal.backing_path.clone()
        } else {
            terminals_root.join(&terminal.backing_path)
        };

        // Best-effort directory creation for terminal backing files
        #[allow(clippy::let_underscore_must_use)]
        let _ = self.authority.filesystem.create_dir_all(
            log_path
                .parent()
                .or_else(|| backing_path.parent())
                .unwrap_or(&terminals_root),
        );

        // Record paths using the predicted ID so buffer creation can reuse them
        let predicted_id = self.terminal_manager.next_terminal_id();
        self.terminal_log_files
            .insert(predicted_id, log_path.clone());
        self.terminal_backing_files
            .insert(predicted_id, backing_path.clone());

        // Spawn the terminal with backing file for incremental scrollback
        let terminal_id = match self.terminal_manager.spawn(
            terminal.cols,
            terminal.rows,
            terminal.cwd.clone(),
            Some(log_path.clone()),
            Some(backing_path.clone()),
            self.resolved_terminal_wrapper(),
        ) {
            Ok(id) => id,
            Err(e) => {
                tracing::warn!(
                    "Failed to restore terminal {}: {}",
                    terminal.terminal_index,
                    e
                );
                return None;
            }
        };

        // Ensure maps keyed by actual ID
        if terminal_id != predicted_id {
            self.terminal_log_files
                .insert(terminal_id, log_path.clone());
            self.terminal_backing_files
                .insert(terminal_id, backing_path.clone());
            self.terminal_log_files.remove(&predicted_id);
            self.terminal_backing_files.remove(&predicted_id);
        }

        // Create buffer for this terminal
        let buffer_id = self.create_terminal_buffer_detached(terminal_id);

        // Load backing file directly as read-only buffer (skip log replay)
        // The backing file already contains complete terminal state from last workspace
        self.load_terminal_backing_file_as_buffer(buffer_id, &backing_path);

        Some(buffer_id)
    }

    /// Load a terminal backing file directly as a read-only buffer.
    ///
    /// This is used for fast workspace restore - we load the pre-rendered backing
    /// file instead of replaying the raw log through the VTE parser.
    fn load_terminal_backing_file_as_buffer(&mut self, buffer_id: BufferId, backing_path: &Path) {
        // Check if backing file exists; if not, terminal starts empty
        if !backing_path.exists() {
            return;
        }

        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
        if let Ok(new_state) = EditorState::from_file_with_languages(
            backing_path,
            self.terminal_width,
            self.terminal_height,
            large_file_threshold,
            &self.grammar_registry,
            &self.config.languages,
            std::sync::Arc::clone(&self.authority.filesystem),
        ) {
            if let Some(state) = self.buffers.get_mut(&buffer_id) {
                *state = new_state;
                // Move cursor to end of buffer
                let total = state.buffer.total_bytes();
                // Update cursor position in all splits that show this buffer
                for vs in self.split_view_states.values_mut() {
                    if vs.has_buffer(buffer_id) {
                        vs.cursors.primary_mut().position = total;
                    }
                }
                // Terminal buffers should never be considered "modified"
                state.buffer.set_modified(false);
                // Start in scrollback mode (editing disabled)
                state.editing_disabled = true;
                state.margins.configure_for_line_numbers(false);
            }
        }
    }

    /// Internal helper to open a file and return its buffer ID
    fn open_file_internal(&mut self, path: &Path) -> Result<BufferId, WorkspaceError> {
        // Check if file is already open
        for (buffer_id, metadata) in &self.buffer_metadata {
            if let Some(file_path) = metadata.file_path() {
                if file_path == path {
                    return Ok(*buffer_id);
                }
            }
        }

        // File not open, open it using the Editor's open_file method
        self.open_file(path).map_err(WorkspaceError::Io)
    }

    /// Recursively restore the split layout from a serialized tree
    #[allow(clippy::too_many_arguments)]
    fn restore_split_node(
        &mut self,
        node: &SerializedSplitNode,
        path_to_buffer: &HashMap<PathBuf, BufferId>,
        terminal_buffers: &HashMap<usize, BufferId>,
        unnamed_buffers: &HashMap<String, BufferId>,
        split_states: &HashMap<usize, SerializedSplitViewState>,
        split_id_map: &mut HashMap<usize, SplitId>,
        is_first_leaf: bool,
    ) {
        match node {
            SerializedSplitNode::Leaf {
                file_path,
                split_id,
                label,
                unnamed_recovery_id,
            } => {
                // Get the buffer for this leaf: file path, unnamed recovery ID, or default
                let buffer_id = file_path
                    .as_ref()
                    .and_then(|p| path_to_buffer.get(p).copied())
                    .or_else(|| {
                        unnamed_recovery_id
                            .as_ref()
                            .and_then(|id| unnamed_buffers.get(id).copied())
                    })
                    .unwrap_or(self.active_buffer());

                let current_leaf_id = if is_first_leaf {
                    // First leaf reuses the existing split
                    let leaf_id = self.split_manager.active_split();
                    self.set_pane_buffer(leaf_id, buffer_id);
                    leaf_id
                } else {
                    // Non-first leaves use the active split (created by split_active)
                    self.split_manager.active_split()
                };

                // Map old split ID to new one
                split_id_map.insert(*split_id, current_leaf_id.into());

                // Restore label if present
                if let Some(label) = label {
                    self.split_manager.set_label(current_leaf_id, label.clone());
                }

                // Restore the view state for this split
                self.restore_split_view_state(
                    current_leaf_id,
                    *split_id,
                    split_states,
                    path_to_buffer,
                    terminal_buffers,
                    unnamed_buffers,
                );
            }
            SerializedSplitNode::Terminal {
                terminal_index,
                split_id,
                label,
            } => {
                let buffer_id = terminal_buffers
                    .get(terminal_index)
                    .copied()
                    .unwrap_or(self.active_buffer());

                let current_leaf_id = if is_first_leaf {
                    let leaf_id = self.split_manager.active_split();
                    self.set_pane_buffer(leaf_id, buffer_id);
                    leaf_id
                } else {
                    self.split_manager.active_split()
                };

                split_id_map.insert(*split_id, current_leaf_id.into());

                // Restore label if present
                if let Some(label) = label {
                    self.split_manager.set_label(current_leaf_id, label.clone());
                }

                self.split_manager
                    .set_split_buffer(current_leaf_id, buffer_id);

                self.restore_split_view_state(
                    current_leaf_id,
                    *split_id,
                    split_states,
                    path_to_buffer,
                    terminal_buffers,
                    unnamed_buffers,
                );
            }
            SerializedSplitNode::Split {
                direction,
                first,
                second,
                ratio,
                split_id,
            } => {
                // First, restore the first child (it uses the current active split)
                self.restore_split_node(
                    first,
                    path_to_buffer,
                    terminal_buffers,
                    unnamed_buffers,
                    split_states,
                    split_id_map,
                    is_first_leaf,
                );

                // Get the buffer for the second child's first leaf
                let second_buffer_id = get_first_leaf_buffer(
                    second,
                    path_to_buffer,
                    terminal_buffers,
                    unnamed_buffers,
                )
                .unwrap_or(self.active_buffer());

                // Convert direction
                let split_direction = match direction {
                    SerializedSplitDirection::Horizontal => SplitDirection::Horizontal,
                    SerializedSplitDirection::Vertical => SplitDirection::Vertical,
                };

                // Create the split for the second child
                match self
                    .split_manager
                    .split_active(split_direction, second_buffer_id, *ratio)
                {
                    Ok(new_leaf_id) => {
                        // Create view state for the new split
                        let mut view_state = SplitViewState::with_buffer(
                            self.terminal_width,
                            self.terminal_height,
                            second_buffer_id,
                        );
                        view_state.apply_config_defaults(
                            self.config.editor.line_numbers,
                            self.config.editor.highlight_current_line,
                            self.resolve_line_wrap_for_buffer(second_buffer_id),
                            self.config.editor.wrap_indent,
                            self.resolve_wrap_column_for_buffer(second_buffer_id),
                            self.config.editor.rulers.clone(),
                        );
                        self.split_view_states.insert(new_leaf_id, view_state);

                        // Map the container split ID (though we mainly care about leaves)
                        split_id_map.insert(*split_id, new_leaf_id.into());

                        // Recursively restore the second child (it's now in the new split)
                        self.restore_split_node(
                            second,
                            path_to_buffer,
                            terminal_buffers,
                            unnamed_buffers,
                            split_states,
                            split_id_map,
                            false,
                        );
                    }
                    Err(e) => {
                        tracing::error!("Failed to create split during workspace restore: {}", e);
                    }
                }
            }
        }
    }

    /// Restore view state for a specific split
    fn restore_split_view_state(
        &mut self,
        current_split_id: LeafId,
        saved_split_id: usize,
        split_states: &HashMap<usize, SerializedSplitViewState>,
        path_to_buffer: &HashMap<PathBuf, BufferId>,
        terminal_buffers: &HashMap<usize, BufferId>,
        unnamed_buffers: &HashMap<String, BufferId>,
    ) {
        // Try to find the saved state for this split
        let Some(split_state) = split_states.get(&saved_split_id) else {
            return;
        };

        let Some(view_state) = self.split_view_states.get_mut(&current_split_id) else {
            return;
        };

        let mut active_buffer_id: Option<BufferId> = None;

        if !split_state.open_tabs.is_empty() {
            // Clear pre-existing open_buffers (e.g. the initial empty buffer
            // created at startup) so only the saved tabs appear.
            view_state.open_buffers.clear();

            for tab in &split_state.open_tabs {
                match tab {
                    SerializedTabRef::File(rel_path) => {
                        if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
                            if !view_state.has_buffer(buffer_id) {
                                view_state.add_buffer(buffer_id);
                            }
                            // Ensure keyed state exists for this buffer
                            view_state.ensure_buffer_state(buffer_id);
                            if terminal_buffers.values().any(|&tid| tid == buffer_id) {
                                view_state
                                    .buffer_state_mut(buffer_id)
                                    .unwrap()
                                    .viewport
                                    .line_wrap_enabled = false;
                            }
                        }
                    }
                    SerializedTabRef::Terminal(index) => {
                        if let Some(&buffer_id) = terminal_buffers.get(index) {
                            if !view_state.has_buffer(buffer_id) {
                                view_state.add_buffer(buffer_id);
                            }
                            view_state
                                .ensure_buffer_state(buffer_id)
                                .viewport
                                .line_wrap_enabled = false;
                        }
                    }
                    SerializedTabRef::Unnamed(recovery_id) => {
                        if let Some(&buffer_id) = unnamed_buffers.get(recovery_id) {
                            if !view_state.has_buffer(buffer_id) {
                                view_state.add_buffer(buffer_id);
                            }
                            view_state.ensure_buffer_state(buffer_id);
                        }
                    }
                }
            }

            // If all saved tabs referenced deleted/missing files, open_buffers
            // is now empty. Re-add the buffer that the split manager assigned to
            // this split so the orphan cleanup won't remove a buffer the split
            // manager still points to (#1278).
            if view_state.open_buffers.is_empty() {
                if let Some(buf) = self.split_manager.buffer_for_split(current_split_id) {
                    view_state.add_buffer(buf);
                    view_state.ensure_buffer_state(buf);
                }
            }

            if let Some(active_idx) = split_state.active_tab_index {
                if let Some(tab) = split_state.open_tabs.get(active_idx) {
                    active_buffer_id = match tab {
                        SerializedTabRef::File(rel) => path_to_buffer.get(rel).copied(),
                        SerializedTabRef::Terminal(index) => terminal_buffers.get(index).copied(),
                        SerializedTabRef::Unnamed(id) => unnamed_buffers.get(id).copied(),
                    };
                }
            }
        } else {
            // Backward compatibility path using open_files/active_file_index
            for rel_path in &split_state.open_files {
                if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
                    if !view_state.has_buffer(buffer_id) {
                        view_state.add_buffer(buffer_id);
                    }
                    view_state.ensure_buffer_state(buffer_id);
                }
            }

            let active_file_path = split_state.open_files.get(split_state.active_file_index);
            active_buffer_id =
                active_file_path.and_then(|rel_path| path_to_buffer.get(rel_path).copied());
        }

        // Restore cursor, scroll, view_mode, and compose_width for ALL buffers in file_states
        for (rel_path, file_state) in &split_state.file_states {
            // Look up buffer by path, or by unnamed recovery ID
            let rel_str = rel_path.to_string_lossy();
            let buffer_id = if let Some(recovery_id) = rel_str.strip_prefix("__unnamed__") {
                match unnamed_buffers.get(recovery_id).copied() {
                    Some(id) => id,
                    None => continue,
                }
            } else {
                match path_to_buffer.get(rel_path).copied() {
                    Some(id) => id,
                    None => continue,
                }
            };
            let max_pos = self
                .buffers
                .get(&buffer_id)
                .map(|b| b.buffer.len())
                .unwrap_or(0);

            // Ensure keyed state exists for this buffer
            let buf_state = view_state.ensure_buffer_state(buffer_id);

            let cursor_pos = file_state.cursor.position.min(max_pos);
            buf_state.cursors.primary_mut().position = cursor_pos;
            buf_state.cursors.primary_mut().anchor =
                file_state.cursor.anchor.map(|a| a.min(max_pos));
            buf_state.cursors.primary_mut().sticky_column = file_state.cursor.sticky_column;

            buf_state.viewport.top_byte = file_state.scroll.top_byte.min(max_pos);
            buf_state.viewport.top_view_line_offset = file_state.scroll.top_view_line_offset;
            buf_state.viewport.left_column = file_state.scroll.left_column;
            buf_state.viewport.set_skip_resize_sync();

            // Saved cursor and saved viewport are independent fields; if they
            // were already out of sync at save time (cursor moved off-screen
            // before the user closed) the restore re-creates an off-screen
            // cursor that arrow keys can't escape (the wrap-mode early return
            // in `viewport.rs::ensure_visible` no-ops for any cursor whose
            // byte position is `>= viewport.top_byte`). Reconcile so the
            // restored view always shows the cursor (#1689 follow-up).
            if let Some(state) = self.buffers.get_mut(&buffer_id) {
                super::navigation::reconcile_restored_buffer_view(buf_state, &mut state.buffer);
            }

            // Restore per-buffer view mode and compose width
            buf_state.view_mode = match file_state.view_mode {
                SerializedViewMode::Source => ViewMode::Source,
                SerializedViewMode::PageView => ViewMode::PageView,
            };
            buf_state.compose_width = file_state.compose_width;
            buf_state.plugin_state = file_state.plugin_state.clone();
            if let Some(state) = self.buffers.get_mut(&buffer_id) {
                buf_state.folds.clear(&mut state.marker_list);
                for fold in &file_state.folds {
                    // Resolve the stored line numbers against the current
                    // buffer content. If a header_text was recorded (issue
                    // #1568), validate — and if necessary relocate — the
                    // fold so it lands on the line it was actually meant
                    // for, even after an external edit shifted line
                    // numbers.
                    let Some(resolved_header) = resolve_fold_header_line(
                        &state.buffer,
                        fold.header_line,
                        fold.header_text.as_deref(),
                    ) else {
                        tracing::debug!(
                            "Dropping stale fold: header_line={} no longer matches stored \
                             header_text after external edit",
                            fold.header_line,
                        );
                        continue;
                    };

                    // Adjust end_line by the same shift we applied to the header.
                    let shift = resolved_header as i64 - fold.header_line as i64;
                    let adjusted_end = (fold.end_line as i64 + shift).max(0) as usize;
                    let start_line = resolved_header.saturating_add(1);
                    let end_line = adjusted_end;
                    if start_line > end_line {
                        continue;
                    }
                    let Some(start_byte) = state.buffer.line_start_offset(start_line) else {
                        continue;
                    };
                    let end_byte = state
                        .buffer
                        .line_start_offset(end_line.saturating_add(1))
                        .unwrap_or_else(|| state.buffer.len());
                    buf_state.folds.add(
                        &mut state.marker_list,
                        start_byte,
                        end_byte,
                        fold.placeholder.clone(),
                    );
                }
            }

            tracing::trace!(
                "Restored keyed state for {:?}: cursor={}, top_byte={}, view_mode={:?}",
                rel_path,
                cursor_pos,
                buf_state.viewport.top_byte,
                buf_state.view_mode,
            );
        }

        // For buffers without saved file_state (e.g., terminals), apply split-level
        // view_mode/compose_width as fallback (backward compatibility)
        let restored_view_mode = match split_state.view_mode {
            SerializedViewMode::Source => ViewMode::Source,
            SerializedViewMode::PageView => ViewMode::PageView,
        };

        if let Some(active_id) = active_buffer_id {
            // Switch the split to the active buffer
            view_state.switch_buffer(active_id);

            // If no per-buffer file_state was saved, apply split-level settings
            let active_has_file_state = split_state
                .file_states
                .keys()
                .any(|rel_path| path_to_buffer.get(rel_path).copied() == Some(active_id));
            if !active_has_file_state {
                view_state.active_state_mut().view_mode = restored_view_mode.clone();
                view_state.active_state_mut().compose_width = split_state.compose_width;
            }

            // Cursors now live in SplitViewState, no need to sync to EditorState

            // Set this buffer as active in the split (fires buffer_activated hook)
            self.split_manager
                .set_split_buffer(current_split_id, active_id);
        }
        view_state.tab_scroll_offset = split_state.tab_scroll_offset;
    }
}

/// Helper: Get the buffer ID from the first leaf node in a split tree
fn get_first_leaf_buffer(
    node: &SerializedSplitNode,
    path_to_buffer: &HashMap<PathBuf, BufferId>,
    terminal_buffers: &HashMap<usize, BufferId>,
    unnamed_buffers: &HashMap<String, BufferId>,
) -> Option<BufferId> {
    match node {
        SerializedSplitNode::Leaf {
            file_path,
            unnamed_recovery_id,
            ..
        } => file_path
            .as_ref()
            .and_then(|p| path_to_buffer.get(p).copied())
            .or_else(|| {
                unnamed_recovery_id
                    .as_ref()
                    .and_then(|id| unnamed_buffers.get(id).copied())
            }),
        SerializedSplitNode::Terminal { terminal_index, .. } => {
            terminal_buffers.get(terminal_index).copied()
        }
        SerializedSplitNode::Split { first, .. } => {
            get_first_leaf_buffer(first, path_to_buffer, terminal_buffers, unnamed_buffers)
        }
    }
}

// ============================================================================
// Serialization helpers
// ============================================================================

fn serialize_split_node(
    node: &SplitNode,
    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
    working_dir: &Path,
    terminal_buffers: &HashMap<BufferId, TerminalId>,
    terminal_indices: &HashMap<TerminalId, usize>,
    split_labels: &HashMap<SplitId, String>,
) -> SerializedSplitNode {
    serialize_split_node_pruned(
        node,
        buffer_metadata,
        working_dir,
        terminal_buffers,
        terminal_indices,
        split_labels,
    )
    .unwrap_or_else(|| {
        // Entire tree was virtual buffers — nothing to persist.  Fall back to
        // an empty [No Name] leaf so the restored workspace is still valid.
        SerializedSplitNode::Leaf {
            file_path: None,
            split_id: 0,
            label: None,
            unnamed_recovery_id: None,
        }
    })
}

/// Like `serialize_split_node` but returns `None` for subtrees that only
/// contain transient virtual buffers (e.g. `*Search/Replace*` panels).
/// Virtual buffers can't be rebuilt from disk, so persisting their split
/// would leave an empty or mis-attributed pane on restore (see bug #5).
/// When one child of a Split prunes away, the surviving child is hoisted in
/// place of the whole Split node.
fn serialize_split_node_pruned(
    node: &SplitNode,
    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
    working_dir: &Path,
    terminal_buffers: &HashMap<BufferId, TerminalId>,
    terminal_indices: &HashMap<TerminalId, usize>,
    split_labels: &HashMap<SplitId, String>,
) -> Option<SerializedSplitNode> {
    match node {
        SplitNode::Grouped { layout, .. } => {
            // Grouped nodes are rebuilt by plugins on load; serialize just
            // the inner layout so the split tree structure is preserved
            // without the group wrapper.
            serialize_split_node_pruned(
                layout,
                buffer_metadata,
                working_dir,
                terminal_buffers,
                terminal_indices,
                split_labels,
            )
        }
        SplitNode::Leaf {
            buffer_id,
            split_id,
        } => {
            let raw_split_id: SplitId = (*split_id).into();
            let label = split_labels.get(&raw_split_id).cloned();

            if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
                if let Some(index) = terminal_indices.get(terminal_id) {
                    return Some(SerializedSplitNode::Terminal {
                        terminal_index: *index,
                        split_id: raw_split_id.0,
                        label,
                    });
                }
            }

            let meta = buffer_metadata.get(buffer_id);

            // Virtual buffers (e.g. the *Search/Replace* panel) have no
            // persistent identity — drop them and let the parent Split node
            // collapse to the sibling.
            if meta.map(|m| m.is_virtual()).unwrap_or(false) {
                return None;
            }

            let file_path = meta.and_then(|m| m.file_path()).and_then(|abs_path| {
                if abs_path.as_os_str().is_empty() {
                    None // unnamed buffer
                } else {
                    abs_path
                        .strip_prefix(working_dir)
                        .ok()
                        .map(|p| p.to_path_buf())
                }
            });

            // For unnamed buffers, emit their recovery ID so workspace restore
            // can load content from recovery files
            let unnamed_recovery_id = if file_path.is_none() {
                meta.and_then(|m| m.recovery_id.clone())
            } else {
                None
            };

            Some(SerializedSplitNode::Leaf {
                file_path,
                split_id: raw_split_id.0,
                label,
                unnamed_recovery_id,
            })
        }
        SplitNode::Split {
            direction,
            first,
            second,
            ratio,
            split_id,
            ..
        } => {
            let raw_split_id: SplitId = (*split_id).into();
            let first = serialize_split_node_pruned(
                first,
                buffer_metadata,
                working_dir,
                terminal_buffers,
                terminal_indices,
                split_labels,
            );
            let second = serialize_split_node_pruned(
                second,
                buffer_metadata,
                working_dir,
                terminal_buffers,
                terminal_indices,
                split_labels,
            );
            match (first, second) {
                (Some(f), Some(s)) => Some(SerializedSplitNode::Split {
                    direction: match direction {
                        SplitDirection::Horizontal => SerializedSplitDirection::Horizontal,
                        SplitDirection::Vertical => SerializedSplitDirection::Vertical,
                    },
                    first: Box::new(f),
                    second: Box::new(s),
                    ratio: *ratio,
                    split_id: raw_split_id.0,
                }),
                // One side was a virtual-buffer-only subtree — collapse to
                // the surviving sibling.
                (Some(only), None) | (None, Some(only)) => Some(only),
                (None, None) => None,
            }
        }
    }
}

fn serialize_split_view_state(
    view_state: &crate::view::split::SplitViewState,
    buffers: &HashMap<BufferId, EditorState>,
    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
    working_dir: &Path,
    active_buffer: Option<BufferId>,
    terminal_buffers: &HashMap<BufferId, TerminalId>,
    terminal_indices: &HashMap<TerminalId, usize>,
) -> SerializedSplitViewState {
    let mut open_tabs = Vec::new();
    let mut open_files = Vec::new();
    let mut active_tab_index = None;

    // Only serialize buffer tabs; group tabs are rebuilt by plugins on load.
    for buffer_id in view_state.buffer_tab_ids() {
        let buffer_id = &buffer_id;
        let tab_index = open_tabs.len();
        if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
            if let Some(idx) = terminal_indices.get(terminal_id) {
                open_tabs.push(SerializedTabRef::Terminal(*idx));
                if Some(*buffer_id) == active_buffer {
                    active_tab_index = Some(tab_index);
                }
                continue;
            }
        }

        if let Some(meta) = buffer_metadata.get(buffer_id) {
            if let Some(abs_path) = meta.file_path() {
                if abs_path.as_os_str().is_empty() {
                    // Unnamed buffer - reference by recovery ID
                    if let Some(ref recovery_id) = meta.recovery_id {
                        open_tabs.push(SerializedTabRef::Unnamed(recovery_id.clone()));
                        if Some(*buffer_id) == active_buffer {
                            active_tab_index = Some(tab_index);
                        }
                    }
                } else if let Ok(rel_path) = abs_path.strip_prefix(working_dir) {
                    open_tabs.push(SerializedTabRef::File(rel_path.to_path_buf()));
                    open_files.push(rel_path.to_path_buf());
                    if Some(*buffer_id) == active_buffer {
                        active_tab_index = Some(tab_index);
                    }
                } else {
                    // External file (outside working_dir) - store absolute path
                    open_tabs.push(SerializedTabRef::File(abs_path.to_path_buf()));
                    if Some(*buffer_id) == active_buffer {
                        active_tab_index = Some(tab_index);
                    }
                }
            }
        }
    }

    // Derive active_file_index for backward compatibility
    let active_file_index = active_tab_index
        .and_then(|idx| open_tabs.get(idx))
        .and_then(|tab| match tab {
            SerializedTabRef::File(path) => {
                Some(open_files.iter().position(|p| p == path).unwrap_or(0))
            }
            _ => None,
        })
        .unwrap_or(0);

    // Serialize file states for ALL buffers in keyed_states (not just the active one)
    let mut file_states = HashMap::new();
    for (buffer_id, buf_state) in &view_state.keyed_states {
        let Some(meta) = buffer_metadata.get(buffer_id) else {
            continue;
        };
        let Some(abs_path) = meta.file_path() else {
            continue;
        };

        // Determine the key for this buffer's state
        let state_key = if abs_path.as_os_str().is_empty() {
            // Unnamed buffer - use recovery ID as key
            if let Some(ref recovery_id) = meta.recovery_id {
                PathBuf::from(format!("__unnamed__{}", recovery_id))
            } else {
                continue;
            }
        } else if let Ok(rp) = abs_path.strip_prefix(working_dir) {
            rp.to_path_buf()
        } else {
            // External file - use absolute path as key
            abs_path.to_path_buf()
        };

        let primary_cursor = buf_state.cursors.primary();
        let folds = buffers
            .get(buffer_id)
            .map(|state| {
                buf_state
                    .folds
                    .collapsed_line_ranges(&state.buffer, &state.marker_list)
                    .into_iter()
                    .map(|range| SerializedFoldRange {
                        header_line: range.header_line,
                        end_line: range.end_line,
                        placeholder: range.placeholder,
                        header_text: range.header_text,
                    })
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();

        file_states.insert(
            state_key,
            SerializedFileState {
                cursor: SerializedCursor {
                    position: primary_cursor.position,
                    anchor: primary_cursor.anchor,
                    sticky_column: primary_cursor.sticky_column,
                },
                additional_cursors: buf_state
                    .cursors
                    .iter()
                    .skip(1) // Skip primary
                    .map(|(_, cursor)| SerializedCursor {
                        position: cursor.position,
                        anchor: cursor.anchor,
                        sticky_column: cursor.sticky_column,
                    })
                    .collect(),
                scroll: SerializedScroll {
                    top_byte: buf_state.viewport.top_byte,
                    top_view_line_offset: buf_state.viewport.top_view_line_offset,
                    left_column: buf_state.viewport.left_column,
                },
                view_mode: match buf_state.view_mode {
                    ViewMode::Source => SerializedViewMode::Source,
                    ViewMode::PageView => SerializedViewMode::PageView,
                },
                compose_width: buf_state.compose_width,
                plugin_state: buf_state.plugin_state.clone(),
                folds,
            },
        );
    }

    // Active buffer's view_mode/compose_width for the split-level fields (backward compat)
    let active_view_mode = active_buffer
        .and_then(|id| view_state.keyed_states.get(&id))
        .map(|bs| match bs.view_mode {
            ViewMode::Source => SerializedViewMode::Source,
            ViewMode::PageView => SerializedViewMode::PageView,
        })
        .unwrap_or(SerializedViewMode::Source);
    let active_compose_width = active_buffer
        .and_then(|id| view_state.keyed_states.get(&id))
        .and_then(|bs| bs.compose_width);

    SerializedSplitViewState {
        open_tabs,
        active_tab_index,
        open_files,
        active_file_index,
        file_states,
        tab_scroll_offset: view_state.tab_scroll_offset,
        view_mode: active_view_mode,
        compose_width: active_compose_width,
    }
}

fn serialize_bookmarks(
    bookmarks: &BookmarkState,
    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
    working_dir: &Path,
) -> HashMap<char, SerializedBookmark> {
    bookmarks
        .iter()
        .filter_map(|(key, bookmark)| {
            buffer_metadata
                .get(&bookmark.buffer_id)
                .and_then(|meta| meta.file_path())
                .and_then(|abs_path| {
                    abs_path.strip_prefix(working_dir).ok().map(|rel_path| {
                        (
                            key,
                            SerializedBookmark {
                                file_path: rel_path.to_path_buf(),
                                position: bookmark.position,
                            },
                        )
                    })
                })
        })
        .collect()
}

/// Collect all unique file paths from split_states
fn collect_file_paths_from_states(
    split_states: &HashMap<usize, SerializedSplitViewState>,
) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    for state in split_states.values() {
        if !state.open_tabs.is_empty() {
            for tab in &state.open_tabs {
                if let SerializedTabRef::File(path) = tab {
                    if !paths.contains(path) {
                        paths.push(path.clone());
                    }
                }
            }
        } else {
            for path in &state.open_files {
                if !paths.contains(path) {
                    paths.push(path.clone());
                }
            }
        }
    }
    paths
}

/// Get list of expanded directories from a FileTreeView
fn get_expanded_dirs(
    explorer: &crate::view::file_tree::FileTreeView,
    working_dir: &Path,
) -> Vec<PathBuf> {
    let mut expanded = Vec::new();
    let tree = explorer.tree();

    // Iterate through all nodes and collect expanded directories
    for node in tree.all_nodes() {
        if node.is_expanded() && node.is_dir() {
            // Get the path and make it relative to working_dir
            if let Ok(rel_path) = node.entry.path.strip_prefix(working_dir) {
                expanded.push(rel_path.to_path_buf());
            }
        }
    }

    expanded
}
