//! File operations for the Editor.
//!
//! This module contains file I/O and watching operations:
//! - Saving buffers
//! - Reverting to saved version
//! - Auto-revert and file change polling
//! - LSP file notifications (open, change)
//! - File modification time tracking
//! - Save conflict detection

use crate::model::buffer::SudoSaveRequired;
use crate::model::filesystem::FileSystem;
use crate::view::file_tree::FileTreeView;
use crate::view::prompt::PromptType;
use std::path::{Path, PathBuf};

use lsp_types::TextDocumentContentChangeEvent;
use rust_i18n::t;

use crate::model::event::{BufferId, EventLog};
use crate::services::lsp::manager::LspSpawnResult;
use crate::state::EditorState;

use super::{BufferMetadata, Editor};

impl Editor {
    /// Save the active buffer
    pub fn save(&mut self) -> anyhow::Result<()> {
        // Fail fast if remote connection is down
        if !self.authority().filesystem.is_remote_connected() {
            anyhow::bail!(
                "Cannot save: remote connection lost ({})",
                self.authority()
                    .filesystem
                    .remote_connection_info()
                    .unwrap_or("unknown host")
            );
        }

        let path = self
            .active_state()
            .buffer
            .file_path()
            .map(|p| p.to_path_buf());

        match self.active_state_mut().buffer.save() {
            Ok(()) => self.finalize_save(path),
            Err(e) => {
                if let Some(sudo_info) = e.downcast_ref::<SudoSaveRequired>() {
                    let info = sudo_info.clone();
                    self.start_prompt(
                        t!("prompt.sudo_save_confirm").to_string(),
                        PromptType::ConfirmSudoSave { info },
                    );
                    Ok(())
                } else if let Some(path) = path {
                    // Check if failure is due to non-existent parent directory
                    let is_not_found = e
                        .downcast_ref::<std::io::Error>()
                        .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound);
                    if is_not_found {
                        if let Some(parent) = path.parent() {
                            if !self.authority().filesystem.exists(parent) {
                                let dir_name = parent
                                    .strip_prefix(self.working_dir())
                                    .unwrap_or(parent)
                                    .display()
                                    .to_string();
                                self.start_prompt(
                                    t!("buffer.create_directory_confirm", name = &dir_name)
                                        .to_string(),
                                    PromptType::ConfirmCreateDirectory { path },
                                );
                                return Ok(());
                            }
                        }
                    }
                    Err(e)
                } else {
                    Err(e)
                }
            }
        }
    }

    /// Internal helper to finalize save state (mark as saved, notify LSP, etc.)
    pub(crate) fn finalize_save(&mut self, path: Option<PathBuf>) -> anyhow::Result<()> {
        let buffer_id = self.active_buffer();
        self.finalize_save_buffer(buffer_id, path, false)
    }

    /// Internal helper to finalize save state for a specific buffer
    pub(crate) fn finalize_save_buffer(
        &mut self,
        buffer_id: BufferId,
        path: Option<PathBuf>,
        silent: bool,
    ) -> anyhow::Result<()> {
        // Auto-detect language if it's currently "text" and we have a path
        if let Some(ref p) = path {
            if let Some(state) = self
                .windows
                .get_mut(&self.active_window)
                .map(|w| &mut w.buffers)
                .expect("active window present")
                .get_mut(&buffer_id)
            {
                if state.language == "text" {
                    let first_line = state.buffer.first_line_lossy();
                    let detected =
                        crate::primitives::detected_language::DetectedLanguage::from_path(
                            p,
                            first_line.as_deref(),
                            &self.grammar_registry,
                            &self.config.languages,
                        );
                    state.apply_language(detected);
                    state.apply_buffer_config(&self.config);
                }
            }
        }

        if !silent {
            self.active_window_mut().status_message = Some(t!("status.file_saved").to_string());
        }

        // Mark the event log position as saved (for undo modified tracking)
        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
            event_log.mark_saved();
        }

        // Update file modification time after save
        if let Some(ref p) = path {
            if let Ok(metadata) = self.authority().filesystem.metadata(p) {
                if let Some(mtime) = metadata.modified {
                    self.file_mod_times_mut().insert(p.clone(), mtime);
                }
            }
        }

        // Reload .gitignore in the file explorer when the user saves one.
        // Otherwise the tree keeps filtering by the old rules until restart.
        if let Some(ref p) = path {
            if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
                if let Some(parent) = p.parent() {
                    let parent = parent.to_path_buf();
                    let fs = self.authority().filesystem.clone();
                    if let Some(explorer) = self.file_explorer_mut().as_mut() {
                        load_gitignore_via_fs(fs.as_ref(), explorer, &parent);
                    }
                }
            }
        }

        // Notify LSP of save
        self.active_window_mut().notify_lsp_save_buffer(buffer_id);

        // Delete recovery file (buffer is now saved)
        if let Err(e) = self.delete_buffer_recovery(buffer_id) {
            tracing::warn!("Failed to delete recovery file: {}", e);
        }

        // Emit control event
        if let Some(ref p) = path {
            self.emit_event(
                crate::model::control_event::events::FILE_SAVED.name,
                serde_json::json!({
                    "path": p.display().to_string()
                }),
            );
        }

        // Fire AfterFileSave hook for plugins
        if let Some(ref p) = path {
            self.plugin_manager.read().unwrap().run_hook(
                "after_file_save",
                crate::services::plugins::hooks::HookArgs::AfterFileSave {
                    buffer_id,
                    path: p.clone(),
                },
            );
        }

        // Run on-save actions (formatters, linters, etc.)
        // Note: run_on_save_actions also assumes active_buffer internally.
        // We might need to refactor it too if we want auto-save to trigger formatters.
        // For now, let's just do it for active buffer or skip for silent auto-saves.

        if !silent {
            match self.run_on_save_actions() {
                Ok(true) => {
                    // Actions ran successfully - if status_message was set by run_on_save_actions
                    // (e.g., for missing optional formatters), keep it. Otherwise update status.
                    if self.active_window_mut().status_message.as_deref()
                        == Some(&t!("status.file_saved"))
                    {
                        self.active_window_mut().status_message =
                            Some(t!("status.file_saved_with_actions").to_string());
                    }
                    // else: keep the message set by run_on_save_actions (e.g., missing formatter)
                }
                Ok(false) => {
                    // No actions configured, keep original status
                }
                Err(e) => {
                    // Action failed, show error but don't fail the save
                    self.active_window_mut().status_message = Some(e);
                }
            }
        }

        Ok(())
    }

    /// Auto-save all modified buffers to their original files on disk
    /// Returns the number of buffers saved
    pub fn auto_save_persistent_buffers(&mut self) -> anyhow::Result<usize> {
        if !self.config.editor.auto_save_enabled {
            return Ok(0);
        }

        // Check if enough time has passed since last auto-save
        let interval =
            std::time::Duration::from_secs(self.config.editor.auto_save_interval_secs as u64);
        if self
            .time_source
            .elapsed_since(self.active_window().last_persistent_auto_save)
            < interval
        {
            return Ok(0);
        }

        self.active_window_mut().last_persistent_auto_save = self.time_source.now();

        // Collect info for modified buffers that have a file path
        let mut to_save = Vec::new();
        for (id, state) in self
            .windows
            .get(&self.active_window)
            .map(|w| &w.buffers)
            .expect("active window present")
        {
            if state.buffer.is_modified() {
                if let Some(path) = state.buffer.file_path() {
                    to_save.push((*id, path.to_path_buf()));
                }
            }
        }

        let mut count = 0;
        for (id, path) in to_save {
            if let Some(state) = self
                .windows
                .get_mut(&self.active_window)
                .map(|w| &mut w.buffers)
                .expect("active window present")
                .get_mut(&id)
            {
                match state.buffer.save() {
                    Ok(()) => {
                        self.finalize_save_buffer(id, Some(path), true)?;
                        count += 1;
                    }
                    Err(e) => {
                        // Skip if sudo is required (auto-save can't handle prompts)
                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
                            tracing::debug!(
                                "Auto-save skipped for {:?} (sudo required)",
                                path.display()
                            );
                        } else {
                            tracing::warn!("Auto-save failed for {:?}: {}", path.display(), e);
                        }
                    }
                }
            }
        }

        Ok(count)
    }

    /// Collect ids of modified unnamed (no on-disk path) buffers in tab order.
    ///
    /// Used by the "save and quit" flow to walk a Save-As prompt over each
    /// unnamed buffer before the editor actually exits.
    pub(crate) fn collect_unnamed_modified_buffers(&self) -> Vec<BufferId> {
        let mut out = Vec::new();
        for (id, state) in self
            .windows
            .get(&self.active_window)
            .map(|w| &w.buffers)
            .expect("active window present")
        {
            if !state.buffer.is_modified() {
                continue;
            }
            let is_unnamed = state
                .buffer
                .file_path()
                .map(|p| p.as_os_str().is_empty())
                .unwrap_or(true);
            if is_unnamed {
                out.push(*id);
            }
        }
        out
    }

    /// Pop the next id from `pending_quit_unnamed_save` and start a Save-As
    /// prompt for it. Returns true when a prompt was opened (and the editor
    /// must keep running until the user finishes the chain).
    pub(crate) fn start_next_quit_save_as(&mut self) -> bool {
        while let Some(buffer_id) = self
            .active_window_mut()
            .pending_quit_unnamed_save
            .first()
            .copied()
        {
            // Skip ids that vanished or were already saved out from under us.
            let still_dirty_unnamed = self
                .buffers()
                .get(&buffer_id)
                .map(|s| {
                    s.buffer.is_modified()
                        && s.buffer
                            .file_path()
                            .map(|p| p.as_os_str().is_empty())
                            .unwrap_or(true)
                })
                .unwrap_or(false);
            if !still_dirty_unnamed {
                self.active_window_mut().pending_quit_unnamed_save.remove(0);
                continue;
            }

            self.set_active_buffer(buffer_id);
            self.start_prompt(
                t!("file.save_as_prompt").to_string(),
                PromptType::SaveFileAs,
            );
            return true;
        }
        false
    }

    /// Save all modified file-backed buffers to disk (called on exit when auto_save is enabled).
    /// Unlike `auto_save_persistent_buffers`, this skips the interval check and only saves
    /// named file-backed buffers (not unnamed buffers).
    pub fn save_all_on_exit(&mut self) -> anyhow::Result<usize> {
        let mut to_save = Vec::new();
        for (id, state) in self
            .windows
            .get(&self.active_window)
            .map(|w| &w.buffers)
            .expect("active window present")
        {
            if state.buffer.is_modified() {
                if let Some(path) = state.buffer.file_path() {
                    if !path.as_os_str().is_empty() {
                        to_save.push((*id, path.to_path_buf()));
                    }
                }
            }
        }

        let mut count = 0;
        for (id, path) in to_save {
            if let Some(state) = self
                .windows
                .get_mut(&self.active_window)
                .map(|w| &mut w.buffers)
                .expect("active window present")
                .get_mut(&id)
            {
                match state.buffer.save() {
                    Ok(()) => {
                        self.finalize_save_buffer(id, Some(path), true)?;
                        count += 1;
                    }
                    Err(e) => {
                        if e.downcast_ref::<SudoSaveRequired>().is_some() {
                            tracing::debug!(
                                "Auto-save on exit skipped for {} (sudo required)",
                                path.display()
                            );
                        } else {
                            tracing::warn!(
                                "Auto-save on exit failed for {}: {}",
                                path.display(),
                                e
                            );
                        }
                    }
                }
            }
        }

        Ok(count)
    }

    /// Save every modified, file-backed buffer in the active window to disk.
    ///
    /// This is the interactive "Save All" command. Unlike `save_all_on_exit`,
    /// it is not gated on a config flag and is meant to be invoked explicitly
    /// by the user (File menu / command palette). Unnamed (scratch) buffers
    /// have no path to save to and are left untouched — they still need an
    /// explicit Save As. Each saved buffer goes through `finalize_save_buffer`
    /// so LSP notifications, recovery cleanup, and the modified marker are all
    /// kept in sync, just like a single-buffer save.
    ///
    /// Returns `(saved, failed)`: the number of buffers written successfully
    /// and the number whose write failed (e.g. permissions, lost remote).
    pub fn save_all(&mut self) -> anyhow::Result<(usize, usize)> {
        // Collect ids + paths up front so we don't hold an immutable borrow of
        // `windows` while mutably saving each buffer below.
        let mut to_save = Vec::new();
        for (id, state) in self
            .windows
            .get(&self.active_window)
            .map(|w| &w.buffers)
            .expect("active window present")
        {
            if !state.buffer.is_modified() {
                continue;
            }
            if let Some(path) = state.buffer.file_path() {
                if !path.as_os_str().is_empty() {
                    to_save.push((*id, path.to_path_buf()));
                }
            }
        }

        let mut saved = 0;
        let mut failed = 0;
        for (id, path) in to_save {
            let result = self
                .windows
                .get_mut(&self.active_window)
                .map(|w| &mut w.buffers)
                .expect("active window present")
                .get_mut(&id)
                .map(|state| state.buffer.save());
            match result {
                Some(Ok(())) => {
                    self.finalize_save_buffer(id, Some(path), true)?;
                    saved += 1;
                }
                Some(Err(e)) => {
                    failed += 1;
                    tracing::warn!("Save All failed for {}: {}", path.display(), e);
                }
                None => {}
            }
        }

        Ok((saved, failed))
    }

    /// Revert the active buffer to the last saved version on disk
    /// Returns Ok(true) if reverted, Ok(false) if no file path, Err on failure
    pub fn revert_file(&mut self) -> anyhow::Result<bool> {
        let path = match self.active_state().buffer.file_path() {
            Some(p) => p.to_path_buf(),
            None => {
                self.active_window_mut().status_message =
                    Some(t!("status.no_file_to_revert").to_string());
                return Ok(false);
            }
        };

        if !path.exists() {
            self.active_window_mut().status_message =
                Some(t!("status.file_not_exists", path = path.display().to_string()).to_string());
            return Ok(false);
        }

        // Save scroll position (from SplitViewState) and cursor positions before reloading
        let active_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        let (old_top_byte, old_left_column) = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(_, vs)| vs)
            .expect("active window must have a populated split layout")
            .get(&active_split)
            .map(|vs| (vs.viewport.top_byte(), vs.viewport.left_column))
            .unwrap_or((0, 0));
        let old_cursors = self.active_cursors().clone();

        // Preserve user settings before reloading
        let old_buffer_settings = self.active_state().buffer_settings.clone();
        let old_editing_disabled = self.active_state().editing_disabled;

        // Load the file content fresh from disk
        let mut new_state = EditorState::from_file_with_languages(
            &path,
            self.terminal_width,
            self.terminal_height,
            self.config.editor.large_file_threshold_bytes as usize,
            &self.grammar_registry,
            &self.config.languages,
            std::sync::Arc::clone(&self.authority().filesystem),
        )?;

        // Restore cursor positions (clamped to valid range for new file size)
        let new_file_size = new_state.buffer.len();
        let mut restored_cursors = old_cursors;
        restored_cursors.map(|cursor| {
            cursor.position = cursor.position.min(new_file_size);
            // Clear selection since the content may have changed
            cursor.clear_selection();
        });
        // Restore user settings (tab size, indentation, etc.)
        new_state.buffer_settings = old_buffer_settings;
        new_state.editing_disabled = old_editing_disabled;
        // Line number visibility is in per-split BufferViewState (survives buffer replacement)

        // Replace the current buffer with the new state
        let buffer_id = self.active_buffer();
        if let Some(state) = self
            .windows
            .get_mut(&self.active_window)
            .map(|w| &mut w.buffers)
            .expect("active window present")
            .get_mut(&buffer_id)
        {
            *state = new_state;
            // Note: line_wrap_enabled is now in SplitViewState.viewport
        }

        // Restore cursor positions in SplitViewState (clamped to valid range for new file size)
        let active_split = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(mgr, _)| mgr)
            .expect("active window must have a populated split layout")
            .active_split();
        if let Some(view_state) = self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .get_mut(&active_split)
        {
            view_state.cursors = restored_cursors;
        }

        // Restore scroll position in SplitViewState (clamped to valid range for new file size)
        if let Some(view_state) = self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .get_mut(&active_split)
        {
            view_state
                .viewport
                .set_top_byte(old_top_byte.min(new_file_size));
            view_state.viewport.left_column = old_left_column;
        }

        // Clear the undo/redo history for this buffer
        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
            *event_log = EventLog::new();
        }

        // Clear seen_byte_ranges so plugins get notified of all visible lines
        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);

        // Update the file modification time
        if let Ok(metadata) = self.authority().filesystem.metadata(&path) {
            if let Some(mtime) = metadata.modified {
                self.file_mod_times_mut().insert(path.clone(), mtime);
            }
        }

        // Notify LSP that the file was changed
        self.notify_lsp_file_changed(&path);

        // Fire AfterFileRevert hook — the reload replaced the buffer content
        // without going through the save path, so plugins tracking
        // disk-derived state (git gutter, etc.) need this to re-scan.
        self.plugin_manager.read().unwrap().run_hook(
            "after_file_revert",
            crate::services::plugins::hooks::HookArgs::AfterFileRevert {
                buffer_id,
                path: path.clone(),
            },
        );

        self.active_window_mut().status_message = Some(t!("status.reverted").to_string());
        Ok(true)
    }

    /// Toggle auto-revert for the active buffer. Auto-revert is a per-buffer
    /// property, so this only affects the buffer in the focused split.
    pub fn toggle_auto_revert(&mut self) {
        let buffer_id = self.active_buffer();

        // Terminal-backed buffers never auto-revert (their backing file is
        // PTY-streamed), so the toggle can't enable it for them.
        if self.active_window().is_terminal_buffer(buffer_id) {
            self.active_window_mut().status_message =
                Some(t!("status.auto_revert_disabled").to_string());
            return;
        }

        let enabled = match self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
            Some(meta) => {
                meta.auto_revert_enabled = !meta.auto_revert_enabled;
                meta.auto_revert_enabled
            }
            None => return,
        };

        self.active_window_mut().status_message = Some(if enabled {
            t!("status.auto_revert_enabled").to_string()
        } else {
            t!("status.auto_revert_disabled").to_string()
        });
    }

    /// Poll for file changes (called from main loop)
    ///
    /// Checks modification times of open files to detect external changes.
    /// Returns true if any file was changed (requires re-render).
    ///
    /// To avoid blocking the event loop, metadata checks run on a background
    /// thread. This method launches a poll if the interval has elapsed and no
    /// poll is already in flight, then checks for results from a prior poll.
    pub fn poll_file_changes(&mut self) -> bool {
        // Check for results from a previous background poll
        let mut any_changed = false;
        if let Some(ref rx) = self.active_window_mut().pending_file_poll_rx {
            match rx.try_recv() {
                Ok(results) => {
                    self.active_window_mut().pending_file_poll_rx = None;
                    any_changed = self.process_file_poll_results(results);
                }
                Err(std::sync::mpsc::TryRecvError::Empty) => {
                    // Still in progress — don't block, don't start another
                    return false;
                }
                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                    // Background task panicked or was dropped
                    self.active_window_mut().pending_file_poll_rx = None;
                }
            }
        }

        // Check poll interval
        let poll_interval =
            std::time::Duration::from_millis(self.config.editor.auto_revert_poll_interval_ms);
        let last_poll = self.active_window().last_auto_revert_poll;
        let elapsed = self.time_source.elapsed_since(last_poll);
        tracing::trace!(
            "poll_file_changes: elapsed={:?}, poll_interval={:?}",
            elapsed,
            poll_interval
        );
        if elapsed < poll_interval {
            return any_changed;
        }
        self.active_window_mut().last_auto_revert_poll = self.time_source.now();

        // Collect paths of open files that need checking. Auto-revert is a
        // per-buffer property, so only poll files whose buffer opts in — this
        // keeps terminal backing files (append-streamed by the PTY reader)
        // out of the poll set entirely, so their constant growth never fires
        // a reload or redraw (fresh#2608).
        let files_to_check: Vec<PathBuf> = {
            let window = self.active_window();
            window
                .buffers
                .iter()
                .filter(|(id, _)| window.buffer_auto_revert_enabled(**id))
                .filter_map(|(_, state)| state.buffer.file_path().map(PathBuf::from))
                .collect()
        };

        if files_to_check.is_empty() {
            return any_changed;
        }

        // Spawn background metadata checks
        let (tx, rx) = std::sync::mpsc::channel();
        let fs = self.authority().filesystem.clone();
        std::thread::Builder::new()
            .name("poll-file-changes".to_string())
            .spawn(move || {
                let results: Vec<(PathBuf, Option<std::time::SystemTime>)> = files_to_check
                    .into_iter()
                    .map(|path| {
                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
                        (path, mtime)
                    })
                    .collect();
                // Receiver may have been dropped if auto-revert was disabled
                // or the editor is shutting down — that's fine.
                if tx.send(results).is_err() {}
            })
            .ok();
        self.active_window_mut().pending_file_poll_rx = Some(rx);

        any_changed
    }

    /// Process results from a background file poll
    fn process_file_poll_results(
        &mut self,
        results: Vec<(PathBuf, Option<std::time::SystemTime>)>,
    ) -> bool {
        let mut any_changed = false;
        for (path, mtime_opt) in results {
            let Some(current_mtime) = mtime_opt else {
                continue;
            };

            if let Some(&stored_mtime) = self.file_mod_times().get(&path) {
                if current_mtime != stored_mtime {
                    let path_str = path.display().to_string();
                    if self.handle_async_file_changed(path_str) {
                        any_changed = true;
                    }
                }
            } else {
                // First time seeing this file, record its mtime
                self.file_mod_times_mut().insert(path, current_mtime);
            }
        }
        any_changed
    }

    /// Poll for file tree changes (called from main loop)
    ///
    /// Checks modification times of expanded directories to detect new/deleted files.
    /// Returns true if any directory was refreshed (requires re-render).
    ///
    /// Like poll_file_changes, metadata checks run on a background thread to
    /// avoid blocking the event loop.
    pub fn poll_file_tree_changes(&mut self) -> bool {
        use crate::view::file_tree::NodeId;

        // Check for results from a previous background poll
        let mut any_refreshed = false;
        let mut dir_poll_pending = false;
        if let Some(ref rx) = self.active_window_mut().pending_dir_poll_rx {
            match rx.try_recv() {
                Ok((dir_results, git_index_mtimes)) => {
                    self.active_window_mut().pending_dir_poll_rx = None;
                    any_refreshed = self.process_dir_poll_results(dir_results, git_index_mtimes);
                }
                Err(std::sync::mpsc::TryRecvError::Empty) => {
                    dir_poll_pending = true;
                }
                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                    self.active_window_mut().pending_dir_poll_rx = None;
                }
            }
        }

        // Fold in any completed background git-index resolution (see
        // `git_index.rs`), seeding the explicit `.git/index` watch set.
        self.collect_git_index_resolution();

        // Check poll interval
        let poll_interval =
            std::time::Duration::from_millis(self.config.editor.file_tree_poll_interval_ms);
        let last_tree_poll = self.active_window().last_file_tree_poll;
        if self.time_source.elapsed_since(last_tree_poll) < poll_interval {
            return any_refreshed;
        }
        self.active_window_mut().last_file_tree_poll = self.time_source.now();

        // Re-stat every loaded .gitignore and reload/drop as needed, so
        // external edits (git pull, sed, another editor) and deletions take
        // effect without a restart. In-editor saves already reload eagerly
        // via finalize_save_buffer. Sync I/O here — a handful of small files
        // and all access goes through the filesystem authority.
        if self.sync_gitignores_from_disk() {
            any_refreshed = true;
        }

        // If a previous dir-poll is still in flight, don't stack another.
        if dir_poll_pending {
            return any_refreshed;
        }

        // Kick off the one-shot git-index discovery on a background thread
        // (see `git_index.rs`) so the first poll never blocks on `git`.
        self.spawn_git_index_resolution();

        // Get file explorer reference
        let Some(explorer) = self.file_explorer() else {
            return any_refreshed;
        };

        // Collect expanded directories (node_id, path)
        let expanded_dirs: Vec<(NodeId, PathBuf)> = explorer
            .tree()
            .all_nodes()
            .filter(|node| node.is_dir() && node.is_expanded())
            .map(|node| (node.id, node.entry.path.clone()))
            .collect();

        // The watch set is stored explicitly (populated once above), not
        // re-derived from `dir_mod_times` by pattern-matching `.git/index`.
        // In a monorepo there may be several; in a single-repo just one.
        let git_index_paths: Vec<PathBuf> = self.active_window().watched_git_indexes.clone();

        if expanded_dirs.is_empty() && git_index_paths.is_empty() {
            return any_refreshed;
        }

        // Spawn background metadata checks (directories + git indexes)
        let (tx, rx) = std::sync::mpsc::channel();
        let fs = self.authority().filesystem.clone();
        std::thread::Builder::new()
            .name("poll-dir-changes".to_string())
            .spawn(move || {
                let results: Vec<(NodeId, PathBuf, Option<std::time::SystemTime>)> = expanded_dirs
                    .into_iter()
                    .map(|(node_id, path)| {
                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified);
                        (node_id, path, mtime)
                    })
                    .collect();

                let git_index_mtimes: Vec<(PathBuf, std::time::SystemTime)> = git_index_paths
                    .into_iter()
                    .filter_map(|path| {
                        let mtime = fs.metadata(&path).ok().and_then(|m| m.modified)?;
                        Some((path, mtime))
                    })
                    .collect();

                // Receiver may have been dropped during shutdown — that's fine.
                if tx.send((results, git_index_mtimes)).is_err() {}
            })
            .ok();
        self.active_window_mut().pending_dir_poll_rx = Some(rx);

        any_refreshed
    }

    /// Process results from a background directory poll
    fn process_dir_poll_results(
        &mut self,
        results: Vec<(
            crate::view::file_tree::NodeId,
            PathBuf,
            Option<std::time::SystemTime>,
        )>,
        git_index_mtimes: Vec<(PathBuf, std::time::SystemTime)>,
    ) -> bool {
        let mut dirs_to_refresh: Vec<(crate::view::file_tree::NodeId, PathBuf)> = Vec::new();

        for (node_id, path, mtime_opt) in results {
            let Some(current_mtime) = mtime_opt else {
                continue;
            };

            if let Some(&stored_mtime) = self.active_window_mut().dir_mod_times.get(&path) {
                if current_mtime != stored_mtime {
                    self.active_window_mut()
                        .dir_mod_times
                        .insert(path.clone(), current_mtime);
                    dirs_to_refresh.push((node_id, path.clone()));
                    tracing::debug!("Directory changed: {:?}", path);
                }
            } else {
                self.active_window_mut()
                    .dir_mod_times
                    .insert(path, current_mtime);
            }
        }

        // Check if any .git/index mtime changed (detected in background thread).
        // In a monorepo multiple indexes are watched; fire the hook if any changed.
        let mut git_index_changed = false;
        for (path, current_mtime) in git_index_mtimes {
            if let Some(&stored_mtime) = self.active_window_mut().dir_mod_times.get(&path) {
                if current_mtime != stored_mtime {
                    self.active_window_mut()
                        .dir_mod_times
                        .insert(path, current_mtime);
                    git_index_changed = true;
                }
            }
        }

        if dirs_to_refresh.is_empty() && !git_index_changed {
            return false;
        }

        // Refresh each changed directory and (re)load its .gitignore. A new
        // .gitignore file inside an expanded dir bumps the dir's mtime so we
        // land here; reload_expanded_node re-lists entries but doesn't parse
        // rules — load_gitignore_via_fs handles the rules side.
        let refreshed_dirs: Vec<PathBuf> = dirs_to_refresh.iter().map(|(_, p)| p.clone()).collect();
        self.refresh_file_tree_dirs(&refreshed_dirs);
        let fs = self.authority().filesystem.clone();
        if let Some(explorer) = self.file_explorer_mut().as_mut() {
            for dir in refreshed_dirs {
                load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
            }
        }

        // External git operations (commit, pull, checkout, …) update
        // `.git/index` without touching expanded directories. Fire the
        // explorer-change hook so git_explorer rescans and clears stale
        // status badges (#1431).
        if git_index_changed {
            let cwd = self.working_dir().to_path_buf();
            self.notify_file_explorer_change(&cwd);
        }

        true
    }

    /// Re-read the given directories in the file explorer, preserving
    /// descendant expansion state and the cursor's path.
    ///
    /// Why reload_expanded_node and not refresh_node: refresh_node
    /// collapses the directory and re-expands it, which recycles every
    /// descendant NodeId and drops their expansion state. That's fatal
    /// for this code path, which runs unprompted from a background timer:
    /// after a cut+paste into the workspace root, the source parent's
    /// mtime changes, we land here seconds later, and refresh_node would
    /// collapse a user-expanded subtree and invalidate the cursor
    /// NodeId (after which Up/Down become no-ops because
    /// select_next/select_prev can't find the current id in the visible
    /// list).
    ///
    /// We also snapshot the cursor path before the reload and
    /// re-resolve it afterwards. reload_expanded_node still recycles
    /// ids under the refreshed root, so the old id is gone; the path
    /// survives unless the underlying file was deleted, in which case
    /// we fall back to the root so the cursor stays live and visible
    /// (a stale id is effectively no cursor at all).
    ///
    /// Exposed as `pub` so tests can drive the refresh path directly
    /// without relying on filesystem mtime detection, which is too
    /// environment-sensitive (especially across CI filesystems).
    pub fn refresh_file_tree_dirs(&mut self, paths: &[PathBuf]) {
        let active_id = self.active_window;
        let (Some(runtime), Some(explorer)) = (
            self.tokio_runtime.as_ref(),
            self.windows
                .get_mut(&active_id)
                .and_then(|w| w.file_explorer.as_mut()),
        ) else {
            return;
        };
        let cursor_path: Option<PathBuf> = explorer.get_selected_entry().map(|e| e.path.clone());
        // Re-resolve node ids by path at each step: an earlier
        // reload_expanded_node in this loop may have recycled ids under
        // its subtree, so any ids captured before this call can be
        // stale.
        for path in paths {
            let Some(id_now) = explorer.tree().get_node_by_path(path).map(|n| n.id) else {
                continue;
            };
            let tree = explorer.tree_mut();
            if let Err(e) = runtime.block_on(tree.reload_expanded_node(id_now)) {
                tracing::warn!("Failed to refresh directory {:?}: {}", path, e);
            }
        }
        if let Some(path) = cursor_path {
            if explorer.tree().get_node_by_path(&path).is_some() {
                explorer.navigate_to_path(&path);
            } else {
                let root_id = explorer.tree().root_id();
                explorer.set_selected(Some(root_id));
            }
        }
    }

    /// Re-stat every loaded .gitignore via the filesystem authority and
    /// reload or drop as needed. Returns true if anything changed.
    fn sync_gitignores_from_disk(&mut self) -> bool {
        let fs = self.authority().filesystem.clone();
        let Some(explorer) = self.file_explorer_mut() else {
            return false;
        };
        let dirs = explorer.ignore_patterns().loaded_gitignore_dirs();
        let mut changed = false;
        for dir in dirs {
            let gitignore_path = dir.join(".gitignore");
            match fs.metadata(&gitignore_path) {
                Err(_) => {
                    explorer.ignore_patterns_mut().remove_gitignore(&dir);
                    changed = true;
                }
                Ok(meta) => {
                    let stored = explorer.ignore_patterns().stored_gitignore_mtime(&dir);
                    if stored != meta.modified {
                        load_gitignore_via_fs(fs.as_ref(), explorer, &dir);
                        changed = true;
                    }
                }
            }
        }
        changed
    }

    /// Notify LSP server about a newly opened file
    /// Handles language detection, spawning LSP clients, and sending didOpen notifications
    ///
    /// Thin delegator: the LSP-notify core lives on `impl Window` (it
    /// reads only the window's own `lsp` / `buffers` /
    /// `diagnostic_result_ids` + `self.resources`). Editor callers
    /// forward to the active window.
    pub(crate) fn notify_lsp_file_opened(
        &mut self,
        path: &Path,
        buffer_id: BufferId,
        metadata: &mut BufferMetadata,
    ) {
        self.active_window_mut()
            .notify_lsp_file_opened(path, buffer_id, metadata);
    }

    /// Record a file's modification time (called when opening files)
    /// This is used by the polling-based auto-revert to detect external changes.
    ///
    /// Thin delegator: `watch_file` is window-local (records into the
    /// active window's `file_mod_times`); forwards to the active window.
    pub(crate) fn watch_file(&mut self, path: &Path) {
        self.active_window_mut().watch_file(path);
    }

    /// Notify LSP that a file's contents changed (e.g., after revert)
    pub(crate) fn notify_lsp_file_changed(&mut self, path: &Path) {
        use crate::services::lsp::manager::LspSpawnResult;

        let Some(lsp_uri) = super::types::file_path_to_lsp_uri_with_translation(
            path,
            self.authority().path_translation.as_ref(),
        ) else {
            return;
        };

        // Find the buffer ID, content, and language for this path
        let Some((buffer_id, content, language)) = self
            .buffers()
            .iter()
            .find(|(_, s)| s.buffer.file_path() == Some(path))
            .and_then(|(id, state)| {
                state
                    .buffer
                    .to_string()
                    .map(|t| (*id, t, state.language.clone()))
            })
        else {
            return;
        };

        // Check if we can spawn LSP (respects auto_start setting)
        let spawn_result = {
            let __active_id = self.active_window;
            let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) else {
                return;
            };
            lsp.try_spawn(&language, Some(path))
        };

        // Only proceed if spawned successfully (or already running)
        if spawn_result != LspSpawnResult::Spawned {
            return;
        }

        // Send didOpen to any handles that haven't received it yet
        {
            let opened_with = self
                .active_window()
                .buffer_metadata
                .get(&buffer_id)
                .map(|m| m.lsp_opened_with.clone())
                .unwrap_or_default();

            let __active_id = self.active_window;

            if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
                for sh in lsp.get_handles_mut(&language) {
                    if opened_with.contains(&sh.handle.id()) {
                        continue;
                    }
                    if let Err(e) =
                        sh.handle
                            .did_open(lsp_uri.clone(), content.clone(), language.clone())
                    {
                        tracing::warn!(
                            "Failed to send didOpen to LSP '{}' before didChange: {}",
                            sh.name,
                            e
                        );
                    } else {
                        tracing::debug!(
                            "Sent didOpen for {} to LSP '{}' before file change notification",
                            lsp_uri.as_str(),
                            sh.name
                        );
                    }
                }
            }

            // Mark all handles as opened
            let active_id = self.active_window;
            if let Some(__win) = self.windows.get_mut(&active_id) {
                if let Some(metadata) = __win.buffer_metadata.get_mut(&buffer_id) {
                    for sh in __win.lsp.get_handles(&language) {
                        metadata.lsp_opened_with.insert(sh.handle.id());
                    }
                }
            }
        }

        // Use full document sync - broadcast to all handles
        let __active_id = self.active_window;
        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
            let content_change = TextDocumentContentChangeEvent {
                range: None, // None means full document replacement
                range_length: None,
                text: content,
            };
            for sh in lsp.get_handles_mut(&language) {
                if let Err(e) = sh
                    .handle
                    .did_change(lsp_uri.clone(), vec![content_change.clone()])
                {
                    tracing::warn!("Failed to notify LSP '{}' of file change: {}", sh.name, e);
                }
            }
        }
    }

    /// Revert a specific buffer by ID without affecting the active viewport.
    ///
    /// This is used for auto-reverting background buffers that aren't currently
    /// visible in the active split. It reloads the buffer content and updates
    /// cursors (clamped to valid positions), but does NOT touch any viewport state.
    pub(crate) fn revert_buffer_by_id(
        &mut self,
        buffer_id: BufferId,
        path: &Path,
    ) -> anyhow::Result<()> {
        // Preserve user settings before reloading
        // TODO: Consider moving line numbers to SplitViewState (per-view setting)
        // Get cursors from split view states for this buffer (find any split showing it)
        let old_cursors = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(_, vs)| vs)
            .expect("active window must have a populated split layout")
            .values()
            .find_map(|vs| {
                if vs.keyed_states.contains_key(&buffer_id) {
                    vs.keyed_states.get(&buffer_id).map(|bs| bs.cursors.clone())
                } else {
                    None
                }
            })
            .unwrap_or_default();
        let (old_buffer_settings, old_editing_disabled) = self
            .buffers()
            .get(&buffer_id)
            .map(|s| (s.buffer_settings.clone(), s.editing_disabled))
            .unwrap_or_default();

        // Reload through the *buffer's own* filesystem rather than the
        // session authority's. For normal files these are the same handle
        // (buffers are created with the authority fs), so behaviour is
        // unchanged — but terminal scrollback buffers are backed by a local
        // file (see `terminal_backing_fs`), and reverting those through a
        // remote authority would read a path that only exists locally.
        let fs = self
            .buffers()
            .get(&buffer_id)
            .map(|s| std::sync::Arc::clone(s.buffer.filesystem()))
            .unwrap_or_else(|| std::sync::Arc::clone(&self.authority().filesystem));
        // Load the file content fresh from disk
        let mut new_state = EditorState::from_file_with_languages(
            path,
            self.terminal_width,
            self.terminal_height,
            self.config.editor.large_file_threshold_bytes as usize,
            &self.grammar_registry,
            &self.config.languages,
            fs,
        )?;

        // Get the new file size for clamping
        let new_file_size = new_state.buffer.len();

        // Restore cursor positions (clamped to valid range for new file size)
        let mut restored_cursors = old_cursors;
        restored_cursors.map(|cursor| {
            cursor.position = cursor.position.min(new_file_size);
            cursor.clear_selection();
        });
        // Restore user settings (tab size, indentation, etc.)
        new_state.buffer_settings = old_buffer_settings;
        new_state.editing_disabled = old_editing_disabled;
        // Line number visibility is in per-split BufferViewState (survives buffer replacement)

        // Replace the buffer content
        if let Some(state) = self
            .windows
            .get_mut(&self.active_window)
            .map(|w| &mut w.buffers)
            .expect("active window present")
            .get_mut(&buffer_id)
        {
            *state = new_state;
        }

        // Restore cursors in any split view states that have this buffer
        for vs in self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_view_states_mut())
            .expect("active window must have a populated split layout")
            .values_mut()
        {
            if let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) {
                buf_state.cursors = restored_cursors.clone();
            }
        }

        // Clear the undo/redo history for this buffer
        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
            *event_log = EventLog::new();
        }

        // Clear seen_byte_ranges so plugins get notified of all visible lines
        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);

        // Update the file modification time
        if let Ok(metadata) = self.authority().filesystem.metadata(path) {
            if let Some(mtime) = metadata.modified {
                self.file_mod_times_mut().insert(path.to_path_buf(), mtime);
            }
        }

        // Notify LSP that the file was changed
        self.notify_lsp_file_changed(path);

        // Fire AfterFileRevert hook — same contract as the active-buffer
        // revert path: a reload is not a save, and plugins tracking
        // disk-derived state need to re-scan the reloaded buffer.
        self.plugin_manager.read().unwrap().run_hook(
            "after_file_revert",
            crate::services::plugins::hooks::HookArgs::AfterFileRevert {
                buffer_id,
                path: path.to_path_buf(),
            },
        );

        Ok(())
    }

    /// Handle a file change notification (from file watcher)
    pub fn handle_file_changed(&mut self, changed_path: &str) {
        let path = PathBuf::from(changed_path);

        // Find buffers that have this file open
        let buffer_ids: Vec<BufferId> = self
            .buffers()
            .iter()
            .filter(|(_, state)| state.buffer.file_path() == Some(&path))
            .map(|(id, _)| *id)
            .collect();

        if buffer_ids.is_empty() {
            return;
        }

        for buffer_id in buffer_ids {
            // Auto-revert is a per-buffer property, forced off for terminal
            // buffers and opt-out streaming buffers (`openFileStreaming`).
            // Reverting a buffer whose owner is actively writing the file
            // (PTY reader / `extend_streaming`) would wipe the piece tree
            // mid-stream and snap the cursor back to byte 0.
            if !self.active_window().buffer_auto_revert_enabled(buffer_id) {
                continue;
            }

            let state = match self
                .windows
                .get(&self.active_window)
                .map(|w| &w.buffers)
                .expect("active window present")
                .get(&buffer_id)
            {
                Some(s) => s,
                None => continue,
            };

            // Check if the file actually changed (compare mod times)
            // We use optimistic concurrency: check mtime, and if we decide to revert,
            // re-check to handle the race where a save completed between our checks.
            let current_mtime = match self
                .authority()
                .filesystem
                .metadata(&path)
                .ok()
                .and_then(|m| m.modified)
            {
                Some(mtime) => mtime,
                None => continue, // Can't read file, skip
            };

            let dominated_by_stored = self
                .file_mod_times()
                .get(&path)
                .map(|stored| current_mtime <= *stored)
                .unwrap_or(false);

            if dominated_by_stored {
                continue;
            }

            // If buffer has local modifications, show a warning (don't auto-revert)
            if state.buffer.is_modified() {
                self.active_window_mut().status_message = Some(format!(
                    "File {} changed on disk (buffer has unsaved changes)",
                    path.display()
                ));
                continue;
            }

            // The buffer is unmodified and opted into auto-revert (gated at
            // the top of the loop) — reload it to match disk.
            {
                // Optimistic concurrency: re-check mtime before reverting.
                // A save may have completed between our first check and now,
                // updating file_mod_times. If so, skip the revert.
                let still_needs_revert = self
                    .file_mod_times()
                    .get(&path)
                    .map(|stored| current_mtime > *stored)
                    .unwrap_or(true);

                if !still_needs_revert {
                    continue;
                }

                // Check if this buffer is currently displayed in the active split
                let is_active_buffer = buffer_id == self.active_buffer();

                if is_active_buffer {
                    // Use revert_file() which preserves viewport for active buffer
                    if let Err(e) = self.revert_file() {
                        tracing::error!("Failed to auto-revert file {:?}: {}", path, e);
                    } else {
                        tracing::info!("Auto-reverted file: {:?}", path);
                    }
                } else {
                    // Use revert_buffer_by_id() which doesn't touch any viewport
                    // This prevents corrupting the active split's viewport state
                    if let Err(e) = self.revert_buffer_by_id(buffer_id, &path) {
                        tracing::error!("Failed to auto-revert background file {:?}: {}", path, e);
                    } else {
                        tracing::info!("Auto-reverted file: {:?}", path);
                    }
                }

                // Update the modification time tracking for this file
                self.watch_file(&path);
            }
        }
    }

    /// Check if saving would overwrite changes made by another process
    /// Returns Some(current_mtime) if there's a conflict, None otherwise
    pub fn check_save_conflict(&self) -> Option<std::time::SystemTime> {
        let path = self.active_state().buffer.file_path()?;

        // Get current file modification time
        let current_mtime = self
            .authority()
            .filesystem
            .metadata(path)
            .ok()
            .and_then(|m| m.modified)?;

        // Compare with our recorded modification time
        match self.file_mod_times().get(path) {
            Some(recorded_mtime) if current_mtime > *recorded_mtime => {
                // File was modified externally since we last loaded/saved it
                Some(current_mtime)
            }
            _ => None,
        }
    }
}

/// Stat and read `dir/.gitignore` via the filesystem authority and install
/// the result on `explorer`. No-op (with a warn-level log on unexpected
/// errors) when the file doesn't exist. Shared by the init, expand, save,
/// and poll paths so everything routes through the same authority.
pub(crate) fn load_gitignore_via_fs(fs: &dyn FileSystem, explorer: &mut FileTreeView, dir: &Path) {
    let gitignore_path = dir.join(".gitignore");
    let meta = match fs.metadata(&gitignore_path) {
        Ok(m) => m,
        Err(_) => return,
    };
    let bytes = match fs.read_file(&gitignore_path) {
        Ok(b) => b,
        Err(e) => {
            tracing::warn!("Failed to read {:?}: {}", gitignore_path, e);
            return;
        }
    };
    explorer.load_gitignore_from_bytes(dir, &bytes, meta.modified);
}

impl crate::app::window::Window {
    /// Notify this window's LSP servers about a newly opened file.
    ///
    /// Window-scoped: reads only the window's own `lsp`, `buffers`,
    /// `diagnostic_result_ids`, and `self.resources.{authority,config}`.
    /// Handles language detection, spawning LSP clients, and sending
    /// didOpen notifications + follow-up pull-diagnostics / inlay-hints.
    pub(crate) fn notify_lsp_file_opened(
        &mut self,
        path: &Path,
        buffer_id: BufferId,
        metadata: &mut BufferMetadata,
    ) {
        // Get language from buffer state
        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
            tracing::debug!("No buffer state for file: {}", path.display());
            return;
        };

        let Some(uri) = metadata.file_uri().cloned() else {
            tracing::warn!(
                "No URI in metadata for file: {} (failed to compute absolute path)",
                path.display()
            );
            return;
        };

        // Check file size
        let file_size = self
            .authority()
            .filesystem
            .metadata(path)
            .ok()
            .map(|m| m.size)
            .unwrap_or(0);
        if file_size > self.resources.config.editor.large_file_threshold_bytes {
            let reason = format!("File too large ({} bytes)", file_size);
            tracing::debug!(
                "Skipping LSP for large file: {} ({})",
                path.display(),
                reason
            );
            metadata.disable_lsp(reason);
            return;
        }

        // Get text before borrowing lsp
        let text = match self
            .buffers
            .get(&buffer_id)
            .and_then(|state| state.buffer.to_string())
        {
            Some(t) => t,
            None => {
                tracing::debug!("Buffer not fully loaded for LSP notification");
                return;
            }
        };

        let enable_inlay_hints = self.resources.config.editor.enable_inlay_hints;
        // Per-server last result_ids for this URI, snapshotted before the
        // `&mut self.lsp` borrow below. Diagnostics is a merged feature, so
        // each diagnostic-capable server is pulled with its own cursor.
        let previous_result_ids = self
            .diagnostic_result_ids
            .get(uri.as_str())
            .cloned()
            .unwrap_or_default();

        // Get buffer line count and version for inlay hints
        let (last_line, last_char, buffer_version) = self
            .buffers
            .get(&buffer_id)
            .map(|state| {
                let line_count = state.buffer.line_count().unwrap_or(1000);
                (
                    line_count.saturating_sub(1) as u32,
                    10000u32,
                    state.buffer.version(),
                )
            })
            .unwrap_or((999, 10000, 0));

        // Now borrow lsp and do all LSP operations
        let lsp = &mut self.lsp;
        let __next_id = &mut self.next_lsp_request_id;

        tracing::debug!("LSP manager available for file: {}", path.display());
        tracing::debug!(
            "Detected language: {} for file: {}",
            language,
            path.display()
        );
        tracing::debug!("Using URI from metadata: {}", uri.as_str());
        tracing::debug!("Attempting to spawn LSP client for language: {}", language);

        match lsp.try_spawn(&language, Some(path)) {
            LspSpawnResult::Spawned => {
                // Send didOpen to ALL server handles for this language,
                // not just the first one.  With multiple servers configured
                // (e.g. error-server + warning-server) each needs to know
                // about the open document.
                for sh in lsp.get_handles_mut(&language) {
                    tracing::info!("Sending didOpen to LSP '{}' for: {}", sh.name, uri.as_str());
                    if let Err(e) =
                        sh.handle
                            .did_open(uri.as_uri().clone(), text.clone(), language.clone())
                    {
                        tracing::warn!("Failed to send didOpen to LSP '{}': {}", sh.name, e);
                    } else {
                        metadata.lsp_opened_with.insert(sh.handle.id());
                    }
                }

                // Route each follow-up request through capability-aware
                // routing so we never send an optional method to a server
                // that didn't advertise it. On a cold spawn the capability
                // check returns `None` (capabilities aren't known until the
                // `initialize` response arrives); the `LspInitialized`
                // handler replays these requests once capabilities land.
                for sh in
                    lsp.handles_for_feature_mut(&language, crate::types::LspFeature::Diagnostics)
                {
                    let request_id = {
                        let id = *__next_id;
                        *__next_id += 1;
                        id
                    };
                    let previous_result_id = previous_result_ids.get(&sh.name).cloned();
                    if let Err(e) = sh.handle.document_diagnostic(
                        request_id,
                        uri.as_uri().clone(),
                        previous_result_id,
                    ) {
                        tracing::debug!("Failed to request pull diagnostics: {}", e);
                    } else {
                        tracing::info!(
                            "Requested pull diagnostics for {} from '{}' (request_id={})",
                            uri.as_str(),
                            sh.name,
                            request_id
                        );
                    }
                }

                if enable_inlay_hints {
                    if let Some(sh) =
                        lsp.handle_for_feature_mut(&language, crate::types::LspFeature::InlayHints)
                    {
                        let request_id = {
                            let id = *__next_id;
                            *__next_id += 1;
                            id
                        };

                        if let Err(e) = sh.handle.inlay_hints(
                            request_id,
                            uri.as_uri().clone(),
                            0,
                            0,
                            last_line,
                            last_char,
                        ) {
                            tracing::debug!("Failed to request inlay hints: {}", e);
                        } else {
                            self.pending_inlay_hints_requests.insert(
                                request_id,
                                super::InlayHintsRequest {
                                    buffer_id,
                                    version: buffer_version,
                                },
                            );
                            tracing::info!(
                                "Requested inlay hints for {} (request_id={})",
                                uri.as_str(),
                                request_id
                            );
                        }
                    }
                }

                // Schedule folding range refresh
                self.schedule_folding_ranges_refresh(buffer_id);
            }
            LspSpawnResult::NotAutoStart => {
                tracing::debug!(
                    "LSP for {} not auto-starting (auto_start=false). Click the LSP indicator to start manually.",
                    language
                );
            }
            LspSpawnResult::NotConfigured => {
                tracing::debug!("No LSP server configured for language: {}", language);
            }
            LspSpawnResult::Disabled => {
                tracing::debug!("LSP disabled in config for language: {}", language);
            }
            LspSpawnResult::Failed => {
                tracing::warn!("Failed to spawn LSP client for language: {}", language);
            }
        }
    }

    /// Record a file's modification time (called when opening files).
    /// Window-local: records into this window's own `file_mod_times`.
    pub(crate) fn watch_file(&mut self, path: &Path) {
        if let Ok(metadata) = self.authority().filesystem.metadata(path) {
            if let Some(mtime) = metadata.modified {
                self.file_mod_times.insert(path.to_path_buf(), mtime);
            }
        }
    }
}
