use crate::model::buffer::{Buffer, LineNumber};
use crate::model::cursor::{Cursor, Cursors};
use crate::model::document_model::{
    DocumentCapabilities, DocumentModel, DocumentPosition, ViewportContent, ViewportLine,
};
use crate::model::event::{
    Event, MarginContentData, MarginPositionData, OverlayFace as EventOverlayFace, PopupData,
    PopupPositionData,
};
use crate::model::filesystem::FileSystem;
use crate::model::marker::{MarkerId, MarkerList};
use crate::primitives::detected_language::DetectedLanguage;
use crate::primitives::grammar::GrammarRegistry;
use crate::primitives::highlight_engine::HighlightEngine;
use crate::primitives::indent::IndentCalculator;
use crate::primitives::reference_highlighter::ReferenceHighlighter;
use crate::primitives::text_property::TextPropertyManager;
use crate::view::bracket_highlight_overlay::BracketHighlightOverlay;
use crate::view::conceal::ConcealManager;
use crate::view::folding::LspFoldRanges;
use crate::view::margin::{MarginAnnotation, MarginContent, MarginManager, MarginPosition};
use crate::view::overlay::{Overlay, OverlayFace, OverlayManager, UnderlineStyle};
use crate::view::popup::{
    Popup, PopupContent, PopupKind, PopupListItem, PopupManager, PopupPosition,
};
use crate::view::reference_highlight_overlay::ReferenceHighlightOverlay;
use crate::view::soft_break::SoftBreakManager;
use crate::view::virtual_text::VirtualTextManager;
use anyhow::Result;
use ratatui::style::{Color, Style};
use std::cell::RefCell;
use std::ops::Range;
use std::sync::Arc;

/// A marker whose position was displaced by a deletion.
/// Stored in LogEntry (for single edits) or Event::BulkEdit (for bulk edits).
/// On undo, the marker is restored to its exact original position.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DisplacedMarker {
    /// Marker from the main marker_list (virtual text, overlays)
    Main { id: u64, position: usize },
    /// Marker from margins.indicator_markers (breakpoints, line indicators)
    Margin { id: u64, position: usize },
}

impl DisplacedMarker {
    /// Encode as (u64, usize) for compact storage. Uses high bit to tag source.
    pub fn encode(&self) -> (u64, usize) {
        match self {
            Self::Main { id, position } => (*id, *position),
            Self::Margin { id, position } => (*id | (1u64 << 63), *position),
        }
    }

    /// Decode from (u64, usize) compact representation.
    pub fn decode(tagged_id: u64, position: usize) -> Self {
        if (tagged_id >> 63) == 1 {
            Self::Margin {
                id: tagged_id & !(1u64 << 63),
                position,
            }
        } else {
            Self::Main {
                id: tagged_id,
                position,
            }
        }
    }
}

/// Display mode for a buffer
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViewMode {
    /// Plain source rendering
    Source,
    /// Document-style page view with centered content, concealed markers,
    /// and plugin-driven word wrapping (previously called "compose mode")
    PageView,
}

/// Per-buffer user settings that should be preserved across file reloads (auto-revert).
///
/// These are user overrides that apply to a specific buffer, separate from:
/// - File-derived state (syntax highlighting, language detection)
/// - View-specific state (scroll position, line wrap - those live in SplitViewState)
///
/// TODO: Consider moving view-related settings (line numbers, debug mode) to SplitViewState
/// to allow per-split preferences. Currently line numbers is in margins (coupled with plugin
/// gutters), and debug_highlight_mode is in EditorState, but both could arguably be per-view
/// rather than per-buffer.
#[derive(Debug, Clone)]
pub struct BufferSettings {
    /// Resolved whitespace indicator visibility for this buffer.
    /// Set based on global + language config; can be toggled per-buffer by user
    pub whitespace: crate::config::WhitespaceVisibility,

    /// Whether pressing Tab should insert a tab character instead of spaces.
    /// Set based on language config; can be toggled per-buffer by user
    pub use_tabs: bool,

    /// Explicit per-buffer indentation-style override, set by "Toggle
    /// Indentation: Spaces ↔ Tabs (Current Buffer)". `None` = follow the
    /// resolved language config. Persisted in the per-file workspace state.
    pub use_tabs_override: Option<bool>,

    /// Explicit per-buffer whitespace-indicator master override, set by
    /// "Toggle Whitespace Indicators (Current Buffer)" / "Toggle Tab
    /// Indicators (Current Buffer)". `Some(false)` hides every indicator
    /// regardless of config; `Some(true)` shows the configured set; `None`
    /// follows config. Persisted in the per-file workspace state.
    ///
    /// A single bool rather than a full [`WhitespaceVisibility`](crate::config::WhitespaceVisibility):
    /// the command is a master on/off, and storing the resolved struct would
    /// freeze the buffer against later config edits.
    pub whitespace_override: Option<bool>,
    /// Explicit per-buffer override for the tab indicators alone (the `tabs_*`
    /// trio of [`WhitespaceVisibility`](crate::config::WhitespaceVisibility)),
    /// layered on top of `whitespace_override`: the master toggle answers "any
    /// indicators at all?", this answers "tab arrows specifically?". `None` =
    /// follow whatever the master/config resolution produced.
    pub tab_indicators_override: Option<bool>,

    /// Tab size (number of spaces per tab character) for rendering.
    /// Used for visual display of tab characters and indent calculations.
    /// Set based on language config; can be changed per-buffer by user
    pub tab_size: usize,

    /// Whether to auto-close brackets, parentheses, and quotes.
    /// Set based on global + language config.
    pub auto_close: bool,

    /// Whether to surround selected text with matching pairs when typing a delimiter.
    /// Set based on global + language config.
    pub auto_surround: bool,

    /// Where the cursor may move beyond the end of a line (virtual space).
    /// Resolved effective value: the per-buffer override when set, else the
    /// global config.
    pub virtual_space: crate::config::VirtualSpaceMode,

    /// Explicit per-buffer virtual-space override, set by "Toggle Virtual
    /// Space (Current Buffer)". `None` = follow the global config. Persisted
    /// in the per-file workspace state.
    pub virtual_space_override: Option<crate::config::VirtualSpaceMode>,

    /// Extra characters (beyond alphanumeric + `_`) considered part of
    /// identifiers for this language. Used by completion providers.
    pub word_characters: String,

    /// Whether indentation guides may render for this buffer. `false`
    /// suppresses them regardless of the global `editor.indentation_guide`
    /// mode (plain-text buffers default off; any language can opt out via
    /// `languages.<id>.indentation_guide`). Set based on global + language
    /// config.
    pub indentation_guide: bool,

    /// Explicit per-buffer occurrence-highlight override, set by "Toggle
    /// Occurrence Highlight (Current Buffer)". `None` = follow the global
    /// `editor.highlight_occurrences` setting. Persisted in the per-file
    /// workspace state.
    pub highlight_occurrences_override: Option<bool>,
}

impl Default for BufferSettings {
    fn default() -> Self {
        Self {
            whitespace: crate::config::WhitespaceVisibility::default(),
            use_tabs: false,
            use_tabs_override: None,
            whitespace_override: None,
            tab_indicators_override: None,
            tab_size: 4,
            auto_close: true,
            auto_surround: true,
            virtual_space: crate::config::VirtualSpaceMode::default(),
            virtual_space_override: None,
            word_characters: String::new(),
            indentation_guide: true,
            highlight_occurrences_override: None,
        }
    }
}

impl BufferSettings {
    /// Stamp a resolved global + per-language configuration onto this
    /// buffer's settings. Together with [`EditorState::apply_buffer_config`]
    /// this is THE single place language-dependent settings land on a buffer
    /// — when a new language-overridable flag is added, extend
    /// [`crate::config::BufferConfig::resolve`] and this function, and every
    /// call site (file open, new buffer, Set Language, save-time detection,
    /// config reload) picks it up automatically.
    ///
    /// Explicit per-buffer user overrides are preserved: `virtual_space`
    /// keeps following `virtual_space_override`, `use_tabs` follows
    /// `use_tabs_override` and `whitespace` follows `whitespace_override`
    /// when one is set. (The indentation-guide and fold-indicator pins live
    /// on `BufferViewState` — per split — and are consulted at render time,
    /// so re-stamping the language gate below can't clear them.)
    pub fn apply_config(&mut self, resolved: &crate::config::BufferConfig) {
        self.tab_size = resolved.tab_size;
        self.use_tabs = self.use_tabs_override.unwrap_or(resolved.use_tabs);
        self.auto_close = resolved.auto_close;
        self.auto_surround = resolved.auto_surround;
        self.virtual_space = self
            .virtual_space_override
            .unwrap_or(resolved.virtual_space);
        self.apply_whitespace_override(resolved.whitespace);
        self.word_characters = resolved.word_characters.clone();
        self.indentation_guide = resolved.indentation_guide;
    }

    /// Drop every explicit per-buffer override, so the buffer goes back to
    /// following the global + per-language configuration.
    ///
    /// The counterpart to [`apply_config`](Self::apply_config), which
    /// deliberately *preserves* these: "Reset Buffer Settings" is the one place
    /// that must clear them, and it re-applies the config afterwards. Every
    /// `*_override` field belongs here — one left out is a setting the user can
    /// pin but never un-pin.
    pub fn clear_user_overrides(&mut self) {
        self.virtual_space_override = None;
        self.use_tabs_override = None;
        self.whitespace_override = None;
        self.tab_indicators_override = None;
        self.highlight_occurrences_override = None;
    }

    /// Resolve `whitespace` from the buffer's configured visibility and the
    /// per-buffer master override.
    ///
    /// THE single place the override semantics live, so a restored session and
    /// a later `apply_config` can't disagree. `Some(true)` has to mirror what
    /// `WhitespaceVisibility::toggle_all` does when nothing is configured
    /// visible — fall back to the default set — otherwise turning indicators on
    /// in a language that hides them (Go hides tabs) would silently restore to
    /// "hidden" and lose the user's choice.
    pub fn apply_whitespace_override(&mut self, configured: crate::config::WhitespaceVisibility) {
        use crate::config::WhitespaceVisibility;
        self.whitespace = match self.whitespace_override {
            Some(false) => WhitespaceVisibility::hidden(),
            Some(true) if !configured.any_visible() => WhitespaceVisibility::default(),
            _ => configured,
        };
        // The tab pin layers on top of the master resolution, so "hide the
        // arrows but keep the space dots" (and the reverse) survives both a
        // config re-application and a session restore.
        if let Some(tabs) = self.tab_indicators_override {
            self.whitespace.tabs_leading = tabs;
            self.whitespace.tabs_inner = tabs;
            self.whitespace.tabs_trailing = tabs;
        }
    }
}

/// The complete editor state - everything needed to represent the current editing session
///
/// NOTE: Viewport is NOT stored here - it lives in SplitViewState.
/// This is because viewport is view-specific (each split can view the same buffer
/// at different scroll positions), while EditorState represents the buffer content.
pub struct EditorState {
    /// The text buffer
    pub buffer: Buffer,

    /// Syntax highlighter (tree-sitter or TextMate based on language)
    pub highlighter: HighlightEngine,

    /// Auto-indent calculator for smart indentation (RefCell for interior mutability)
    pub indent_calculator: RefCell<IndentCalculator>,

    /// Overlays for visual decorations (underlines, highlights, etc.)
    pub overlays: OverlayManager,

    /// Marker list for content-anchored overlay positions
    pub marker_list: MarkerList,

    /// Virtual text manager for inline hints (type hints, parameter hints, etc.)
    pub virtual_texts: VirtualTextManager,

    /// Conceal ranges for hiding/replacing byte ranges during rendering
    pub conceals: ConcealManager,

    /// Soft break points for marker-based line wrapping during rendering
    pub soft_breaks: SoftBreakManager,

    /// Popups for floating windows (completion, documentation, etc.)
    pub popups: PopupManager,

    /// Margins for line numbers, annotations, gutter symbols, etc.)
    pub margins: MarginManager,

    /// Cached line number for primary cursor (0-indexed)
    /// Maintained incrementally to avoid O(n) scanning on every render.
    /// Every path that moves the primary cursor must refresh this, or the status
    /// bar shows stale coordinates (see `set_buffer_cursor_in_splits`).
    pub primary_cursor_line_number: LineNumber,

    /// Current mode (for modal editing, if implemented)
    pub mode: String,

    /// Text properties for virtual buffers (embedded metadata in text ranges)
    /// Used by virtual buffers to store location info, severity, etc.
    pub text_properties: TextPropertyManager,

    /// Whether to show cursors in this buffer (default true)
    /// Can be set to false for virtual buffers like diagnostics panels
    pub show_cursors: bool,

    /// Virtual-space line counts to install on cursors when their next
    /// MoveCursor event applies (vertical virtual space). Movement handlers
    /// produce events but events don't carry `virtual_lines_below`, and a
    /// direct write from the handler would be cleared when the event applies
    /// later — so the handler queues the value here and `apply_move_cursor`
    /// consumes it. Entries are one-shot.
    pub pending_virtual_lines: Vec<(crate::model::event::CursorId, usize)>,

    /// Set once a plugin explicitly controls this buffer's cursor
    /// visibility via `setBufferShowCursors`. When true, the widget
    /// runtime stops overriding the cursor (`apply_widget_focus_cursor`
    /// becomes a no-op for this buffer) — letting a widget-panel plugin
    /// own its pane's cursor, e.g. git log's cursor-driven commit list.
    pub cursor_visibility_locked: bool,

    /// Whether editing is disabled for this buffer (default false)
    /// When true, typing, deletion, cut/paste, undo/redo are blocked
    /// but navigation, selection, and copy are still allowed
    pub editing_disabled: bool,

    /// Whether this buffer can be scrolled (default true). Fixed buffer-group
    /// panels (toolbars, headers, footers) set this to false so the mouse
    /// wheel is ignored and no scrollbar is drawn.
    pub scrollable: bool,

    /// Explicit per-buffer override for indentation-guide visibility.
    /// `None` = follow the default (the global `editor.indentation_guide`,
    /// except virtual buffers which default off — guides are a source-code aid,
    /// like the column rulers). `Some(false)` = force off; `Some(true)` = force
    /// on using the global mode. Set via the `setIndentationGuide` plugin API so
    /// a file-backed tool view that the virtual-buffer default can't catch — the
    /// Git Log commit-detail diff is the motivating case — can opt out.
    pub indentation_guide_override: Option<bool>,

    /// Whether this buffer hosts an interactive plugin widget panel (set
    /// when a widget panel is mounted into it). Such a buffer can be
    /// non-scrollable *and* still be a focus/click target — it owns its
    /// own scroll window (List/Tree) yet needs keyboard focus for
    /// navigation. Distinguishes it from a fixed, non-interactive
    /// buffer-group toolbar, which is also non-scrollable but must not
    /// take focus. Default false.
    pub interactive_widget_panel: bool,

    /// Per-buffer user settings (tab size, indentation style, etc.)
    /// These settings are preserved across file reloads (auto-revert)
    pub buffer_settings: BufferSettings,

    /// Semantic highlighter for word occurrence highlighting
    pub reference_highlighter: ReferenceHighlighter,

    /// Whether this buffer is a composite view (e.g., side-by-side diff)
    pub is_composite_buffer: bool,

    /// Debug mode: reveal highlight/overlay spans (WordPerfect-style)
    pub debug_highlight_mode: bool,

    /// Debounced semantic highlight cache
    pub reference_highlight_overlay: ReferenceHighlightOverlay,

    /// Bracket matching highlight overlay
    pub bracket_highlight_overlay: BracketHighlightOverlay,

    /// Cached LSP semantic tokens (converted to buffer byte ranges)
    pub semantic_tokens: Option<SemanticTokenStore>,

    /// Last-known LSP folding ranges for this buffer, tracked by byte markers
    /// so they auto-adjust when content is inserted or deleted around them
    /// (issue #1571).
    pub folding_ranges: LspFoldRanges,

    /// The detected language ID for this buffer (e.g., "rust", "csharp", "text").
    /// Used for LSP config lookup and internal identification.
    pub language: String,

    /// Human-readable language display name (e.g., "Rust", "C#", "Plain Text").
    /// Shown in the status bar and Set Language prompt.
    // TODO: Consider embedding `DetectedLanguage` directly in `EditorState`
    // instead of copying its fields, to avoid duplication between the two structs.
    pub display_name: String,

    /// Row structure per rendered geometry.
    ///
    /// Lives with the buffer because damage is a buffer event, but is keyed by
    /// geometry because row boundaries are a property of the *view* — two splits
    /// on this buffer at different widths hold different entries and each is
    /// repaired independently. See `crate::view::wrap_index`.
    pub wrap_indices: crate::view::wrap_index::WrapIndexSet,

    /// Forward coordinate mapper: remaps a byte coordinate captured at an older
    /// buffer `version()` to the current version. Fed at the marker-adjustment
    /// chokepoint (`apply_insert` / `apply_delete` / `replay_bulk_marker_adjustments`)
    /// so its coverage matches the marker tree's by construction. Used to repair
    /// stale coordinates that plugins echo back from fire-and-forget
    /// `lines_changed` hooks. See `crate::model::coord_map`.
    pub coord_map: crate::model::coord_map::CoordMap,

    /// Plugin-owned markers painted on the vertical scrollbar track, and the
    /// cached projection of those markers onto a track column. Byte-anchored
    /// on their own marker list, so they ride edits like gutter indicators do;
    /// see `crate::view::scrollbar_marker`.
    pub scrollbar_markers: crate::view::scrollbar_marker::ScrollbarMarkerManager,

    /// Cached marker→track-cell projection. Rebuilt only when the marker set,
    /// buffer content, geometry, or coordinate basis changes.
    pub scrollbar_marker_buckets: crate::view::scrollbar_marker::ScrollbarMarkerBuckets,
}

/// The fields of an [`Event::AddOverlay`], grouped so
/// [`EditorState::apply_add_overlay`] takes one argument instead of seven.
struct AddOverlaySpec<'a> {
    namespace: &'a Option<crate::view::overlay::OverlayNamespace>,
    range: &'a Range<usize>,
    face: &'a EventOverlayFace,
    priority: i32,
    message: &'a Option<String>,
    extend_to_line_end: bool,
    url: &'a Option<String>,
}

impl EditorState {
    /// Create a new editor state with an empty buffer
    ///
    /// Note: width/height parameters are kept for backward compatibility but
    /// are no longer used - viewport is now owned by SplitViewState.
    /// Apply a detected language to this state. This is the single mutation point
    /// for changing the language of a buffer after creation.
    pub fn apply_language(&mut self, detected: DetectedLanguage) {
        self.language = detected.name;
        self.display_name = detected.display_name;
        self.highlighter = detected.highlighter;
        if let Some(lang) = &detected.ts_language {
            self.reference_highlighter.set_language(lang);
        }
    }

    /// Resolve the global + per-language configuration for this buffer's
    /// current language and stamp it onto `buffer_settings`. THE single
    /// entry point for applying language-dependent settings to a buffer —
    /// call it whenever the buffer's language or the config changes: file
    /// open, new-buffer creation, **Set Language**, save-time language
    /// detection, and config reload/save all go through here, so a new
    /// language-overridable setting only needs to be added to
    /// [`crate::config::BufferConfig::resolve`] and
    /// [`BufferSettings::apply_config`].
    pub fn apply_buffer_config(&mut self, config: &crate::config::Config) {
        let resolved = crate::config::BufferConfig::resolve(config, Some(&self.language));
        self.buffer_settings.apply_config(&resolved);
    }

    /// Point this buffer's occurrence highlighting at the editor-wide default,
    /// unless the user pinned it with "Toggle Occurrence Highlight (Current
    /// Buffer)".
    ///
    /// THE single place `reference_highlight_overlay.enabled` is derived from
    /// config. Every file-open path stamps it, so a pin that only the toggle
    /// knew about would be erased the next time the buffer was (re)opened.
    pub fn apply_occurrence_highlight(&mut self, global_default: bool) {
        self.reference_highlight_overlay.enabled = self
            .buffer_settings
            .highlight_occurrences_override
            .unwrap_or(global_default);
    }

    /// Create a new state with a buffer and default (plain text) language.
    /// All other fields are initialized to their defaults.
    fn new_from_buffer(buffer: Buffer) -> Self {
        let mut marker_list = MarkerList::new();
        if !buffer.is_empty() {
            marker_list.adjust_for_insert(0, buffer.len());
        }

        Self {
            buffer,
            highlighter: HighlightEngine::None,
            indent_calculator: RefCell::new(IndentCalculator::new()),
            overlays: OverlayManager::new(),
            marker_list,
            virtual_texts: VirtualTextManager::new(),
            conceals: ConcealManager::new(),
            soft_breaks: SoftBreakManager::new(),
            popups: PopupManager::new(),
            margins: MarginManager::new(),
            primary_cursor_line_number: LineNumber::Absolute(0),
            mode: "insert".to_string(),
            text_properties: TextPropertyManager::new(),
            show_cursors: true,
            pending_virtual_lines: Vec::new(),
            cursor_visibility_locked: false,
            editing_disabled: false,
            scrollable: true,
            indentation_guide_override: None,
            interactive_widget_panel: false,
            buffer_settings: BufferSettings::default(),
            reference_highlighter: ReferenceHighlighter::new(),
            is_composite_buffer: false,
            debug_highlight_mode: false,
            reference_highlight_overlay: ReferenceHighlightOverlay::new(),
            bracket_highlight_overlay: BracketHighlightOverlay::new(),
            semantic_tokens: None,
            folding_ranges: LspFoldRanges::new(),
            language: "text".to_string(),
            display_name: "Text".to_string(),
            wrap_indices: crate::view::wrap_index::WrapIndexSet::default(),
            coord_map: crate::model::coord_map::CoordMap::default(),
            scrollbar_markers: crate::view::scrollbar_marker::ScrollbarMarkerManager::default(),
            scrollbar_marker_buckets:
                crate::view::scrollbar_marker::ScrollbarMarkerBuckets::default(),
        }
    }

    pub fn new(
        _width: u16,
        _height: u16,
        large_file_threshold: usize,
        fs: Arc<dyn FileSystem + Send + Sync>,
    ) -> Self {
        Self::new_from_buffer(Buffer::new(large_file_threshold, fs))
    }

    /// Create a new editor state with an empty buffer associated with a file path.
    /// Used for files that don't exist yet — the path is set so saving will create the file.
    pub fn new_with_path(
        large_file_threshold: usize,
        fs: Arc<dyn FileSystem + Send + Sync>,
        path: std::path::PathBuf,
    ) -> Self {
        Self::new_from_buffer(Buffer::new_with_path(large_file_threshold, fs, path))
    }

    /// Set the syntax highlighting language based on a virtual buffer name.
    /// Handles names like `*OLD:test.ts*` or `*OURS*.c` by stripping markers
    /// and detecting language from the cleaned filename.
    pub fn set_language_from_name(&mut self, name: &str, registry: &GrammarRegistry) {
        let detected = DetectedLanguage::from_virtual_name(name, registry);
        tracing::debug!(
            "Set highlighter for virtual buffer based on name: {} (backend: {}, language: {})",
            name,
            detected.highlighter.backend_name(),
            detected.name
        );
        self.apply_language(detected);
    }

    /// Create an editor state from a file
    ///
    /// Note: width/height parameters are kept for backward compatibility but
    /// are no longer used - viewport is now owned by SplitViewState.
    pub fn from_file(
        path: &std::path::Path,
        _width: u16,
        _height: u16,
        large_file_threshold: usize,
        registry: &GrammarRegistry,
        fs: Arc<dyn FileSystem + Send + Sync>,
    ) -> anyhow::Result<Self> {
        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
        let first_line = buffer.first_line_lossy();
        let detected = registry
            .find_by_path(path, first_line.as_deref())
            .map(|entry| DetectedLanguage::from_entry(entry, registry))
            .unwrap_or_else(DetectedLanguage::plain_text);
        let mut state = Self::new_from_buffer(buffer);
        state.apply_language(detected);
        Ok(state)
    }

    /// Create an editor state from a file with language configuration.
    ///
    /// This version uses the provided languages configuration for syntax detection,
    /// allowing user-configured filename patterns to be respected for highlighting.
    ///
    /// Note: width/height parameters are kept for backward compatibility but
    /// are no longer used - viewport is now owned by SplitViewState.
    pub fn from_file_with_languages(
        path: &std::path::Path,
        _width: u16,
        _height: u16,
        large_file_threshold: usize,
        registry: &GrammarRegistry,
        languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
        fs: Arc<dyn FileSystem + Send + Sync>,
    ) -> anyhow::Result<Self> {
        let buffer = Buffer::load_from_file(path, large_file_threshold, fs)?;
        let first_line = buffer.first_line_lossy();
        let detected =
            DetectedLanguage::from_path(path, first_line.as_deref(), registry, languages);
        let mut state = Self::new_from_buffer(buffer);
        state.apply_language(detected);
        Ok(state)
    }

    /// Create an editor state from a file, always treating it as text.
    ///
    /// Like [`from_file_with_languages`](Self::from_file_with_languages) but
    /// skips binary detection — the backing file is always loaded through the
    /// text path. Used for the terminal scrollback view so raw PTY output
    /// containing control bytes never flips the buffer into binary mode, which
    /// would otherwise suppress ANSI-color rendering in scrollback (#2449).
    pub fn from_file_with_languages_force_text(
        path: &std::path::Path,
        _width: u16,
        _height: u16,
        large_file_threshold: usize,
        registry: &GrammarRegistry,
        languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
        fs: Arc<dyn FileSystem + Send + Sync>,
    ) -> anyhow::Result<Self> {
        let buffer = Buffer::load_from_file_force_text(path, large_file_threshold, fs)?;
        let first_line = buffer.first_line_lossy();
        let detected =
            DetectedLanguage::from_path(path, first_line.as_deref(), registry, languages);
        let mut state = Self::new_from_buffer(buffer);
        state.apply_language(detected);
        Ok(state)
    }

    /// Create an editor state from a buffer and a pre-built `DetectedLanguage`.
    ///
    /// This is useful when you have already loaded a buffer with a specific encoding
    /// and want to create an EditorState from it.
    pub fn from_buffer_with_language(buffer: Buffer, detected: DetectedLanguage) -> Self {
        let mut state = Self::new_from_buffer(buffer);
        state.apply_language(detected);
        state
    }

    /// Remap a byte coordinate that a plugin computed against an older buffer
    /// version forward to the current buffer state.
    ///
    /// `epoch` is the `buffer.version()` the coordinate was captured at (the
    /// `epoch` carried by the `lines_changed` hook). `None` means the caller
    /// has no epoch to remap against, so the coordinate is taken as-is.
    /// `Some(_)` returning `None` means the epoch is too old to map (evicted or
    /// barriered) — the caller should skip the operation; the next
    /// `lines_changed` for the affected line redoes it with fresh coordinates.
    pub fn map_plugin_coord(&self, coord: usize, epoch: Option<u64>) -> Option<usize> {
        match epoch {
            None => Some(coord),
            Some(v) => self.coord_map.map(coord, v),
        }
    }

    /// Remap both ends of a `[start, end)` range via [`Self::map_plugin_coord`].
    /// Returns `None` if either end is unmappable (epoch evicted/barriered) so a
    /// coordinate-bearing handler can `?`/early-return as one unit instead of
    /// re-implementing the two-coord match. Every range-taking plugin command
    /// goes through this, so the stale-coordinate repair can't be forgotten.
    pub fn map_plugin_range(
        &self,
        start: usize,
        end: usize,
        epoch: Option<u64>,
    ) -> Option<(usize, usize)> {
        Some((
            self.map_plugin_coord(start, epoch)?,
            self.map_plugin_coord(end, epoch)?,
        ))
    }

    /// Snapshot the decorations that move row boundaries, for a wrap-index
    /// build.
    ///
    /// Owned data, resolved before the build takes `&mut Buffer` — the build
    /// cannot hold a `&EditorState` alongside that. Queried over the whole
    /// buffer, since the index covers every line, and with **no cursors**:
    /// cursor-dependent activation is the renderer's business, and letting it
    /// reach the index would mean scroll position changed when a cursor moved.
    ///
    /// `view_mode` decides the one namespace that is mode-specific:
    /// markdown_compose's `md-syntax` conceals are emitted whenever any split
    /// composes the buffer, so a Source-mode split must ignore them — the same
    /// gate `build_view_data` applies.
    /// Collapsed byte ranges for `folds`, sorted — the form the index and the
    /// renderer both want. Folds live on the split, so they arrive as an
    /// argument rather than off `self`.
    pub fn fold_ranges(
        &self,
        folds: &crate::view::folding::FoldManager,
    ) -> Vec<std::ops::Range<usize>> {
        crate::view::ui::split_rendering::fold_skip_set(&self.buffer, &self.marker_list, folds)
    }

    /// Resolve the decorations the wrap index builds from.
    ///
    /// `cursors` must be empty for the index itself — the index is canonical,
    /// or scroll position would change when a cursor moved. The one legitimate
    /// non-empty caller is the render path's cursor-line expansion, which
    /// re-wraps a single line the way the frame will draw it.
    pub fn index_decorations(
        &self,
        view_mode: crate::view::line_wrap_cache::CacheViewMode,
        folds: Vec<std::ops::Range<usize>>,
        cursors: &[usize],
    ) -> crate::view::wrap_index::IndexDecorations {
        use crate::view::line_wrap_cache::CacheViewMode;
        let end = self.buffer.len().saturating_add(1);
        let no_cursors = cursors;

        let soft_breaks = if self.soft_breaks.is_empty() {
            Vec::new()
        } else {
            self.soft_breaks
                .query_viewport(0, end, &self.marker_list, &no_cursors)
        };

        let conceals = if self.conceals.is_empty() {
            Vec::new()
        } else {
            let exclude = (!matches!(view_mode, CacheViewMode::Compose))
                .then(|| fresh_core::overlay::OverlayNamespace::from_string("md-syntax".into()));
            self.conceals
                .query_viewport_excluding(0, end, &self.marker_list, exclude.as_ref(), &no_cursors)
                .into_iter()
                .map(|(r, t)| (r, t.map(str::to_owned)))
                .collect()
        };

        let inline_hints = if self.virtual_texts.is_empty() {
            Vec::new()
        } else {
            crate::view::ui::split_rendering::transforms::resolve_inline_hints(self, None, 0, end)
        };

        crate::view::wrap_index::IndexDecorations {
            soft_breaks,
            conceals,
            inline_hints,
            folds,
        }
    }

    /// Handle an Insert event - adjusts markers, buffer, highlighter, cursors, and line numbers
    /// Pre-edit line coordinates the wrap index's repair needs.
    ///
    /// Captured *before* the buffer changes: repair locates the damaged row in
    /// the old layout, so it needs the old line numbering. Reconstructing it
    /// afterwards would mean keeping a shadow copy of the line index.
    fn wrap_damage_coords(&self, start: usize, removed: usize) -> (usize, usize, usize) {
        let line_before = self.buffer.get_line_number(start);
        let line_start_before = self.buffer.line_start_offset(line_before).unwrap_or(0);
        let line_end_before = self.buffer.get_line_number(start + removed);
        (line_before, line_start_before, line_end_before)
    }

    /// Repair every geometry's row structure after a buffer edit.
    ///
    /// Local by construction: boundaries before the edit's row are untouched,
    /// and the rest resynchronises within a row or two. This is what makes a
    /// keystroke on a 500 KB single-line file cost rows rather than the line.
    fn repair_wrap_indices(&mut self, damage: crate::view::wrap_index::EditDamage) {
        if self.wrap_indices.is_empty() {
            return;
        }
        let line_ending = self.buffer.line_ending();
        // Snapshot the virtual-line anchors so the lookup borrows this list
        // rather than `self`, whose buffer the repair holds mutably.
        let virtual_positions: Vec<usize> = if self.virtual_texts.is_empty() {
            Vec::new()
        } else {
            let mut v: Vec<usize> = self
                .virtual_texts
                .query_lines_in_range(&self.marker_list, 0, self.buffer.len() + 1)
                .into_iter()
                .map(|(pos, _)| pos)
                .collect();
            v.sort_unstable();
            v
        };
        let virtual_rows = |start: usize, end: usize| -> u32 {
            let lo = virtual_positions.partition_point(|p| *p < start);
            let hi = virtual_positions.partition_point(|p| *p < end);
            (hi - lo) as u32
        };
        // Computed after the edit, so the repaired index is marked current and
        // the next render reuses it instead of rebuilding.
        let new_version = crate::view::line_wrap_cache::pipeline_inputs_version(
            self.buffer.version(),
            self.soft_breaks.version(),
            self.conceals.version(),
            self.virtual_texts.version(),
        );
        self.wrap_indices.damage_bytes(
            &mut self.buffer,
            damage,
            line_ending,
            &virtual_rows,
            new_version,
        );
    }

    fn apply_insert(
        &mut self,
        cursors: &mut Cursors,
        position: usize,
        text: &str,
        cursor_id: crate::model::event::CursorId,
    ) {
        let newlines_inserted = text.matches('\n').count();
        let (line_before, line_start_before, line_end_before) =
            self.wrap_damage_coords(position, 0);

        // CRITICAL: Adjust markers BEFORE modifying buffer
        self.marker_list.adjust_for_insert(position, text.len());
        self.margins.adjust_for_insert(position, text.len());
        self.scrollbar_markers
            .adjust_for_insert(position, text.len());

        // Insert text into buffer
        self.buffer.insert(position, text);

        self.repair_wrap_indices(crate::view::wrap_index::EditDamage {
            start: position,
            removed: 0,
            inserted: text.len(),
            line_before,
            line_start_before,
            line_end_before,
        });

        // Record the edit for forward coordinate mapping, keyed by the
        // post-edit version — same shift the marker tree just applied.
        self.coord_map
            .record_insert(self.buffer.version(), position, text.len());

        // Notify highlighter of the insert (adjusts checkpoint marker positions)
        // and invalidate span cache for the edited range.
        self.highlighter.notify_insert(position, text.len());
        self.highlighter
            .invalidate_range(position..position + text.len());

        // Note: reference_highlight_overlay uses markers that auto-adjust,
        // so no manual invalidation needed

        // Adjust all cursors after the edit
        cursors.adjust_for_edit(position, 0, text.len());

        // Move the cursor that made the edit to the end of the insertion
        if let Some(cursor) = cursors.get_mut(cursor_id) {
            cursor.position = position + text.len();
            cursor.clear_selection();
            // Editing moves the cursor horizontally, so the goal column for
            // vertical navigation must re-derive from the new position. A
            // stale goal would send the next Up/Down to the pre-edit column.
            cursor.sticky_column = None;
            cursor.virtual_lines_below = 0;
        }

        // Update primary cursor line number if this was the primary cursor
        if cursor_id == cursors.primary_id() {
            self.primary_cursor_line_number = match self.primary_cursor_line_number {
                LineNumber::Absolute(line) => LineNumber::Absolute(line + newlines_inserted),
                LineNumber::Relative {
                    line,
                    from_cached_line,
                } => LineNumber::Relative {
                    line: line + newlines_inserted,
                    from_cached_line,
                },
            };
        }
    }

    /// Handle a Delete event - adjusts markers, buffer, highlighter, cursors, and line numbers
    fn apply_delete(
        &mut self,
        cursors: &mut Cursors,
        range: &std::ops::Range<usize>,
        cursor_id: crate::model::event::CursorId,
        deleted_text: &str,
    ) {
        let len = range.len();
        let (line_before, line_start_before, line_end_before) =
            self.wrap_damage_coords(range.start, len);

        // Count newlines deleted BEFORE the primary cursor's original position.
        // For backspace: cursor was at range.end, so all deleted newlines are before it.
        // For forward delete: cursor was at range.start, so no deleted newlines are before it.
        let primary_newlines_removed = if cursor_id == cursors.primary_id() {
            let cursor_pos = cursors.get(cursor_id).map_or(range.start, |c| c.position);
            let bytes_before_cursor = cursor_pos
                .saturating_sub(range.start)
                .min(len)
                .min(deleted_text.len());
            deleted_text[..bytes_before_cursor].matches('\n').count()
        } else {
            0
        };

        // Drop virtual texts whose anchors are being erased. This is what
        // makes inlay hints disappear immediately when the range containing
        // them is deleted; without this the marker would just clamp to
        // range.start and the hint would linger at the wrong position until
        // the next LSP refresh.
        self.virtual_texts
            .remove_in_range(&mut self.marker_list, range.start, range.end);

        // CRITICAL: Adjust markers BEFORE modifying buffer
        self.marker_list.adjust_for_delete(range.start, len);
        self.margins.adjust_for_delete(range.start, len);
        self.scrollbar_markers.adjust_for_delete(range.start, len);

        // Delete from buffer
        self.buffer.delete(range.clone());

        self.repair_wrap_indices(crate::view::wrap_index::EditDamage {
            start: range.start,
            removed: len,
            inserted: 0,
            line_before,
            line_start_before,
            line_end_before,
        });

        // Record the edit for forward coordinate mapping, keyed by the
        // post-edit version — same shift the marker tree just applied.
        self.coord_map
            .record_delete(self.buffer.version(), range.start, len);

        // Notify highlighter of the delete (adjusts checkpoint marker positions)
        // and invalidate span cache for the edited range.
        self.highlighter.notify_delete(range.start, len);
        self.highlighter.invalidate_range(range.clone());

        // Note: reference_highlight_overlay uses markers that auto-adjust,
        // so no manual invalidation needed

        // Adjust all cursors after the edit
        cursors.adjust_for_edit(range.start, len, 0);

        // Move the cursor that made the edit to the start of deletion
        if let Some(cursor) = cursors.get_mut(cursor_id) {
            cursor.position = range.start;
            cursor.clear_selection();
            // See apply_insert: edits invalidate the vertical-navigation goal.
            cursor.sticky_column = None;
            cursor.virtual_lines_below = 0;
        }

        // Update primary cursor line number if this was the primary cursor
        if cursor_id == cursors.primary_id() && primary_newlines_removed > 0 {
            self.primary_cursor_line_number = match self.primary_cursor_line_number {
                LineNumber::Absolute(line) => {
                    LineNumber::Absolute(line.saturating_sub(primary_newlines_removed))
                }
                LineNumber::Relative {
                    line,
                    from_cached_line,
                } => LineNumber::Relative {
                    line: line.saturating_sub(primary_newlines_removed),
                    from_cached_line,
                },
            };
        }
    }

    /// Apply an event to the state - THE ONLY WAY TO MODIFY STATE
    /// This is the heart of the event-driven architecture
    pub fn apply(&mut self, cursors: &mut Cursors, event: &Event) {
        match event {
            Event::Insert {
                position,
                text,
                cursor_id,
            } => self.apply_insert(cursors, *position, text, *cursor_id),

            Event::Delete {
                range,
                cursor_id,
                deleted_text,
            } => self.apply_delete(cursors, range, *cursor_id, deleted_text),

            Event::MoveCursor {
                cursor_id,
                new_position,
                new_anchor,
                new_sticky_column,
                ..
            } => self.apply_move_cursor(
                cursors,
                *cursor_id,
                *new_position,
                *new_anchor,
                *new_sticky_column,
            ),

            Event::AddCursor {
                cursor_id,
                position,
                anchor,
            } => self.apply_add_cursor(cursors, *cursor_id, *position, *anchor),

            Event::RemoveCursor { cursor_id, .. } => {
                cursors.remove(*cursor_id);
            }

            // View events (Scroll, SetViewport, Recenter) are now handled at Editor level
            // via SplitViewState. They should not reach EditorState.apply().
            Event::Scroll { .. } | Event::SetViewport { .. } | Event::Recenter => {
                // These events are intercepted in Editor::apply_event_to_active_buffer
                // and routed to SplitViewState. If we get here, something is wrong.
                tracing::warn!("View event {:?} reached EditorState.apply() - should be handled by SplitViewState", event);
            }

            Event::SetAnchor {
                cursor_id,
                position,
            } => {
                // Set the anchor (selection start) for a specific cursor
                // Also disable deselect_on_move so movement preserves the selection (Emacs mark mode)
                if let Some(cursor) = cursors.get_mut(*cursor_id) {
                    cursor.anchor = Some(*position);
                    cursor.deselect_on_move = false;
                }
            }

            Event::CancelAnchor { cursor_id } => {
                // Re-enable deselect_on_move so that moving the cursor
                // causes it to drop its selection (exiting "mark mode")
                if let Some(cursor) = cursors.get_mut(*cursor_id) {
                    cursor.deselect_on_move = true;
                }
            }

            Event::ClearAnchor { cursor_id } => {
                // Clear the anchor and reset deselect_on_move to cancel mark mode
                // Also clear block selection if active
                if let Some(cursor) = cursors.get_mut(*cursor_id) {
                    cursor.anchor = None;
                    cursor.deselect_on_move = true;
                    cursor.clear_block_selection();
                }
            }

            Event::ChangeMode { mode } => {
                self.mode = mode.clone();
            }

            Event::AddOverlay {
                namespace,
                range,
                face,
                priority,
                message,
                extend_to_line_end,
                url,
            } => self.apply_add_overlay(AddOverlaySpec {
                namespace,
                range,
                face,
                priority: *priority,
                message,
                extend_to_line_end: *extend_to_line_end,
                url,
            }),

            Event::RemoveOverlay { handle } => {
                tracing::trace!("RemoveOverlay: handle={:?}", handle);
                self.overlays
                    .remove_by_handle(handle, &mut self.marker_list);
            }

            Event::RemoveOverlaysInRange { range } => {
                self.overlays.remove_in_range(range, &mut self.marker_list);
            }

            Event::ClearNamespace { namespace } => {
                tracing::trace!("ClearNamespace: namespace={:?}", namespace);
                self.overlays
                    .clear_namespace(namespace, &mut self.marker_list);
            }

            Event::ClearOverlays => {
                self.overlays.clear(&mut self.marker_list);
            }

            Event::ShowPopup { popup } => self.apply_show_popup(popup),

            Event::HidePopup => {
                self.popups.hide();
            }

            Event::ClearPopups => {
                self.popups.clear();
            }

            Event::PopupSelectNext => {
                if let Some(popup) = self.popups.top_mut() {
                    popup.select_next();
                }
            }

            Event::PopupSelectPrev => {
                if let Some(popup) = self.popups.top_mut() {
                    popup.select_prev();
                }
            }

            Event::PopupPageDown => {
                if let Some(popup) = self.popups.top_mut() {
                    popup.page_down();
                }
            }

            Event::PopupPageUp => {
                if let Some(popup) = self.popups.top_mut() {
                    popup.page_up();
                }
            }

            Event::AddMarginAnnotation {
                line,
                position,
                content,
                annotation_id,
            } => self.apply_add_margin_annotation(*line, position, content, annotation_id),

            Event::RemoveMarginAnnotation { annotation_id } => {
                self.margins.remove_by_id(annotation_id);
            }

            Event::RemoveMarginAnnotationsAtLine { line, position } => {
                let margin_position = convert_margin_position(position);
                self.margins.remove_at_line(*line, margin_position);
            }

            Event::ClearMarginPosition { position } => {
                let margin_position = convert_margin_position(position);
                self.margins.clear_position(margin_position);
            }

            Event::ClearMargins => {
                self.margins.clear_all();
            }

            Event::SetLineNumbers { enabled } => {
                self.margins.configure_for_line_numbers(*enabled);
            }

            // Split events are handled at the Editor level, not at EditorState level
            // These are no-ops here as they affect the split layout, not buffer state
            Event::SplitPane { .. }
            | Event::CloseSplit { .. }
            | Event::SetActiveSplit { .. }
            | Event::AdjustSplitRatio { .. }
            | Event::NextSplit
            | Event::PrevSplit
            | Event::NextPane
            | Event::PrevPane => {
                // No-op: split events are handled by Editor, not EditorState
            }

            Event::Batch { events, .. } => {
                // Apply all events in the batch sequentially
                // This ensures multi-cursor operations are applied atomically
                for event in events {
                    self.apply(cursors, event);
                }
            }

            Event::BulkEdit {
                new_snapshot,
                new_cursors,
                edits,
                displaced_markers,
                ..
            } => self.apply_bulk_edit(cursors, new_snapshot, new_cursors, edits, displaced_markers),
        }
    }

    /// Move `cursor_id` to a new position/anchor and keep the cached primary
    /// cursor line number in sync. Split out of [`Self::apply`] so the
    /// dispatcher stays a flat table of one-line arms.
    fn apply_move_cursor(
        &mut self,
        cursors: &mut Cursors,
        cursor_id: crate::model::event::CursorId,
        new_position: usize,
        new_anchor: Option<usize>,
        new_sticky_column: Option<usize>,
    ) {
        if let Some(cursor) = cursors.get_mut(cursor_id) {
            // Vertical virtual space is only meaningful while the cursor
            // stays parked at the buffer end; moving to a different byte
            // position leaves it. Same-position moves (virtual column
            // steps) keep it.
            if cursor.position != new_position {
                cursor.virtual_lines_below = 0;
            }
            cursor.position = new_position;
            cursor.anchor = new_anchor;
            cursor.sticky_column = new_sticky_column;
            // Install a queued virtual-line count (vertical movement into /
            // along the virtual lines below the buffer end).
            if let Some(idx) = self
                .pending_virtual_lines
                .iter()
                .position(|(id, _)| *id == cursor_id)
            {
                let (_, vlines) = self.pending_virtual_lines.swap_remove(idx);
                cursor.virtual_lines_below = vlines;
            }
        }

        // Update primary cursor line number if this is the primary cursor.
        // Try to get exact line number from buffer, or estimate for large files.
        if cursor_id == cursors.primary_id() {
            self.primary_cursor_line_number = match self.buffer.offset_to_position(new_position) {
                Some(pos) => LineNumber::Absolute(pos.line),
                None => {
                    // Large file without line metadata - estimate line number
                    // using the default estimated_line_length of 80 bytes.
                    LineNumber::Absolute(new_position / 80)
                }
            };
        }
    }

    /// Insert a cursor under the exact id carried by the event. The id is
    /// preserved (rather than freshly allocated) so undo/redo stays
    /// deterministic.
    fn apply_add_cursor(
        &self,
        cursors: &mut Cursors,
        cursor_id: crate::model::event::CursorId,
        position: usize,
        anchor: Option<usize>,
    ) {
        let cursor = match anchor {
            Some(anchor) => Cursor::with_selection(anchor, position),
            None => Cursor::new(position),
        };
        cursors.insert_with_id(cursor_id, cursor);
        let mode = self.buffer_settings.virtual_space;
        cursors.normalize_with_key(|c| {
            (
                crate::model::virtual_space::cursor_virtual_columns(mode, &self.buffer, c),
                crate::model::virtual_space::cursor_virtual_lines(mode, &self.buffer, c),
            )
        });
    }

    /// Materialize an `AddOverlay` event into a tracked [`Overlay`].
    fn apply_add_overlay(&mut self, spec: AddOverlaySpec) {
        self.add_overlay(
            spec.namespace.clone(),
            spec.range.clone(),
            spec.face.clone(),
            spec.priority,
            spec.message.clone(),
            spec.extend_to_line_end,
            spec.url.clone(),
        );
    }

    /// Add an overlay to this buffer's overlay manager and return its handle.
    ///
    /// This is the shared materialization point for both the `AddOverlay`
    /// event (via [`apply_add_overlay`](Self::apply_add_overlay)) and direct
    /// callers that need the handle back for later removal — e.g. the hover /
    /// rename symbol highlights. Returning the handle from the add itself is
    /// what `OverlayManager::add` already provides; callers must **not** try to
    /// recover it via `overlays.all().last()`, which returns the
    /// highest-priority overlay (priority-sorted), not the one just added.
    #[allow(clippy::too_many_arguments)]
    pub fn add_overlay(
        &mut self,
        namespace: Option<crate::view::overlay::OverlayNamespace>,
        range: Range<usize>,
        face: EventOverlayFace,
        priority: i32,
        message: Option<String>,
        extend_to_line_end: bool,
        url: Option<String>,
    ) -> crate::view::overlay::OverlayHandle {
        let overlay_face = convert_event_face_to_overlay_face(&face);
        let mut overlay =
            Overlay::with_priority(&mut self.marker_list, range, overlay_face, priority);
        overlay.namespace = namespace;
        overlay.message = message;
        overlay.extend_to_line_end = extend_to_line_end;
        overlay.url = url;
        self.overlays.add(overlay)
    }

    /// Show a popup synthesized from replayed [`PopupData`].
    ///
    /// The replay path has no theme handle, so the popup is built with theme
    /// *defaults*. Editor-level callers
    /// (`Editor::apply_event_to_active_buffer`) intercept `Event::ShowPopup`
    /// *before* it reaches `state.apply` and build the popup with the live
    /// theme — see `event_apply.rs`. This is reached only when tests drive
    /// `state.apply` directly (no surrounding `Editor`).
    fn apply_show_popup(&mut self, popup: &PopupData) {
        use crate::view::theme::{default_popup_bg, default_popup_border_fg};
        let popup_obj = convert_popup_data_to_popup(
            popup,
            default_popup_bg().into(),
            default_popup_border_fg().into(),
        );
        self.popups.show_or_replace(popup_obj);
    }

    /// Add a margin annotation, reusing the event-supplied id when present.
    fn apply_add_margin_annotation(
        &mut self,
        line: usize,
        position: &MarginPositionData,
        content: &MarginContentData,
        annotation_id: &Option<String>,
    ) {
        let margin_position = convert_margin_position(position);
        let margin_content = convert_margin_content(content);
        let annotation = match annotation_id {
            Some(id) => {
                MarginAnnotation::with_id(line, margin_position, margin_content, id.clone())
            }
            None => MarginAnnotation::new(line, margin_position, margin_content),
        };
        self.margins.add_annotation(annotation);
    }

    /// Replay a bulk (undo/redo) edit: restore the buffer snapshot, replay the
    /// marker/margin adjustments, restore displaced markers, drop ephemeral
    /// decorations, and move the cursors to their recorded positions.
    fn apply_bulk_edit(
        &mut self,
        cursors: &mut Cursors,
        new_snapshot: &Option<Arc<crate::model::buffer::BufferSnapshot>>,
        new_cursors: &[(crate::model::event::CursorId, usize, Option<usize>)],
        edits: &[(usize, usize, usize)],
        displaced_markers: &[(u64, usize)],
    ) {
        // Restore the target buffer state (piece tree + buffers) for this event.
        // - For undo: snapshots are swapped, so new_snapshot is the original state
        // - For redo: new_snapshot is the state after edits
        // Restoring buffers alongside the tree is critical because
        // consolidate_after_save() can replace buffers between snapshot and restore.
        if let Some(snapshot) = new_snapshot {
            self.buffer.restore_buffer_state(snapshot);

            // A snapshot restore with no per-edit triples (hot-exit recovery,
            // logs loaded from disk) changes content opaquely: the ring can't
            // replay it, so bar any map across this version. When `edits` is
            // populated the per-tuple records below describe the delta exactly.
            if edits.is_empty() {
                self.coord_map.record_barrier(self.buffer.version());
            }
        }

        self.replay_bulk_marker_adjustments(edits);

        // Restore displaced markers to their original positions.
        // This fixes markers that were inside a deleted range and collapsed
        // to the deletion boundary — they're now moved back to their exact
        // original positions after the text has been restored by undo.
        if !displaced_markers.is_empty() {
            self.restore_displaced_markers(displaced_markers);
        }

        // Clear ephemeral decorations — their source systems will re-push
        // correct positions after the edit notification.
        self.virtual_texts.clear(&mut self.marker_list);

        use crate::view::overlay::OverlayNamespace;
        let namespaces = ["lsp-diagnostic", "reference-highlight", "bracket-highlight"];
        for ns in &namespaces {
            self.overlays.clear_namespace(
                &OverlayNamespace::from_string(ns.to_string()),
                &mut self.marker_list,
            );
        }

        // Update cursor positions
        for &(cursor_id, position, anchor) in new_cursors {
            if let Some(cursor) = cursors.get_mut(cursor_id) {
                cursor.position = position;
                cursor.anchor = anchor;
            }
        }

        // Invalidate highlight cache for entire buffer
        self.highlighter.invalidate_all();

        // Update primary cursor line number
        let primary_pos = cursors.primary().position;
        self.primary_cursor_line_number = match self.buffer.offset_to_position(primary_pos) {
            Some(pos) => LineNumber::Absolute(pos.line),
            None => LineNumber::Absolute(0),
        };
    }

    /// Replay the marker and margin position adjustments recorded for a bulk
    /// edit.
    ///
    /// `edits` are `(position, delete_len, insert_len)` tuples in descending
    /// position order, so processing them as-is adjusts later positions first
    /// and avoids cascading shift errors. Replacements (both lengths non-zero
    /// at the same position) are collapsed to their net delta to avoid the
    /// marker-at-boundary problem where a sequential delete+insert pushes
    /// markers incorrectly.
    pub(crate) fn replay_bulk_marker_adjustments(&mut self, edits: &[(usize, usize, usize)]) {
        let version = self.buffer.version();
        for &(pos, del_len, ins_len) in edits {
            // Feed the coordinate ring the same tuples, in the same
            // descending-position order, all sharing the post-bulk version.
            self.coord_map
                .record_replace(version, pos, del_len, ins_len);
        }

        // Adjust each marker owner once for the whole batch. Doing it per edit
        // costs O(markers) for every deletion, which made a replace-all over
        // tens of thousands of matches quadratic (issue #2893).
        self.marker_list.adjust_for_bulk_edits(edits);
        self.margins.adjust_for_bulk_edits(edits);
        self.scrollbar_markers.adjust_for_bulk_edits(edits);
    }

    /// Capture positions of markers strictly inside a deleted range.
    /// Call this BEFORE applying the delete. Returns encoded displaced markers.
    pub fn capture_displaced_markers(&self, range: &Range<usize>) -> Vec<(u64, usize)> {
        let mut displaced = Vec::new();
        if range.is_empty() {
            return displaced;
        }
        for (marker_id, start, _end) in self.marker_list.query_range(range.start, range.end) {
            if start > range.start && start < range.end {
                displaced.push(
                    DisplacedMarker::Main {
                        id: marker_id.0,
                        position: start,
                    }
                    .encode(),
                );
            }
        }
        for (marker_id, start, _end) in self.margins.query_indicator_range(range.start, range.end) {
            if start > range.start && start < range.end {
                displaced.push(
                    DisplacedMarker::Margin {
                        id: marker_id.0,
                        position: start,
                    }
                    .encode(),
                );
            }
        }
        displaced
    }

    /// Capture displaced markers for multiple delete ranges (BulkEdit).
    pub fn capture_displaced_markers_bulk(
        &self,
        edits: &[(usize, usize, String)],
    ) -> Vec<(u64, usize)> {
        let mut displaced = Vec::new();
        for (pos, del_len, _text) in edits {
            if *del_len > 0 {
                displaced.extend(self.capture_displaced_markers(&(*pos..*pos + *del_len)));
            }
        }
        displaced
    }

    /// Restore displaced markers to their exact original positions.
    pub fn restore_displaced_markers(&mut self, displaced: &[(u64, usize)]) {
        for &(tagged_id, original_pos) in displaced {
            let dm = DisplacedMarker::decode(tagged_id, original_pos);
            match dm {
                DisplacedMarker::Main { id, position } => {
                    self.marker_list.set_position(MarkerId(id), position);
                }
                DisplacedMarker::Margin { id, position } => {
                    self.margins.set_indicator_position(MarkerId(id), position);
                }
            }
        }
    }

    /// Apply multiple events in sequence
    pub fn apply_many(&mut self, cursors: &mut Cursors, events: &[Event]) {
        for event in events {
            self.apply(cursors, event);
        }
    }

    /// Called when this buffer loses focus (e.g., switching to another buffer,
    /// opening a prompt, focusing file explorer, etc.)
    /// Dismisses transient popups like Hover and Signature Help.
    pub fn on_focus_lost(&mut self) {
        if self.popups.dismiss_transient() {
            tracing::debug!("Dismissed transient popup on buffer focus loss");
        }
    }
}

/// Convert event overlay face to the actual overlay face
fn convert_event_face_to_overlay_face(event_face: &EventOverlayFace) -> OverlayFace {
    match event_face {
        EventOverlayFace::Underline { color, style } => {
            let underline_style = match style {
                crate::model::event::UnderlineStyle::Straight => UnderlineStyle::Straight,
                crate::model::event::UnderlineStyle::Wavy => UnderlineStyle::Wavy,
                crate::model::event::UnderlineStyle::Dotted => UnderlineStyle::Dotted,
                crate::model::event::UnderlineStyle::Dashed => UnderlineStyle::Dashed,
            };
            OverlayFace::Underline {
                color: Color::Rgb(color.0, color.1, color.2),
                style: underline_style,
            }
        }
        EventOverlayFace::Background { color } => OverlayFace::Background {
            color: Color::Rgb(color.0, color.1, color.2),
        },
        EventOverlayFace::Foreground { color } => OverlayFace::Foreground {
            color: Color::Rgb(color.0, color.1, color.2),
        },
        EventOverlayFace::Style { options } => {
            use crate::view::theme::named_color_from_str;
            use ratatui::style::Modifier;

            // Build fallback style from RGB values or named colors
            let mut style = Style::default();

            // Extract foreground color (RGB, named color, or default white)
            if let Some(ref fg) = options.fg {
                if let Some((r, g, b)) = fg.as_rgb() {
                    style = style.fg(Color::Rgb(r, g, b));
                } else if let Some(key) = fg.as_theme_key() {
                    if let Some(color) = named_color_from_str(key) {
                        style = style.fg(color);
                    }
                }
            }

            // Extract background color (RGB, named color, or fallback)
            if let Some(ref bg) = options.bg {
                if let Some((r, g, b)) = bg.as_rgb() {
                    style = style.bg(Color::Rgb(r, g, b));
                } else if let Some(key) = bg.as_theme_key() {
                    if let Some(color) = named_color_from_str(key) {
                        style = style.bg(color);
                    }
                }
            }

            // Apply modifiers
            let mut modifiers = Modifier::empty();
            if options.bold {
                modifiers |= Modifier::BOLD;
            }
            if options.italic {
                modifiers |= Modifier::ITALIC;
            }
            if options.underline {
                modifiers |= Modifier::UNDERLINED;
            }
            if options.strikethrough {
                modifiers |= Modifier::CROSSED_OUT;
            }
            if !modifiers.is_empty() {
                style = style.add_modifier(modifiers);
            }

            // Extract theme keys (exclude recognized named colors, already resolved above)
            let fg_theme = options
                .fg
                .as_ref()
                .and_then(|c| c.as_theme_key())
                .filter(|key| named_color_from_str(key).is_none())
                .map(String::from);
            let bg_theme = options
                .bg
                .as_ref()
                .and_then(|c| c.as_theme_key())
                .filter(|key| named_color_from_str(key).is_none())
                .map(String::from);

            // If theme keys are provided, use ThemedStyle for runtime resolution
            if fg_theme.is_some() || bg_theme.is_some() {
                OverlayFace::ThemedStyle {
                    fallback_style: style,
                    fg_theme,
                    bg_theme,
                    fg_on_collision_only: options.fg_on_collision_only,
                }
            } else {
                OverlayFace::Style { style }
            }
        }
    }
}

/// Convert popup data to the actual popup object.
///
/// `popup_bg` and `popup_border_fg` come from the active theme (the
/// caller resolves them from `Theme::popup_bg` / `Theme::popup_border_fg`,
/// which are already `ratatui::style::Color`). The replay path inside
/// `EditorState::apply` has no theme handle and falls back to theme defaults.
pub(crate) fn convert_popup_data_to_popup(
    data: &PopupData,
    popup_bg: Color,
    popup_border_fg: Color,
) -> Popup {
    let content = match &data.content {
        crate::model::event::PopupContentData::Text(lines) => PopupContent::Text(lines.clone()),
        crate::model::event::PopupContentData::List { items, selected } => PopupContent::List {
            items: items
                .iter()
                .map(|item| PopupListItem {
                    text: item.text.clone(),
                    detail: item.detail.clone(),
                    icon: item.icon.clone(),
                    data: item.data.clone(),
                    disabled: false,
                })
                .collect(),
            selected: *selected,
        },
    };

    let position = match data.position {
        PopupPositionData::AtCursor => PopupPosition::AtCursor,
        PopupPositionData::BelowCursor => PopupPosition::BelowCursor,
        PopupPositionData::AboveCursor => PopupPosition::AboveCursor,
        PopupPositionData::Fixed { x, y } => PopupPosition::Fixed { x, y },
        PopupPositionData::Centered => PopupPosition::Centered,
        PopupPositionData::BottomRight => PopupPosition::BottomRight,
        PopupPositionData::AboveStatusBarAt { x, status_row } => {
            PopupPosition::AboveStatusBarAt { x, status_row }
        }
    };

    // Map the explicit kind hint to PopupKind for input handling
    let kind = match data.kind {
        crate::model::event::PopupKindHint::Completion => PopupKind::Completion,
        crate::model::event::PopupKindHint::List => PopupKind::List,
        crate::model::event::PopupKindHint::Text => PopupKind::Text,
    };

    // Kind-implied resolver default: a popup whose kind is
    // `Completion` always confirms by inserting the selected
    // completion, regardless of who built it. Other kinds need an
    // explicit resolver (LSP confirm, plugin action, LSP status, code
    // action) because the same `List` kind is used for all four, so we
    // can't infer which feature owns the popup from its kind alone.
    let resolver = match kind {
        PopupKind::Completion => crate::view::popup::PopupResolver::Completion,
        _ => crate::view::popup::PopupResolver::None,
    };

    // Popups that appear under the user's cursor without an explicit
    // user gesture default to *unfocused* so the next keystroke drives
    // the buffer rather than the popup. The user grabs focus with
    // `popup_focus` (default `Alt+T`). Popups that the user
    // *explicitly* invokes (completion via Tab, list/action choosers
    // via plugin commands, …) keep the historical focused-on-show
    // behavior — type-to-filter and arrow-nav need to "just work"
    // without an extra keystroke.
    // Only `PopupKindHint` variants reach this path — Completion, List,
    // Text. Hover/Action popups are constructed directly in editor code
    // (`lsp_requests.rs`) and set their own `focused` flag.
    let focused = match kind {
        // Completion popups are user-invoked (type-to-trigger / explicit
        // `lsp_completion`), so type-to-filter and arrow-nav need to
        // "just work" without the user pressing the focus-popup key.
        PopupKind::Completion => true,
        // List popups are typically explicit user invocations (plugin
        // action popups, status-bar menus). Keep them focused on show.
        PopupKind::List => true,
        // Text popups are auto-shown informational overlays —
        // unfocused so they don't swallow the user's next keystroke.
        PopupKind::Text => false,
        // Direct-construction kinds (Hover, Action) are not produced by
        // `Event::ShowPopup`; default to unfocused if ever reached so
        // an auto-shown overlay doesn't grab the keyboard by accident.
        PopupKind::Hover | PopupKind::Action => false,
    };

    Popup {
        kind,
        title: data.title.clone(),
        description: data.description.clone(),
        transient: data.transient,
        content,
        position,
        width: data.width,
        max_height: data.max_height,
        bordered: data.bordered,
        border_style: Style::default().fg(popup_border_fg),
        background_style: Style::default().bg(popup_bg),
        scroll_offset: 0,
        text_selection: None,
        accept_key_hint: None,
        resolver,
        focused,
        focus_key_hint: None,
    }
}

/// Convert margin position data to the actual margin position
fn convert_margin_position(position: &MarginPositionData) -> MarginPosition {
    match position {
        MarginPositionData::Left => MarginPosition::Left,
        MarginPositionData::Right => MarginPosition::Right,
    }
}

/// Convert margin content data to the actual margin content
fn convert_margin_content(content: &MarginContentData) -> MarginContent {
    match content {
        MarginContentData::Text(text) => MarginContent::Text(text.clone()),
        MarginContentData::Symbol { text, color } => {
            if let Some((r, g, b)) = color {
                MarginContent::colored_symbol(text.clone(), Color::Rgb(*r, *g, *b))
            } else {
                MarginContent::symbol(text.clone(), Style::default())
            }
        }
        MarginContentData::Empty => MarginContent::Empty,
    }
}

impl EditorState {
    /// Prepare viewport for rendering (called before frame render)
    ///
    /// This pre-loads all data that will be needed for rendering the current viewport,
    /// ensuring that subsequent read-only access during rendering will succeed.
    ///
    /// Takes viewport parameters since viewport is now owned by SplitViewState.
    pub fn prepare_for_render(&mut self, top_byte: usize, height: u16) -> Result<()> {
        self.buffer.prepare_viewport(top_byte, height as usize)?;
        Ok(())
    }

    /// Resolve all plugin-injected virtual-line anchor byte positions
    /// for this buffer.  Sorted ascending.
    ///
    /// Used by `Viewport::scroll_down` / `scroll_up` /
    /// `find_max_visual_scroll_position` so the scroll math counts the
    /// rows the renderer actually draws (e.g. markdown_compose's
    /// `┌─┬─┐` table borders) when computing `max_scroll_row`.  Without
    /// this, mouse wheel and PageDown clamp to a row count that
    /// ignores virtual lines and stop short of the buffer's real tail.
    ///
    /// Empty when no plugin has added virtual lines.
    pub fn collect_virtual_line_positions(&self) -> Vec<usize> {
        if self.virtual_texts.is_empty() {
            return Vec::new();
        }
        let mut v: Vec<usize> = self
            .virtual_texts
            .query_lines_in_range(&self.marker_list, 0, self.buffer.len() + 1)
            .into_iter()
            .map(|(pos, _vt)| pos)
            .collect();
        v.sort_unstable();
        v
    }

    /// Resolve all plugin-injected soft-break `(byte_position, indent)`
    /// pairs for this buffer.
    ///
    /// Returns a sorted slice suitable for passing to `Viewport::scroll_up` /
    /// `scroll_down`, which use it to keep their visual-row counting in
    /// lock-step with the renderer (which applies these same breaks via
    /// `apply_soft_breaks`).  The `indent` field is the column count of
    /// hanging-indent spaces the plugin asked the renderer to inject
    /// after the break — the wrap counter needs it to compute the
    /// continuation segment's effective width correctly.
    ///
    /// Empty when no plugin is wrapping the buffer.
    pub fn collect_soft_break_positions(&self) -> Vec<(usize, u16)> {
        if self.soft_breaks.is_empty() {
            return Vec::new();
        }
        // query_viewport already returns pairs sorted by ascending position.
        // Cursor-blind: scroll math consumers see the canonical "no cursor
        // anywhere" break set, consistent with `WrapIndex`.
        self.soft_breaks
            .query_viewport(0, self.buffer.len() + 1, &self.marker_list, &[])
    }

    // ========== DocumentModel Helper Methods ==========
    // These methods provide convenient access to DocumentModel functionality
    // while maintaining backward compatibility with existing code.

    /// Get text in a range, driving lazy loading transparently
    ///
    /// This is a convenience wrapper around DocumentModel::get_range that:
    /// - Drives lazy loading automatically (never fails due to unloaded data)
    /// - Uses byte offsets directly
    /// - Returns String (not Result) - errors are logged internally
    /// - Returns empty string for invalid ranges
    ///
    /// This is the preferred API for getting text ranges. The caller never needs
    /// to worry about lazy loading or buffer preparation.
    ///
    /// # Example
    /// ```ignore
    /// let text = state.get_text_range(0, 100);
    /// ```
    pub fn get_text_range(&mut self, start: usize, end: usize) -> String {
        // TextBuffer::get_text_range_mut() handles lazy loading automatically
        match self
            .buffer
            .get_text_range_mut(start, end.saturating_sub(start))
        {
            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
            Err(e) => {
                tracing::warn!("Failed to get text range {}..{}: {}", start, end, e);
                String::new()
            }
        }
    }

    /// Get the content of a line by its byte offset
    ///
    /// Returns the line containing the given offset, along with its start position.
    /// This uses DocumentModel's viewport functionality for consistent behavior.
    ///
    /// # Returns
    /// `Some((line_start_offset, line_content))` if successful, `None` if offset is invalid
    pub fn get_line_at_offset(&mut self, offset: usize) -> Option<(usize, String)> {
        use crate::model::document_model::DocumentModel;

        // Find the start of the line containing this offset
        // Scan backwards to find the previous newline or start of buffer
        let mut line_start = offset;
        while line_start > 0 {
            if let Ok(text) = self.buffer.get_text_range_mut(line_start - 1, 1) {
                if text.first() == Some(&b'\n') {
                    break;
                }
                line_start -= 1;
            } else {
                break;
            }
        }

        // Get a single line viewport starting at the line start
        let viewport = self
            .get_viewport_content(
                crate::model::document_model::DocumentPosition::byte(line_start),
                1,
            )
            .ok()?;

        viewport
            .lines
            .first()
            .map(|line| (line.byte_offset, line.content.clone()))
    }

    /// Get text from current cursor position to end of line
    ///
    /// This is a common pattern in editing operations. Uses DocumentModel
    /// for consistent behavior across file sizes.
    pub fn get_text_to_end_of_line(&mut self, cursor_pos: usize) -> Result<String> {
        use crate::model::document_model::DocumentModel;

        // Get the line containing cursor
        let viewport = self.get_viewport_content(
            crate::model::document_model::DocumentPosition::byte(cursor_pos),
            1,
        )?;

        if let Some(line) = viewport.lines.first() {
            let line_start = line.byte_offset;
            let line_end = line_start + line.content.len();

            if cursor_pos >= line_start && cursor_pos <= line_end {
                let offset_in_line = cursor_pos - line_start;
                // Use get() to safely handle potential non-char-boundary offsets
                Ok(line.content.get(offset_in_line..).unwrap_or("").to_string())
            } else {
                Ok(String::new())
            }
        } else {
            Ok(String::new())
        }
    }

    /// Replace cached semantic tokens with a new store.
    pub fn set_semantic_tokens(&mut self, store: SemanticTokenStore) {
        self.semantic_tokens = Some(store);
    }

    /// Clear cached semantic tokens (e.g., when tokens are invalidated).
    pub fn clear_semantic_tokens(&mut self) {
        self.semantic_tokens = None;
    }

    /// Get the server-provided semantic token result_id if available.
    pub fn semantic_tokens_result_id(&self) -> Option<&str> {
        self.semantic_tokens
            .as_ref()
            .and_then(|store| store.result_id.as_deref())
    }
}

/// Implement DocumentModel trait for EditorState
///
/// This provides a clean abstraction layer between rendering/editing operations
/// and the underlying text buffer implementation.
impl DocumentModel for EditorState {
    fn capabilities(&self) -> DocumentCapabilities {
        let line_count = self.buffer.line_count();
        DocumentCapabilities {
            has_line_index: line_count.is_some(),
            uses_lazy_loading: false, // TODO: add large file detection
            byte_length: self.buffer.len(),
            approximate_line_count: line_count.unwrap_or_else(|| {
                // Estimate assuming ~80 bytes per line
                self.buffer.len() / 80
            }),
        }
    }

    fn get_viewport_content(
        &mut self,
        start_pos: DocumentPosition,
        max_lines: usize,
    ) -> Result<ViewportContent> {
        // Convert to byte offset
        let start_offset = self.position_to_offset(start_pos)?;

        // Use new efficient line iteration that tracks line numbers during iteration
        // by accumulating line_feed_cnt from pieces (single source of truth)
        let line_iter = self.buffer.iter_lines_from(start_offset, max_lines)?;
        let has_more = line_iter.has_more;

        let lines = line_iter
            .map(|line_data| ViewportLine {
                byte_offset: line_data.byte_offset,
                content: line_data.content,
                has_newline: line_data.has_newline,
                approximate_line_number: line_data.line_number,
            })
            .collect();

        Ok(ViewportContent {
            start_position: DocumentPosition::ByteOffset(start_offset),
            lines,
            has_more,
        })
    }

    fn position_to_offset(&self, pos: DocumentPosition) -> Result<usize> {
        match pos {
            DocumentPosition::ByteOffset(offset) => Ok(offset),
            DocumentPosition::LineColumn { line, column } => {
                if !self.has_line_index() {
                    anyhow::bail!("Line indexing not available for this document");
                }
                // Use piece tree's position conversion
                let position = crate::model::piece_tree::Position { line, column };
                Ok(self.buffer.position_to_offset(position))
            }
        }
    }

    fn offset_to_position(&self, offset: usize) -> DocumentPosition {
        if self.has_line_index() {
            if let Some(pos) = self.buffer.offset_to_position(offset) {
                DocumentPosition::LineColumn {
                    line: pos.line,
                    column: pos.column,
                }
            } else {
                // Line index exists but metadata unavailable - fall back to byte offset
                DocumentPosition::ByteOffset(offset)
            }
        } else {
            DocumentPosition::ByteOffset(offset)
        }
    }

    fn get_range(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<String> {
        let start_offset = self.position_to_offset(start)?;
        let end_offset = self.position_to_offset(end)?;

        if start_offset > end_offset {
            anyhow::bail!(
                "Invalid range: start offset {} > end offset {}",
                start_offset,
                end_offset
            );
        }

        let bytes = self
            .buffer
            .get_text_range_mut(start_offset, end_offset - start_offset)?;

        Ok(String::from_utf8_lossy(&bytes).into_owned())
    }

    fn get_line_content(&mut self, line_number: usize) -> Option<String> {
        if !self.has_line_index() {
            return None;
        }

        // Convert line number to byte offset
        let line_start_offset = self.buffer.line_start_offset(line_number)?;

        // Get line content using iterator
        let mut iter = self.buffer.line_iterator(line_start_offset, 80);
        if let Some((_start, content)) = iter.next_line() {
            let has_newline = content.ends_with('\n');
            let line_content = if has_newline {
                content[..content.len() - 1].to_string()
            } else {
                content
            };
            Some(line_content)
        } else {
            None
        }
    }

    fn get_chunk_at_offset(&mut self, offset: usize, size: usize) -> Result<(usize, String)> {
        let bytes = self.buffer.get_text_range_mut(offset, size)?;

        Ok((offset, String::from_utf8_lossy(&bytes).into_owned()))
    }

    fn insert(&mut self, pos: DocumentPosition, text: &str) -> Result<usize> {
        let offset = self.position_to_offset(pos)?;
        self.buffer.insert_bytes(offset, text.as_bytes().to_vec());
        Ok(text.len())
    }

    fn delete(&mut self, start: DocumentPosition, end: DocumentPosition) -> Result<()> {
        let start_offset = self.position_to_offset(start)?;
        let end_offset = self.position_to_offset(end)?;

        if start_offset > end_offset {
            anyhow::bail!(
                "Invalid range: start offset {} > end offset {}",
                start_offset,
                end_offset
            );
        }

        self.buffer.delete(start_offset..end_offset);
        Ok(())
    }

    fn replace(
        &mut self,
        start: DocumentPosition,
        end: DocumentPosition,
        text: &str,
    ) -> Result<()> {
        // Delete then insert
        self.delete(start, end)?;
        self.insert(start, text)?;
        Ok(())
    }

    fn find_matches(
        &mut self,
        pattern: &str,
        search_range: Option<(DocumentPosition, DocumentPosition)>,
    ) -> Result<Vec<usize>> {
        let (start_offset, end_offset) = if let Some((start, end)) = search_range {
            (
                self.position_to_offset(start)?,
                self.position_to_offset(end)?,
            )
        } else {
            (0, self.buffer.len())
        };

        // Get text in range
        let bytes = self
            .buffer
            .get_text_range_mut(start_offset, end_offset - start_offset)?;
        let text = String::from_utf8_lossy(&bytes);

        // Find all matches (simple substring search for now)
        let mut matches = Vec::new();
        let mut search_offset = 0;
        while let Some(pos) = text[search_offset..].find(pattern) {
            matches.push(start_offset + search_offset + pos);
            search_offset += pos + pattern.len();
        }

        Ok(matches)
    }
}

/// Cached semantic tokens for a buffer.
#[derive(Clone, Debug)]
pub struct SemanticTokenStore {
    /// Buffer version the tokens correspond to.
    pub version: u64,
    /// Server-provided result identifier (if any).
    pub result_id: Option<String>,
    /// Raw semantic token data (u32 array, 5 integers per token).
    pub data: Vec<u32>,
    /// All semantic token spans resolved to byte ranges.
    pub tokens: Vec<SemanticTokenSpan>,
}

/// A semantic token span resolved to buffer byte offsets.
#[derive(Clone, Debug)]
pub struct SemanticTokenSpan {
    pub range: Range<usize>,
    pub token_type: String,
    pub modifiers: Vec<String>,
}

#[cfg(test)]
mod tests {
    use crate::model::filesystem::StdFileSystem;
    use std::sync::Arc;

    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
        Arc::new(StdFileSystem)
    }
    use super::*;
    use crate::model::event::CursorId;

    #[test]
    fn test_state_new() {
        let state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        assert!(state.buffer.is_empty());
    }

    #[test]
    fn test_apply_insert() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "hello".to_string(),
                cursor_id,
            },
        );

        assert_eq!(state.buffer.to_string().unwrap(), "hello");
        assert_eq!(cursors.primary().position, 5);
        assert!(state.buffer.is_modified());
    }

    #[test]
    fn test_apply_delete() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        // Insert then delete
        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "hello world".to_string(),
                cursor_id,
            },
        );

        state.apply(
            &mut cursors,
            &Event::Delete {
                range: 5..11,
                deleted_text: " world".to_string(),
                cursor_id,
            },
        );

        assert_eq!(state.buffer.to_string().unwrap(), "hello");
        assert_eq!(cursors.primary().position, 5);
    }

    /// Inserting or deleting moves the cursor horizontally, so it must drop
    /// the vertical-navigation goal column — a stale goal would send the
    /// next Up/Down to the pre-edit column.
    #[test]
    fn test_edits_reset_goal_column() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "hello".to_string(),
                cursor_id,
            },
        );

        // Simulate a vertical move that pinned a goal column.
        cursors.primary_mut().sticky_column = Some(42);
        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 5,
                text: "!".to_string(),
                cursor_id,
            },
        );
        assert_eq!(cursors.primary().sticky_column, None, "insert resets goal");

        cursors.primary_mut().sticky_column = Some(42);
        state.apply(
            &mut cursors,
            &Event::Delete {
                range: 5..6,
                deleted_text: "!".to_string(),
                cursor_id,
            },
        );
        assert_eq!(cursors.primary().sticky_column, None, "delete resets goal");
    }

    #[test]
    fn coord_map_remaps_through_real_edits() {
        // Verifies the marker-adjustment chokepoint actually feeds the
        // coordinate ring: after real apply_insert / apply_delete, a coordinate
        // captured at an older version maps forward to its current location.
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        // Seed: "line0\nTARGET\n" — TARGET begins at byte 6.
        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "line0\nTARGET\n".to_string(),
                cursor_id,
            },
        );
        let v0 = state.buffer.version();
        let target_at_v0 = 6usize;

        // No edits since v0: identity, and a None epoch is always verbatim.
        assert_eq!(
            state.map_plugin_coord(target_at_v0, Some(v0)),
            Some(target_at_v0)
        );
        assert_eq!(
            state.map_plugin_coord(target_at_v0, None),
            Some(target_at_v0)
        );

        // Insert two lines above the target — it must shift right by 4 bytes.
        let above = "X\nY\n";
        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: above.to_string(),
                cursor_id,
            },
        );
        let v1 = state.buffer.version();
        assert!(v1 > v0);
        assert_eq!(
            state.map_plugin_coord(target_at_v0, Some(v0)),
            Some(target_at_v0 + above.len()),
            "a coord captured at v0 must map forward past the later insert"
        );
        // From the current version there is nothing newer to replay.
        assert_eq!(
            state.map_plugin_coord(target_at_v0 + above.len(), Some(v1)),
            Some(target_at_v0 + above.len()),
        );

        // Delete those lines again — the target shifts back left.
        state.apply(
            &mut cursors,
            &Event::Delete {
                range: 0..above.len(),
                deleted_text: above.to_string(),
                cursor_id,
            },
        );
        let v2 = state.buffer.version();
        assert!(v2 > v1);
        // Insert + delete net to zero above the target: a v0 coord is unchanged.
        assert_eq!(
            state.map_plugin_coord(target_at_v0, Some(v0)),
            Some(target_at_v0)
        );
        // From v1, only the delete applies.
        assert_eq!(
            state.map_plugin_coord(target_at_v0 + above.len(), Some(v1)),
            Some(target_at_v0),
        );
    }

    #[test]
    fn test_apply_move_cursor() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "hello".to_string(),
                cursor_id,
            },
        );

        state.apply(
            &mut cursors,
            &Event::MoveCursor {
                cursor_id,
                old_position: 5,
                new_position: 2,
                old_anchor: None,
                new_anchor: None,
                old_sticky_column: None,
                new_sticky_column: None,
            },
        );

        assert_eq!(cursors.primary().position, 2);
    }

    #[test]
    fn test_apply_add_cursor() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = CursorId(1);

        state.apply(
            &mut cursors,
            &Event::AddCursor {
                cursor_id,
                position: 5,
                anchor: None,
            },
        );

        assert_eq!(cursors.count(), 2);
    }

    #[test]
    fn test_apply_many() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        let events = vec![
            Event::Insert {
                position: 0,
                text: "hello ".to_string(),
                cursor_id,
            },
            Event::Insert {
                position: 6,
                text: "world".to_string(),
                cursor_id,
            },
        ];

        state.apply_many(&mut cursors, &events);

        assert_eq!(state.buffer.to_string().unwrap(), "hello world");
    }

    #[test]
    fn test_cursor_adjustment_after_insert() {
        let mut state = EditorState::new(
            80,
            24,
            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
            test_fs(),
        );
        let mut cursors = Cursors::new();
        let cursor_id = cursors.primary_id();

        // Add a second cursor at position 5
        state.apply(
            &mut cursors,
            &Event::AddCursor {
                cursor_id: CursorId(1),
                position: 5,
                anchor: None,
            },
        );

        // Insert at position 0 - should push second cursor forward
        state.apply(
            &mut cursors,
            &Event::Insert {
                position: 0,
                text: "abc".to_string(),
                cursor_id,
            },
        );

        // Second cursor should be at position 5 + 3 = 8
        if let Some(cursor) = cursors.get(CursorId(1)) {
            assert_eq!(cursor.position, 8);
        }
    }

    // DocumentModel trait tests
    mod document_model_tests {
        use super::*;
        use crate::model::document_model::{DocumentModel, DocumentPosition};

        #[test]
        fn test_capabilities_small_file() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("line1\nline2\nline3");

            let caps = state.capabilities();
            assert!(caps.has_line_index, "Small file should have line index");
            assert_eq!(caps.byte_length, "line1\nline2\nline3".len());
            assert_eq!(caps.approximate_line_count, 3, "Should have 3 lines");
        }

        #[test]
        fn test_position_conversions() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello\nworld\ntest");

            // Test ByteOffset -> offset
            let pos1 = DocumentPosition::ByteOffset(6);
            let offset1 = state.position_to_offset(pos1).unwrap();
            assert_eq!(offset1, 6);

            // Test LineColumn -> offset
            let pos2 = DocumentPosition::LineColumn { line: 1, column: 0 };
            let offset2 = state.position_to_offset(pos2).unwrap();
            assert_eq!(offset2, 6, "Line 1, column 0 should be at byte 6");

            // Test offset -> position (should return LineColumn for small files)
            let converted = state.offset_to_position(6);
            match converted {
                DocumentPosition::LineColumn { line, column } => {
                    assert_eq!(line, 1);
                    assert_eq!(column, 0);
                }
                _ => panic!("Expected LineColumn for small file"),
            }
        }

        #[test]
        fn test_get_viewport_content() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");

            let content = state
                .get_viewport_content(DocumentPosition::ByteOffset(0), 3)
                .unwrap();

            assert_eq!(content.lines.len(), 3);
            assert_eq!(content.lines[0].content, "line1");
            assert_eq!(content.lines[1].content, "line2");
            assert_eq!(content.lines[2].content, "line3");
            assert!(content.has_more);
        }

        #[test]
        fn test_get_range() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            let text = state
                .get_range(
                    DocumentPosition::ByteOffset(0),
                    DocumentPosition::ByteOffset(5),
                )
                .unwrap();
            assert_eq!(text, "hello");

            let text2 = state
                .get_range(
                    DocumentPosition::ByteOffset(6),
                    DocumentPosition::ByteOffset(11),
                )
                .unwrap();
            assert_eq!(text2, "world");
        }

        #[test]
        fn test_get_line_content() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("line1\nline2\nline3");

            let line0 = state.get_line_content(0).unwrap();
            assert_eq!(line0, "line1");

            let line1 = state.get_line_content(1).unwrap();
            assert_eq!(line1, "line2");

            let line2 = state.get_line_content(2).unwrap();
            assert_eq!(line2, "line3");
        }

        #[test]
        fn test_insert_delete() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            // Insert text
            let bytes_inserted = state
                .insert(DocumentPosition::ByteOffset(6), "beautiful ")
                .unwrap();
            assert_eq!(bytes_inserted, 10);
            assert_eq!(state.buffer.to_string().unwrap(), "hello beautiful world");

            // Delete text
            state
                .delete(
                    DocumentPosition::ByteOffset(6),
                    DocumentPosition::ByteOffset(16),
                )
                .unwrap();
            assert_eq!(state.buffer.to_string().unwrap(), "hello world");
        }

        #[test]
        fn test_replace() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            state
                .replace(
                    DocumentPosition::ByteOffset(0),
                    DocumentPosition::ByteOffset(5),
                    "hi",
                )
                .unwrap();
            assert_eq!(state.buffer.to_string().unwrap(), "hi world");
        }

        #[test]
        fn test_find_matches() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world hello");

            let matches = state.find_matches("hello", None).unwrap();
            assert_eq!(matches.len(), 2);
            assert_eq!(matches[0], 0);
            assert_eq!(matches[1], 12);
        }

        #[test]
        fn test_prepare_for_render() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5");

            // Should not panic - pass top_byte=0 and height=24 (typical viewport params)
            state.prepare_for_render(0, 24).unwrap();
        }

        #[test]
        fn test_helper_get_text_range() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            // Test normal range
            let text = state.get_text_range(0, 5);
            assert_eq!(text, "hello");

            // Test middle range
            let text2 = state.get_text_range(6, 11);
            assert_eq!(text2, "world");
        }

        #[test]
        fn test_helper_get_line_at_offset() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("line1\nline2\nline3");

            // Get first line (offset 0)
            let (offset, content) = state.get_line_at_offset(0).unwrap();
            assert_eq!(offset, 0);
            assert_eq!(content, "line1");

            // Get second line (offset in middle of line)
            let (offset2, content2) = state.get_line_at_offset(8).unwrap();
            assert_eq!(offset2, 6); // Line starts at byte 6
            assert_eq!(content2, "line2");

            // Get last line
            let (offset3, content3) = state.get_line_at_offset(12).unwrap();
            assert_eq!(offset3, 12);
            assert_eq!(content3, "line3");
        }

        #[test]
        fn test_helper_get_text_to_end_of_line() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world\nline2");

            // From beginning of line
            let text = state.get_text_to_end_of_line(0).unwrap();
            assert_eq!(text, "hello world");

            // From middle of line
            let text2 = state.get_text_to_end_of_line(6).unwrap();
            assert_eq!(text2, "world");

            // From end of line
            let text3 = state.get_text_to_end_of_line(11).unwrap();
            assert_eq!(text3, "");

            // From second line
            let text4 = state.get_text_to_end_of_line(12).unwrap();
            assert_eq!(text4, "line2");
        }
    }

    // Virtual text integration tests
    mod virtual_text_integration_tests {
        use super::*;
        use crate::view::virtual_text::VirtualTextPosition;
        use ratatui::style::Style;

        #[test]
        fn test_virtual_text_add_and_query() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            // Initialize marker list for buffer
            if !state.buffer.is_empty() {
                state.marker_list.adjust_for_insert(0, state.buffer.len());
            }

            // Add virtual text at position 5 (after 'hello')
            let vtext_id = state.virtual_texts.add(
                &mut state.marker_list,
                5,
                ": string".to_string(),
                Style::default(),
                VirtualTextPosition::AfterChar,
                0,
            );

            // Query should return the virtual text
            let results = state.virtual_texts.query_range(&state.marker_list, 0, 11);
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].0, 5); // Position
            assert_eq!(results[0].1.text, ": string");

            // Build lookup should work
            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 11);
            assert!(lookup.contains_key(&5));
            assert_eq!(lookup[&5].len(), 1);
            assert_eq!(lookup[&5][0].text, ": string");

            // Clean up
            state.virtual_texts.remove(&mut state.marker_list, vtext_id);
            assert!(state.virtual_texts.is_empty());
        }

        #[test]
        fn test_virtual_text_position_tracking_on_insert() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello world");

            // Initialize marker list for buffer
            if !state.buffer.is_empty() {
                state.marker_list.adjust_for_insert(0, state.buffer.len());
            }

            // Add virtual text at position 6 (the 'w' in 'world')
            let _vtext_id = state.virtual_texts.add(
                &mut state.marker_list,
                6,
                "/*param*/".to_string(),
                Style::default(),
                VirtualTextPosition::BeforeChar,
                0,
            );

            // Insert "beautiful " at position 6 using Event
            let mut cursors = Cursors::new();
            let cursor_id = cursors.primary_id();
            state.apply(
                &mut cursors,
                &Event::Insert {
                    position: 6,
                    text: "beautiful ".to_string(),
                    cursor_id,
                },
            );

            // Virtual text should now be at position 16 (6 + 10)
            let results = state.virtual_texts.query_range(&state.marker_list, 0, 30);
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].0, 16); // Position should have moved
            assert_eq!(results[0].1.text, "/*param*/");
        }

        #[test]
        fn test_virtual_text_position_tracking_on_delete() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("hello beautiful world");

            // Initialize marker list for buffer
            if !state.buffer.is_empty() {
                state.marker_list.adjust_for_insert(0, state.buffer.len());
            }

            // Add virtual text at position 16 (the 'w' in 'world')
            let _vtext_id = state.virtual_texts.add(
                &mut state.marker_list,
                16,
                ": string".to_string(),
                Style::default(),
                VirtualTextPosition::AfterChar,
                0,
            );

            // Delete "beautiful " (positions 6-16) using Event
            let mut cursors = Cursors::new();
            let cursor_id = cursors.primary_id();
            state.apply(
                &mut cursors,
                &Event::Delete {
                    range: 6..16,
                    deleted_text: "beautiful ".to_string(),
                    cursor_id,
                },
            );

            // Virtual text should now be at position 6
            let results = state.virtual_texts.query_range(&state.marker_list, 0, 20);
            assert_eq!(results.len(), 1);
            assert_eq!(results[0].0, 6); // Position should have moved back
            assert_eq!(results[0].1.text, ": string");
        }

        #[test]
        fn test_multiple_virtual_texts_with_priorities() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("let x = 5");

            // Initialize marker list for buffer
            if !state.buffer.is_empty() {
                state.marker_list.adjust_for_insert(0, state.buffer.len());
            }

            // Add type hint after 'x' (position 5)
            state.virtual_texts.add(
                &mut state.marker_list,
                5,
                ": i32".to_string(),
                Style::default(),
                VirtualTextPosition::AfterChar,
                0, // Lower priority - renders first
            );

            // Add another hint at same position with higher priority
            state.virtual_texts.add(
                &mut state.marker_list,
                5,
                " /* inferred */".to_string(),
                Style::default(),
                VirtualTextPosition::AfterChar,
                10, // Higher priority - renders second
            );

            // Build lookup - should have both, sorted by priority (lower first)
            let lookup = state.virtual_texts.build_lookup(&state.marker_list, 0, 10);
            assert!(lookup.contains_key(&5));
            let vtexts = &lookup[&5];
            assert_eq!(vtexts.len(), 2);
            // Lower priority first (like layer ordering)
            assert_eq!(vtexts[0].text, ": i32");
            assert_eq!(vtexts[1].text, " /* inferred */");
        }

        #[test]
        fn test_virtual_text_clear() {
            let mut state = EditorState::new(
                80,
                24,
                crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
                test_fs(),
            );
            state.buffer = Buffer::from_str_test("test");

            // Initialize marker list for buffer
            if !state.buffer.is_empty() {
                state.marker_list.adjust_for_insert(0, state.buffer.len());
            }

            // Add multiple virtual texts
            state.virtual_texts.add(
                &mut state.marker_list,
                0,
                "hint1".to_string(),
                Style::default(),
                VirtualTextPosition::BeforeChar,
                0,
            );
            state.virtual_texts.add(
                &mut state.marker_list,
                2,
                "hint2".to_string(),
                Style::default(),
                VirtualTextPosition::AfterChar,
                0,
            );

            assert_eq!(state.virtual_texts.len(), 2);

            // Clear all
            state.virtual_texts.clear(&mut state.marker_list);
            assert!(state.virtual_texts.is_empty());

            // Query should return nothing
            let results = state.virtual_texts.query_range(&state.marker_list, 0, 10);
            assert!(results.is_empty());
        }
    }
}
