//! Renderer-agnostic syntax-highlighting pipeline for the hjkl editor stack.
//!
//! Fully synchronous: parse and highlight run on the main thread.
//! Call [`SyntaxLayer::set_language_for_path`] after opening a file,
//! [`SyntaxLayer::apply_edits`] after each batch of [`hjkl_engine::ContentEdit`]s,
//! and [`SyntaxLayer::render_viewport`] to get styled spans for the visible rows.
//!
//! Output is renderer-agnostic: [`RenderOutput::spans`] carries
//! `(byte_start, byte_end, [`StyleSpec`])` triples.
//! A TUI adapter ([`hjkl-syntax-tui`]) maps these to `ratatui::style::Style`.

use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;

use hjkl_bonsai::runtime::{Grammar, LoadHandle};
use hjkl_bonsai::{
    CommentMarkerPass, DotFallbackTheme, HEX_BG_KEY, HEX_COLOR_CAPTURE, HEX_FG_KEY, HexColorPass,
    Highlighter, InjectedFoldCache, InputEdit, MetaValue, Point, RAINBOW_BRACKET_CAPTURE,
    RAINBOW_DEPTH_KEY, Theme, extract_fold_ranges_rope_with_injections, rainbow_spans_rope,
};
use hjkl_engine::Query;
use hjkl_lang::{GrammarRequest, LanguageDirectory};

pub use hjkl_theme::{Color, Modifiers, StyleSpec};

/// Stable identifier for an open buffer.
///
/// # Examples
///
/// ```
/// use hjkl_syntax::BufferId;
/// let id: BufferId = 42;
/// assert_eq!(id, 42);
/// ```
pub use hjkl_buffer::BufferId;

// ---------------------------------------------------------------------------
// Public output types
// ---------------------------------------------------------------------------

/// A single diagnostic sign emitted from the syntax pipeline.
///
/// # Examples
///
/// ```
/// use hjkl_syntax::DiagSign;
/// let s = DiagSign::new(3, 'E', 100);
/// assert_eq!(s.row, 3);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DiagSign {
    /// Document row (0-indexed).
    pub row: usize,
    /// Gutter character (e.g. `'E'` for a syntax error).
    pub ch: char,
    /// Gutter priority — higher wins when multiple signs land on the same row.
    pub priority: u8,
}

impl Default for DiagSign {
    fn default() -> Self {
        Self {
            row: 0,
            ch: 'E',
            priority: 0,
        }
    }
}

impl DiagSign {
    /// Create a new diagnostic sign.
    ///
    /// # Examples
    ///
    /// ```
    /// use hjkl_syntax::DiagSign;
    /// let s = DiagSign::new(1, 'E', 100);
    /// assert_eq!(s.row, 1);
    /// ```
    pub fn new(row: usize, ch: char, priority: u8) -> Self {
        Self { row, ch, priority }
    }
}

/// Per-call sub-step timings. Kept for API compat (PerfBreakdown is re-exported
/// in the TUI shim and referenced from `:perf` overlay code).
///
/// # Examples
///
/// ```
/// use hjkl_syntax::PerfBreakdown;
/// let p = PerfBreakdown::default();
/// assert_eq!(p.parse_us, 0);
/// ```
#[derive(Default, Debug, Clone, Copy)]
#[non_exhaustive]
pub struct PerfBreakdown {
    /// Microseconds spent building the source string + row_starts table.
    pub source_build_us: u128,
    /// Microseconds spent in `tree_sitter::Parser::parse`.
    pub parse_us: u128,
    /// Microseconds spent in `hjkl_bonsai::Highlighter::highlight_range_*`.
    pub highlight_us: u128,
    /// Microseconds spent building the per-row span table from flat spans.
    pub by_row_us: u128,
    /// Microseconds spent scanning for diagnostic ERROR/MISSING nodes.
    pub diag_us: u128,
}

impl PerfBreakdown {
    /// Construct a zeroed breakdown.
    ///
    /// # Examples
    ///
    /// ```
    /// use hjkl_syntax::PerfBreakdown;
    /// let p = PerfBreakdown::new();
    /// assert_eq!(p.highlight_us, 0);
    /// ```
    pub fn new() -> Self {
        Self::default()
    }
}

/// Per-frame output of the syntax pipeline.
///
/// Contains the styled span table (one inner `Vec` per document row) and the
/// diagnostic signs for the gutter.
///
/// # Examples
///
/// ```
/// use hjkl_syntax::{RenderOutput, PerfBreakdown};
/// let out = RenderOutput::new(0, Vec::new(), Vec::new(), (0, 0, 0), PerfBreakdown::default());
/// assert_eq!(out.buffer_id, 0);
/// ```
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RenderOutput {
    /// Routes spans/signs back to the matching buffer slot.
    pub buffer_id: BufferId,
    /// Per-row span table.
    pub spans: Vec<Vec<(usize, usize, StyleSpec)>>,
    /// Diagnostic signs for the gutter.
    pub signs: Vec<DiagSign>,
    /// `(dirty_gen, viewport_top, viewport_height)` cache key.
    pub key: (u64, usize, usize),
    /// Sub-step timing breakdown (zeroed in fully-sync path).
    pub perf: PerfBreakdown,
}

impl RenderOutput {
    /// Construct a new `RenderOutput`.
    ///
    /// # Examples
    ///
    /// ```
    /// use hjkl_syntax::{RenderOutput, PerfBreakdown};
    /// let out = RenderOutput::new(1, Vec::new(), Vec::new(), (7, 0, 30), PerfBreakdown::new());
    /// assert_eq!(out.buffer_id, 1);
    /// ```
    pub fn new(
        buffer_id: BufferId,
        spans: Vec<Vec<(usize, usize, StyleSpec)>>,
        signs: Vec<DiagSign>,
        key: (u64, usize, usize),
        perf: PerfBreakdown,
    ) -> Self {
        Self {
            buffer_id,
            spans,
            signs,
            key,
            perf,
        }
    }
}

/// Borrowed view of a viewport render result.
///
/// Identical to [`RenderOutput`] except that `spans` borrows the layer's
/// internal row cache instead of deep-copying it. Renderer adapters convert
/// the span table into their own style type anyway, so borrowing lets them
/// build exactly one table per recompute instead of two (the cache copy plus
/// the converted copy).
///
/// The borrow keeps the [`SyntaxLayer`] locked for the lifetime of the value —
/// convert or copy out of it, then drop it. Use
/// [`RenderOutputRef::into_owned`] (or [`SyntaxLayer::render_viewport`]) when
/// an owned table is required.
///
/// # Examples
///
/// ```
/// use hjkl_syntax::{PerfBreakdown, RenderOutputRef};
/// let rows = Vec::new();
/// let out = RenderOutputRef {
///     buffer_id: 0,
///     spans: &rows,
///     signs: Vec::new(),
///     key: (0, 0, 0),
///     perf: PerfBreakdown::default(),
/// };
/// assert_eq!(out.into_owned().buffer_id, 0);
/// ```
#[derive(Debug)]
pub struct RenderOutputRef<'a> {
    /// Routes spans/signs back to the matching buffer slot.
    pub buffer_id: BufferId,
    /// Per-row span table, borrowed from the layer's viewport cache.
    pub spans: &'a [Vec<(usize, usize, StyleSpec)>],
    /// Diagnostic signs for the gutter.
    pub signs: Vec<DiagSign>,
    /// `(dirty_gen, viewport_top, viewport_height)` cache key.
    pub key: (u64, usize, usize),
    /// Sub-step timing breakdown (zeroed in fully-sync path).
    pub perf: PerfBreakdown,
}

impl RenderOutputRef<'_> {
    /// Deep-copy the borrowed span table into an owned [`RenderOutput`].
    ///
    /// # Examples
    ///
    /// ```
    /// use hjkl_syntax::{PerfBreakdown, RenderOutputRef};
    /// let rows = vec![Vec::new()];
    /// let out = RenderOutputRef {
    ///     buffer_id: 3,
    ///     spans: &rows,
    ///     signs: Vec::new(),
    ///     key: (1, 0, 30),
    ///     perf: PerfBreakdown::default(),
    /// }
    /// .into_owned();
    /// assert_eq!(out.spans.len(), 1);
    /// ```
    pub fn into_owned(self) -> RenderOutput {
        RenderOutput {
            buffer_id: self.buffer_id,
            spans: self.spans.to_vec(),
            signs: self.signs,
            key: self.key,
            perf: self.perf,
        }
    }
}

impl PartialEq for RenderOutput {
    fn eq(&self, other: &Self) -> bool {
        self.spans == other.spans
            && self.signs.len() == other.signs.len()
            && self
                .signs
                .iter()
                .zip(other.signs.iter())
                .all(|(a, b)| a.row == b.row && a.ch == b.ch && a.priority == b.priority)
    }
}

// ---------------------------------------------------------------------------
// Public outcome types for set_language_for_path / poll_pending_loads
// ---------------------------------------------------------------------------

/// Outcome of [`SyntaxLayer::set_language_for_path`].
///
/// # Examples
///
/// ```
/// use hjkl_syntax::SetLanguageOutcome;
/// assert!(SetLanguageOutcome::Ready.is_known());
/// assert!(SetLanguageOutcome::Loading.is_known());
/// assert!(!SetLanguageOutcome::Unknown.is_known());
/// ```
#[non_exhaustive]
pub enum SetLanguageOutcome {
    /// Grammar was already cached — installed immediately.
    Ready,
    /// Grammar is being fetched/compiled on the background pool.
    Loading,
    /// Extension unrecognized. No grammar — plain text only.
    Unknown,
}

impl SetLanguageOutcome {
    /// `true` when a grammar was found (either already cached or now in flight).
    pub fn is_known(&self) -> bool {
        matches!(self, Self::Ready | Self::Loading)
    }
}

/// Event emitted by [`SyntaxLayer::poll_pending_loads`].
///
/// # Examples
///
/// ```
/// use hjkl_syntax::LoadEvent;
/// let e = LoadEvent::Ready { id: 0, name: "rust".into() };
/// match e {
///     LoadEvent::Ready { id, name } => assert_eq!(name, "rust"),
///     LoadEvent::Failed { .. } => panic!("unexpected"),
///     _ => {}
/// }
/// ```
#[non_exhaustive]
pub enum LoadEvent {
    /// Grammar installed; trigger a redraw + re-render for `id`.
    Ready { id: BufferId, name: String },
    /// Load failed; buffer stays plain text.
    Failed {
        id: BufferId,
        name: String,
        error: String,
    },
}

/// Exhaustive view of a [`LoadEvent`] for dispatch callbacks.
#[derive(Debug)]
pub enum LoadEventKind<'a> {
    /// Grammar installed successfully.
    Ready { id: BufferId, name: &'a str },
    /// Grammar load failed.
    Failed {
        id: BufferId,
        name: &'a str,
        error: &'a str,
    },
}

// ---------------------------------------------------------------------------
// In-flight grammar load tracking
// ---------------------------------------------------------------------------

struct PendingLoad {
    id: BufferId,
    name: String,
    handle: LoadHandle,
}

// ---------------------------------------------------------------------------
// Per-buffer client state (main thread)
// ---------------------------------------------------------------------------

/// Per-buffer state owned by the main-thread [`SyntaxLayer`].
struct BufferClient {
    has_language: bool,
    current_lang: Option<Arc<Grammar>>,
    /// Owns Parser + Tree for this buffer.
    highlighter: Option<Highlighter>,
    /// dirty_gen the cache was built at (None = cache absent).
    cache_dirty_gen: Option<u64>,
    /// Contiguous row range covered by `cache_spans`.
    cache_rows: Range<usize>,
    /// Per-row span table for `cache_rows`.
    cache_spans: Vec<Vec<(usize, usize, StyleSpec)>>,
    /// `(dirty_gen, row_starts)` — rebuilt only when dirty_gen changes.
    cache_row_starts: Option<(u64, Arc<Vec<usize>>)>,
    /// dirty_gen of the most recent successful parse. Gate reparsing.
    parsed_dirty_gen: Option<u64>,
    /// Cached diag signs keyed by `(dirty_gen, vp_top, vp_end)`.
    cache_signs: Option<(u64, usize, usize, Vec<DiagSign>)>,
    /// Memo of the folds each injected region produced, so one edit re-parses
    /// only the region it touched. Content-hash keyed, so it survives
    /// invalidation of everything else here.
    fold_injections: InjectedFoldCache,
}

impl Default for BufferClient {
    fn default() -> Self {
        Self {
            has_language: false,
            current_lang: None,
            highlighter: None,
            cache_dirty_gen: None,
            cache_rows: 0..0,
            cache_spans: Vec::new(),
            cache_row_starts: None,
            parsed_dirty_gen: None,
            cache_signs: None,
            fold_injections: InjectedFoldCache::default(),
        }
    }
}

impl BufferClient {
    fn invalidate_cache(&mut self) {
        self.cache_dirty_gen = None;
        self.cache_rows = 0..0;
        self.cache_spans.clear();
        self.cache_row_starts = None;
        self.parsed_dirty_gen = None;
        self.cache_signs = None;
    }
}

// ---------------------------------------------------------------------------
// SyntaxLayer — main-thread, fully synchronous
// ---------------------------------------------------------------------------

/// Per-App syntax highlighting layer. Multiplexes per-buffer state.
/// Fully synchronous — no background thread.
///
/// # Examples
///
/// ```no_run
/// use std::sync::Arc;
/// use hjkl_syntax::SyntaxLayer;
/// use hjkl_bonsai::DotFallbackTheme;
/// use hjkl_lang::LanguageDirectory;
///
/// let theme = Arc::new(DotFallbackTheme::dark());
/// let dir = Arc::new(LanguageDirectory::new().unwrap());
/// let layer = SyntaxLayer::new(theme, dir);
/// ```
pub struct SyntaxLayer {
    /// Shared grammar resolver.
    pub directory: Arc<LanguageDirectory>,
    theme: Arc<dyn Theme + Send + Sync>,
    clients: HashMap<BufferId, BufferClient>,
    pending_loads: Vec<PendingLoad>,
    /// When `false`, `HexColorPass` is skipped for all buffers.
    colorizer: bool,
    /// Filetype allowlist for the colorizer. Empty = allow all.
    colorizer_filetypes: Vec<String>,
    /// When `true`, rainbow bracket overlay is applied. Default `true`.
    rainbow_brackets: bool,
}

impl SyntaxLayer {
    /// Create a new layer with no buffers attached.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use std::sync::Arc;
    /// use hjkl_syntax::SyntaxLayer;
    /// use hjkl_bonsai::DotFallbackTheme;
    /// use hjkl_lang::LanguageDirectory;
    ///
    /// let theme = Arc::new(DotFallbackTheme::dark());
    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
    /// let layer = SyntaxLayer::new(theme, dir);
    /// ```
    pub fn new(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
        Self {
            directory,
            theme,
            clients: HashMap::new(),
            pending_loads: Vec::new(),
            colorizer: true,
            colorizer_filetypes: vec![
                "css".to_string(),
                "scss".to_string(),
                "sass".to_string(),
                "less".to_string(),
                "html".to_string(),
                "vue".to_string(),
                "svelte".to_string(),
                "tailwindcss".to_string(),
                "toml".to_string(),
                "lua".to_string(),
                "vim".to_string(),
            ],
            rainbow_brackets: true,
        }
    }

    /// Update rainbow bracket settings. Pass `enabled = false` to disable the
    /// rainbow overlay globally. No-op when the value is unchanged so per-frame
    /// pushes from the app stay cheap. Caches invalidate only on actual change.
    pub fn set_rainbow_brackets(&mut self, enabled: bool) {
        if self.rainbow_brackets == enabled {
            return;
        }
        self.rainbow_brackets = enabled;
        for client in self.clients.values_mut() {
            client.invalidate_cache();
        }
    }

    /// Update colorizer settings. Pass `enabled = false` to disable
    /// the color-literal overlay globally. `filetypes` is the allowlist
    /// of language names (e.g. `"css"`, `"toml"`); an empty slice means
    /// no filetype is allowed (same effect as `enabled = false`).
    ///
    /// No-op when the values are unchanged so per-frame pushes from the
    /// app stay cheap. Caches invalidate only on actual change.
    pub fn set_colorizer(&mut self, enabled: bool, filetypes: Vec<String>) {
        if self.colorizer == enabled && self.colorizer_filetypes == filetypes {
            return;
        }
        self.colorizer = enabled;
        self.colorizer_filetypes = filetypes;
        for client in self.clients.values_mut() {
            client.invalidate_cache();
        }
    }

    /// Borrow the shared language directory.
    pub fn directory(&self) -> &Arc<LanguageDirectory> {
        &self.directory
    }

    fn client_mut(&mut self, id: BufferId) -> &mut BufferClient {
        self.clients.entry(id).or_default()
    }

    /// Detect the language for `path` and attach a grammar.
    ///
    /// - `Ready`   — grammar cached; highlighter installed immediately.
    /// - `Loading` — grammar compiling; renders as plain text until
    ///   `poll_pending_loads` fires `LoadEvent::Ready`.
    /// - `Unknown` — unrecognized extension; plain text only.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use std::sync::Arc;
    /// use std::path::Path;
    /// use hjkl_syntax::{SyntaxLayer, SetLanguageOutcome};
    /// use hjkl_bonsai::DotFallbackTheme;
    /// use hjkl_lang::LanguageDirectory;
    ///
    /// let theme = Arc::new(DotFallbackTheme::dark());
    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
    /// let mut layer = SyntaxLayer::new(theme, dir);
    /// let outcome = layer.set_language_for_path(0, Path::new("a.zzz_not_real"));
    /// assert!(!outcome.is_known());
    /// ```
    pub fn set_language_for_path(&mut self, id: BufferId, path: &Path) -> SetLanguageOutcome {
        match self.directory.request_for_path(path) {
            GrammarRequest::Cached(grammar) => {
                self.attach_grammar(id, grammar.clone());
                let c = self.client_mut(id);
                c.current_lang = Some(grammar);
                c.has_language = true;
                SetLanguageOutcome::Ready
            }
            GrammarRequest::Loading { name, handle } => {
                let c = self.client_mut(id);
                c.current_lang = None;
                c.has_language = false;
                c.highlighter = None;
                c.invalidate_cache();
                self.pending_loads.push(PendingLoad {
                    id,
                    name: name.clone(),
                    handle,
                });
                SetLanguageOutcome::Loading
            }
            GrammarRequest::Unknown | _ => {
                let c = self.client_mut(id);
                c.current_lang = None;
                c.has_language = false;
                c.highlighter = None;
                c.invalidate_cache();
                SetLanguageOutcome::Unknown
            }
        }
    }

    /// Attach a grammar by its canonical language name, bypassing path
    /// detection entirely. The name may come from content detection (shebang,
    /// modeline `ft=`) or a manual `:set filetype=`.
    ///
    /// Identical semantics to [`Self::set_language_for_path`] once the name
    /// is known; an unrecognised name resolves to [`SetLanguageOutcome::Unknown`]
    /// without attaching anything.
    pub fn set_language_by_name(&mut self, id: BufferId, name: &str) -> SetLanguageOutcome {
        match self.directory.request_by_name(name) {
            GrammarRequest::Cached(grammar) => {
                self.attach_grammar(id, grammar.clone());
                let c = self.client_mut(id);
                c.current_lang = Some(grammar);
                c.has_language = true;
                SetLanguageOutcome::Ready
            }
            GrammarRequest::Loading { name, handle } => {
                let c = self.client_mut(id);
                c.current_lang = None;
                c.has_language = false;
                c.highlighter = None;
                c.invalidate_cache();
                self.pending_loads.push(PendingLoad {
                    id,
                    name: name.clone(),
                    handle,
                });
                SetLanguageOutcome::Loading
            }
            GrammarRequest::Unknown | _ => {
                let c = self.client_mut(id);
                c.current_lang = None;
                c.has_language = false;
                c.highlighter = None;
                c.invalidate_cache();
                SetLanguageOutcome::Unknown
            }
        }
    }

    /// Attach a grammar to a buffer, creating/replacing the Highlighter.
    fn attach_grammar(&mut self, id: BufferId, grammar: Arc<Grammar>) {
        let c = self.clients.entry(id).or_default();
        c.invalidate_cache();
        match Highlighter::new(grammar) {
            Ok(h) => {
                c.highlighter = Some(h);
            }
            Err(e) => {
                tracing::error!(buffer_id = id, error = %e, "failed to attach highlighter");
                c.highlighter = None;
            }
        }
    }

    /// Poll all in-flight grammar loads. Call once per tick.
    ///
    /// Returns one `LoadEvent` per handle that resolved during this tick.
    pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
        let mut events = Vec::new();
        let mut i = 0;
        while i < self.pending_loads.len() {
            match self.pending_loads[i].handle.try_recv() {
                None => {
                    i += 1;
                }
                Some(Ok(lib_path)) => {
                    let name = self.pending_loads[i].name.clone();
                    let bid = self.pending_loads[i].id;
                    self.pending_loads.swap_remove(i);
                    match self.directory.complete_load(&name, &lib_path) {
                        Ok(grammar) => {
                            self.attach_grammar(bid, grammar.clone());
                            let c = self.client_mut(bid);
                            c.current_lang = Some(grammar);
                            c.has_language = true;
                            events.push(LoadEvent::Ready { id: bid, name });
                        }
                        Err(e) => {
                            events.push(LoadEvent::Failed {
                                id: bid,
                                name,
                                error: format!("{e:#}"),
                            });
                        }
                    }
                }
                Some(Err(err)) => {
                    let name = self.pending_loads[i].name.clone();
                    let bid = self.pending_loads[i].id;
                    self.pending_loads.swap_remove(i);
                    events.push(LoadEvent::Failed {
                        id: bid,
                        name,
                        error: err.to_string(),
                    });
                }
            }
        }
        events
    }

    /// Drop all state for a buffer. Call on close.
    pub fn forget(&mut self, id: BufferId) {
        self.clients.remove(&id);
    }

    /// Swap the active theme. Next `render_viewport` call uses the new theme.
    pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
        self.theme = theme;
        // Invalidate all per-buffer caches so they repaint with the new theme.
        for c in self.clients.values_mut() {
            c.invalidate_cache();
        }
    }

    /// Apply a batch of engine `ContentEdit`s to the buffer's retained tree
    /// synchronously. The cache will be invalidated on the next `render_viewport`
    /// call via dirty_gen mismatch.
    ///
    /// No-op when no grammar is attached.
    pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
        let c = match self.clients.get_mut(&id) {
            Some(c) if c.has_language => c,
            _ => return,
        };
        let Some(h) = c.highlighter.as_mut() else {
            return;
        };
        for e in edits {
            h.edit(&InputEdit {
                start_byte: e.start_byte,
                old_end_byte: e.old_end_byte,
                new_end_byte: e.new_end_byte,
                start_position: Point {
                    row: e.start_position.0 as usize,
                    column: e.start_position.1 as usize,
                },
                old_end_position: Point {
                    row: e.old_end_position.0 as usize,
                    column: e.old_end_position.1 as usize,
                },
                new_end_position: Point {
                    row: e.new_end_position.0 as usize,
                    column: e.new_end_position.1 as usize,
                },
            });
        }
        // Drop every cache, the span table included. This used to clear only
        // the parse, row-start and sign caches and leave `cache_spans` to the
        // `dirty_gen` mismatch in `render_viewport` — which does happen for a
        // real buffer edit, but makes correctness here depend on a counter
        // this function neither reads nor controls. A caller that edits
        // through `apply_edits` without the buffer's `dirty_gen` moving got
        // the PRE-edit spans back, reparse and all.
        c.invalidate_cache();
    }

    /// Drop the buffer's retained tree. Next `render_viewport` reparses from scratch.
    ///
    /// Call on `:e!` / content reset.
    pub fn reset(&mut self, id: BufferId) {
        if let Some(c) = self.clients.get_mut(&id) {
            if let Some(h) = c.highlighter.as_mut() {
                h.reset();
            }
            c.invalidate_cache();
        }
    }

    /// Extract fold ranges from the buffer's retained tree using the bundled
    /// `folds.scm` for this grammar, plus one for each injected region using
    /// ITS language's query.
    ///
    /// Returns `Some(ranges)` when the grammar is attached and the tree has
    /// been parsed — `ranges` may be empty when the grammar has no bundled
    /// `folds.scm` or the file contains no foldable nodes.
    ///
    /// Injected regions (a ` ```rust ` block in markdown, a `<script>` body in
    /// HTML) are parsed with their own grammar, folded with that language's
    /// query, and their rows offset into this document — see
    /// [`hjkl_bonsai::extract_fold_ranges_rope_with_injections`]. Resolving an
    /// injected grammar goes through the shared [`LanguageDirectory`], which
    /// may build one on first use; only languages with a bundled fold query
    /// can trigger that.
    ///
    /// Returns `None` when:
    /// - No grammar is attached yet (grammar still loading or unknown extension).
    /// - No highlighter has been created for this buffer.
    /// - The tree has not been parsed yet (call `render_viewport` first).
    ///
    /// **Callers must treat `None` as "not ready — retry later"** and must NOT
    /// record the dirty_gen as processed when `None` is returned. Returning
    /// `Some(empty)` is the signal that the grammar ran but produced no folds
    /// (e.g. no `folds.scm` for this language).
    ///
    /// **NOT viewport-bounded** — runs over the full tree (once per reparse).
    /// Do not call this per-frame; call it only when `dirty_gen` has changed.
    pub fn extract_fold_ranges(
        &mut self,
        id: BufferId,
        buffer: &impl hjkl_engine::Query,
    ) -> Option<Vec<(usize, usize)>> {
        let directory = Arc::clone(&self.directory);
        let client = match self.clients.get_mut(&id) {
            Some(c) if c.has_language => c,
            // Grammar not yet attached (loading or unknown) — signal "not ready".
            _ => return None,
        };
        // Highlighter creation failed — signal "not ready".
        let highlighter = client.highlighter.as_ref()?;
        // Tree not yet parsed — signal "not ready".
        let tree = highlighter.tree()?;
        let grammar = highlighter.grammar()?;
        let injections = highlighter.injection_query();
        let rope = buffer.rope();
        // Grammar is ready and tree is parsed — return Some even if empty.
        // Injected regions (a ```rust block in markdown, the `<script>` body of
        // an HTML page) are folded with their own grammar; a language whose
        // grammar is not installed simply contributes nothing.
        Some(extract_fold_ranges_rope_with_injections(
            tree,
            grammar,
            &rope,
            injections,
            &mut client.fold_injections,
            // Cache-only: a fold pass must never be what clone+compiles a
            // grammar. Injected grammars are loaded (and cached) by the
            // highlight path; until then that region simply contributes no
            // folds, and a later frame picks it up.
            |name| directory.by_name_cached(name),
        ))
    }

    /// Render spans for the visible viewport, returning an owned span table.
    ///
    /// Thin wrapper over [`Self::render_viewport_ref`] that deep-copies the
    /// cached rows. Callers that immediately convert the table into their own
    /// style type (every renderer adapter) should use `render_viewport_ref`
    /// instead and skip the copy.
    pub fn render_viewport(
        &mut self,
        id: BufferId,
        buffer: &impl Query,
        viewport_top: usize,
        viewport_height: usize,
    ) -> Option<RenderOutput> {
        Some(
            self.render_viewport_ref(id, buffer, viewport_top, viewport_height)?
                .into_owned(),
        )
    }

    /// Render spans for the visible viewport. Fully synchronous.
    ///
    /// 1. Returns `None` when no grammar is attached.
    /// 2. Clears the cache when `buffer.dirty_gen()` has advanced.
    /// 3. Returns cached rows when the request is fully inside the cached range.
    /// 4. Walks only rows outside the cache (extend prefix/suffix), splices into
    ///    `cache_spans`, extends `cache_rows`.
    ///
    /// The returned [`RenderOutputRef`] borrows the viewport slice of
    /// `cache_spans` — no per-call copy of the span table.
    pub fn render_viewport_ref(
        &mut self,
        id: BufferId,
        buffer: &impl Query,
        viewport_top: usize,
        viewport_height: usize,
    ) -> Option<RenderOutputRef<'_>> {
        let client = self.clients.get_mut(&id)?;
        if !client.has_language {
            return None;
        }
        let dg = buffer.dirty_gen();
        let row_count = buffer.line_count() as usize;
        if row_count == 0 || viewport_height == 0 {
            return None;
        }

        let vp_top = viewport_top.min(row_count);
        let vp_end = (vp_top + viewport_height).min(row_count);
        if vp_end <= vp_top {
            return None;
        }

        // Single dirty_gen invalidation point.
        if client.cache_dirty_gen != Some(dg) {
            client.invalidate_cache();
        }

        // Get a rope snapshot — O(1) Arc-clone from hjkl_buffer::View.
        // All downstream consumers (parse, highlight, row_starts, diag signs)
        // now read directly from the rope: no full-document String allocation.
        let rope = buffer.rope();

        // Get or build row_starts, cached per dirty_gen.
        // Scan newlines chunk-by-chunk from the rope so we never materialise
        // the full document as a contiguous byte slice.
        let row_starts: Arc<Vec<usize>> = if client
            .cache_row_starts
            .as_ref()
            .is_some_and(|(g, _)| *g == dg)
        {
            Arc::clone(&client.cache_row_starts.as_ref().unwrap().1)
        } else {
            // SIMD-vectorised newline scan via memchr — measurably faster than
            // a per-byte loop. Pre-sized to row_count + 1 to avoid realloc churn.
            let mut rs: Vec<usize> = Vec::with_capacity(row_count + 1);
            rs.push(0);
            let mut chunk_pos = 0usize;
            for chunk in rope.chunks() {
                for nl in memchr::memchr_iter(b'\n', chunk.as_bytes()) {
                    rs.push(chunk_pos + nl + 1);
                }
                chunk_pos += chunk.len();
            }
            let arc = Arc::new(rs);
            client.cache_row_starts = Some((dg, Arc::clone(&arc)));
            arc
        };

        // Reparse only when needed. Use rope-streaming parse to avoid passing
        // the full bytes slice into the parser (tree-sitter reads chunk-by-chunk
        // via the closure; no contiguous copy required for the parse step).
        let needs_reparse = client.parsed_dirty_gen != Some(dg);
        {
            let highlighter = client.highlighter.as_mut()?;
            if highlighter.tree().is_none() {
                highlighter.parse_initial_rope(&rope);
                if highlighter.tree().is_some() {
                    client.parsed_dirty_gen = Some(dg);
                }
            } else if needs_reparse {
                // No-diff incremental: we discard the changed-byte ranges
                // (cache is keyed by dirty_gen + viewport, not by edit
                // ranges). Computing `old.changed_ranges(&new)` walks both
                // trees and was ~54 % of per-keystroke CPU on a 1.86 M-line
                // file.
                let ok = highlighter.parse_incremental_rope(&rope);
                if ok && highlighter.tree().is_some() {
                    client.parsed_dirty_gen = Some(dg);
                }
            }
        }

        // Compute colorizer gate before re-borrowing client mutably.
        // Effective = global flag AND current language is in the allowlist.
        let colorizer_enabled = {
            let c = self.clients.get(&id)?;
            let lang_name = c.current_lang.as_ref().map_or("", |g| g.name());
            self.colorizer
                && (self.colorizer_filetypes.is_empty()
                    || self.colorizer_filetypes.iter().any(|ft| ft == lang_name))
        };
        let rainbow_brackets_enabled = self.rainbow_brackets;

        // Re-borrow after parse.
        let client = self.clients.get_mut(&id)?;
        let highlighter = client.highlighter.as_mut()?;

        // If still no tree (parse failed), give up.
        highlighter.tree()?;

        let theme = self.theme.as_ref();
        let directory = Arc::clone(&self.directory);

        // Extend cache to cover [vp_top, vp_end).
        if client.cache_rows.is_empty() {
            // Case A: empty cache — walk full range.
            client.cache_spans = walk_rows(
                highlighter,
                &rope,
                &row_starts,
                row_count,
                vp_top,
                vp_end,
                theme,
                &directory,
                colorizer_enabled,
                rainbow_brackets_enabled,
            );
            client.cache_rows = vp_top..vp_end;
            client.cache_dirty_gen = Some(dg);
        } else {
            let cache_covers_overlap =
                vp_top < client.cache_rows.end && vp_end > client.cache_rows.start;
            if !cache_covers_overlap {
                // Disjoint — just rebuild the whole viewport.
                client.cache_spans = walk_rows(
                    highlighter,
                    &rope,
                    &row_starts,
                    row_count,
                    vp_top,
                    vp_end,
                    theme,
                    &directory,
                    colorizer_enabled,
                    rainbow_brackets_enabled,
                );
                client.cache_rows = vp_top..vp_end;
            } else {
                // Case B: extend prefix if needed.
                if vp_top < client.cache_rows.start {
                    let new_rows = walk_rows(
                        highlighter,
                        &rope,
                        &row_starts,
                        row_count,
                        vp_top,
                        client.cache_rows.start,
                        theme,
                        &directory,
                        colorizer_enabled,
                        rainbow_brackets_enabled,
                    );
                    let mut combined = new_rows;
                    combined.append(&mut client.cache_spans);
                    client.cache_spans = combined;
                    client.cache_rows.start = vp_top;
                }
                // Case C: extend suffix if needed.
                if vp_end > client.cache_rows.end {
                    let new_rows = walk_rows(
                        highlighter,
                        &rope,
                        &row_starts,
                        row_count,
                        client.cache_rows.end,
                        vp_end,
                        theme,
                        &directory,
                        colorizer_enabled,
                        rainbow_brackets_enabled,
                    );
                    client.cache_spans.extend(new_rows);
                    client.cache_rows.end = vp_end;
                }
            }
            client.cache_dirty_gen = Some(dg);
        }

        // Bounds of the requested viewport inside the cache.
        let offset = vp_top - client.cache_rows.start;
        let len = vp_end - vp_top;

        // Get or build signs, cached per (dirty_gen, vp_top, vp_end).
        // Done before borrowing `cache_spans` so the mutable client borrow
        // (needed for `highlighter` and the sign-cache write) has ended.
        let signs = if client
            .cache_signs
            .as_ref()
            .is_some_and(|(g, t, e, _)| *g == dg && *t == vp_top && *e == vp_end)
        {
            client.cache_signs.as_ref().unwrap().3.clone()
        } else {
            let s = collect_diag_signs_range(highlighter, &rope, &row_starts, vp_top, vp_end);
            client.cache_signs = Some((dg, vp_top, vp_end, s.clone()));
            s
        };

        // Borrow the viewport slice out of the cache — no copy.
        let spans = &self.clients.get(&id)?.cache_spans[offset..offset + len];

        Some(RenderOutputRef {
            buffer_id: id,
            spans,
            signs,
            key: (dg, vp_top, viewport_height),
            perf: PerfBreakdown::default(),
        })
    }

    /// Resolve a path to its language name without loading a grammar.
    pub fn name_for_path(&self, path: &Path) -> Option<String> {
        self.directory.name_for_path(path)
    }

    /// Returns `true` if a client is tracked for the given buffer id.
    #[doc(hidden)]
    pub fn has_client(&self, id: BufferId) -> bool {
        self.clients.contains_key(&id)
    }

    /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
    ///
    /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
    /// let mut got_ready = false;
    /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
    ///     use hjkl_syntax::LoadEventKind;
    ///     match ev {
    ///         LoadEventKind::Ready { id, name } => { got_ready = true; }
    ///         LoadEventKind::Failed { .. } => {}
    ///     }
    /// });
    /// assert!(handled);
    /// assert!(got_ready);
    /// ```
    pub fn dispatch_load_event(
        event: &LoadEvent,
        mut handler: impl FnMut(LoadEventKind<'_>),
    ) -> bool {
        #[allow(unreachable_patterns)]
        match event {
            LoadEvent::Ready { id, name } => {
                handler(LoadEventKind::Ready { id: *id, name });
                true
            }
            LoadEvent::Failed { id, name, error } => {
                handler(LoadEventKind::Failed {
                    id: *id,
                    name,
                    error,
                });
                true
            }
            _ => false,
        }
    }
}

// ---------------------------------------------------------------------------
// Rainbow palette
// ---------------------------------------------------------------------------

/// 7-colour rainbow palette for bracket depth coloring (dark-bg readable).
/// Depth 0 → index 0, depth N → RAINBOW_PALETTE[N % RAINBOW_PALETTE.len()].
const RAINBOW_PALETTE: [Color; 7] = [
    Color::rgb(255, 100, 100), // red
    Color::rgb(255, 175, 80),  // orange
    Color::rgb(255, 230, 80),  // yellow
    Color::rgb(100, 220, 100), // green
    Color::rgb(80, 210, 220),  // cyan
    Color::rgb(100, 140, 255), // blue
    Color::rgb(190, 120, 255), // violet
];

// ---------------------------------------------------------------------------
// Helper: walk a row range against the retained tree
// ---------------------------------------------------------------------------

#[allow(clippy::too_many_arguments)]
fn walk_rows(
    highlighter: &mut Highlighter,
    rope: &ropey::Rope,
    row_starts: &[usize],
    row_count: usize,
    seg_start: usize,
    seg_end: usize,
    theme: &dyn Theme,
    directory: &Arc<LanguageDirectory>,
    colorizer: bool,
    rainbow_brackets: bool,
) -> Vec<Vec<(usize, usize, StyleSpec)>> {
    let rope_len = rope.len_bytes();
    let byte_start = row_starts.get(seg_start).copied().unwrap_or(rope_len);
    let byte_end = row_starts
        .get(seg_end)
        .copied()
        .unwrap_or(rope_len)
        .min(rope_len)
        .max(byte_start);

    let mut flat_spans =
        highlighter.highlight_range_with_injections_rope(rope, byte_start..byte_end, |name| {
            directory.by_name(name)
        });

    let marker_pass = CommentMarkerPass::new();
    marker_pass.apply_rope(&mut flat_spans, rope);
    if colorizer {
        let hex_color_pass = HexColorPass::new();
        hex_color_pass.apply_range_rope(&mut flat_spans, rope, byte_start..byte_end);
    }
    if rainbow_brackets
        && let (Some(tree), Some(grammar)) = (highlighter.tree(), highlighter.grammar())
    {
        let rb_spans = rainbow_spans_rope(tree, grammar, rope, byte_start..byte_end);
        flat_spans.extend(rb_spans);
    }

    // Bucket spans into ONLY the viewport row range. The prior version
    // called `build_by_row(..., row_count, ...)` and sliced the result,
    // which allocated `row_count` empty inner Vecs (8.58 M on a huge
    // file) just to throw away all but ~50 of them — that single line
    // was ~24 % of per-keystroke CPU during a paste burst.
    let _ = row_count; // kept in signature for the public build_by_row tests
    build_by_row_range(&flat_spans, rope_len, row_starts, seg_start..seg_end, theme)
}

/// Viewport-bounded variant of [`build_by_row`]. Allocates exactly
/// `row_range.len()` inner Vecs instead of one per document row. Spans
/// whose byte range falls entirely outside `row_range` are skipped; spans
/// that overlap have their per-row slices recorded with positions local
/// to the viewport (so row `row_range.start` lands at index 0).
fn build_by_row_range(
    flat_spans: &[hjkl_bonsai::HighlightSpan],
    source_len: usize,
    row_starts: &[usize],
    row_range: Range<usize>,
    theme: &dyn Theme,
) -> Vec<Vec<(usize, usize, StyleSpec)>> {
    let seg_start = row_range.start;
    let seg_end = row_range.end.min(row_starts.len());
    if seg_end <= seg_start {
        return Vec::new();
    }
    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); seg_end - seg_start];

    for span in flat_spans {
        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
            let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
                _ => None,
            };
            let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
                _ => None,
            };
            bg.map(|bg| StyleSpec {
                fg,
                bg: Some(bg),
                modifiers: hjkl_theme::Modifiers::default(),
            })
        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
            let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
                Some(MetaValue::Int(d)) => *d as usize,
                _ => 0,
            };
            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
            Some(StyleSpec {
                fg: Some(fg),
                bg: None,
                modifiers: hjkl_theme::Modifiers::default(),
            })
        } else {
            None
        };

        let style: StyleSpec = if let Some(s) = hex_style {
            s
        } else {
            match theme.style(span.capture()) {
                Some(s) => *s,
                None => continue,
            }
        };

        let span_start = span.byte_range.start;
        let span_end = span.byte_range.end;

        let start_row = row_starts
            .partition_point(|&rs| rs <= span_start)
            .saturating_sub(1);

        let mut row = start_row.max(seg_start);
        while row < seg_end {
            let row_byte_start = row_starts[row];
            let row_byte_end = row_starts
                .get(row + 1)
                .map_or(source_len, |&s| s.saturating_sub(1));

            if row_byte_start >= span_end {
                break;
            }

            let local_start = span_start.saturating_sub(row_byte_start);
            let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);

            if local_end > local_start {
                by_row[row - seg_start].push((local_start, local_end, style));
            }

            row += 1;
        }
    }

    by_row
}

/// Row-local end offset for a span clipped to one row.
///
/// A MULTI-ROW span that covers this row's end — markdown's fenced code
/// block, a multi-line string — records one byte PAST the row's content (the
/// newline slot) instead of stopping at the last character. That is how the
/// renderer tells a block from a span that merely happens to reach
/// end-of-line, and it paints the block's bg across the whole row. An empty
/// row inside such a span gets `0..1`; without it the row produces no span
/// at all and shows as an untinted gap mid-block.
///
/// A span confined to ONE row keeps its exact end even when it reaches
/// end-of-line, so a hex-colour swatch or TODO marker never bleeds its bg
/// across the row.
fn row_local_end(
    span_start: usize,
    span_end: usize,
    row_byte_start: usize,
    row_byte_end: usize,
) -> usize {
    let multi_row = span_start < row_byte_start || span_end > row_byte_end;
    if multi_row && span_end >= row_byte_end {
        row_byte_end - row_byte_start + 1
    } else {
        span_end.min(row_byte_end) - row_byte_start
    }
}

// ---------------------------------------------------------------------------
// Helper: build per-row span table (renderer-agnostic StyleSpec output)
// ---------------------------------------------------------------------------

/// Resolve flat highlight spans into a per-row span table sized to `row_count`.
pub fn build_by_row(
    flat_spans: &[hjkl_bonsai::HighlightSpan],
    bytes: &[u8],
    row_starts: &[usize],
    row_count: usize,
    theme: &dyn Theme,
) -> Vec<Vec<(usize, usize, StyleSpec)>> {
    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];

    for span in flat_spans {
        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
            let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
                _ => None,
            };
            let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
                _ => None,
            };
            bg.map(|bg| StyleSpec {
                fg,
                bg: Some(bg),
                modifiers: hjkl_theme::Modifiers::default(),
            })
        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
            let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
                Some(MetaValue::Int(d)) => *d as usize,
                _ => 0,
            };
            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
            Some(StyleSpec {
                fg: Some(fg),
                bg: None,
                modifiers: hjkl_theme::Modifiers::default(),
            })
        } else {
            None
        };

        let style: StyleSpec = if let Some(s) = hex_style {
            s
        } else {
            match theme.style(span.capture()) {
                Some(s) => *s,
                None => continue,
            }
        };
        let style = &style;

        let span_start = span.byte_range.start;
        let span_end = span.byte_range.end;

        let start_row = row_starts
            .partition_point(|&rs| rs <= span_start)
            .saturating_sub(1);

        let mut row = start_row;
        while row < row_count {
            // `row_count` is caller-supplied and may exceed `row_starts.len()`;
            // stop rather than index out of bounds.
            let Some(&row_byte_start) = row_starts.get(row) else {
                break;
            };
            let row_byte_end = row_starts
                .get(row + 1)
                .map_or(bytes.len(), |&s| s.saturating_sub(1));

            if row_byte_start >= span_end {
                break;
            }

            let local_start = span_start.saturating_sub(row_byte_start);
            let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);

            if local_end > local_start {
                by_row[row].push((local_start, local_end, *style));
            }

            row += 1;
        }
    }

    by_row
}

// ---------------------------------------------------------------------------
// Helper: collect diagnostic signs
// ---------------------------------------------------------------------------

fn collect_diag_signs_range(
    h: &mut Highlighter,
    rope: &ropey::Rope,
    row_starts: &[usize],
    vp_top: usize,
    vp_end: usize,
) -> Vec<DiagSign> {
    let rope_len = rope.len_bytes();
    let byte_start = row_starts.get(vp_top).copied().unwrap_or(rope_len);
    let byte_end = row_starts.get(vp_end).copied().unwrap_or(rope_len);
    // The retained tree stores document-absolute byte offsets, and
    // `parse_errors_range` both filters nodes against `byte_range` and
    // harvests snippets by indexing `source` with those absolute offsets.
    // Materialise only the viewport window (typically ≪ 100 KB), but place
    // it at its absolute position in a zero-filled buffer so offsets line
    // up. `vec![0u8; n]` uses `alloc_zeroed`, so the prefix costs no
    // explicit writes. Passing a window-relative range here previously
    // reported errors from the wrong document region once scrolled.
    let source: Vec<u8> = if byte_start < byte_end && byte_end <= rope_len {
        let mut buf = vec![0u8; byte_end];
        let mut pos = byte_start;
        for chunk in rope.byte_slice(byte_start..byte_end).chunks() {
            buf[pos..pos + chunk.len()].copy_from_slice(chunk.as_bytes());
            pos += chunk.len();
        }
        buf
    } else {
        Vec::new()
    };
    let errors = h.parse_errors_range(&source, byte_start..byte_end);
    let mut signs: Vec<DiagSign> = Vec::new();
    let mut last_row: Option<usize> = None;
    for err in &errors {
        // Error byte ranges are already document-absolute.
        let abs_start = err.byte_range.start;
        let r = row_starts
            .partition_point(|&rs| rs <= abs_start)
            .saturating_sub(1);
        if last_row == Some(r) {
            continue;
        }
        last_row = Some(r);
        signs.push(DiagSign::new(r, 'E', 100));
    }
    signs
}

// ---------------------------------------------------------------------------
// Factory helpers
// ---------------------------------------------------------------------------

/// Build a `SyntaxLayer` using the given theme + language directory.
pub fn layer_with_theme(
    theme: Arc<DotFallbackTheme>,
    directory: Arc<LanguageDirectory>,
) -> SyntaxLayer {
    SyntaxLayer::new(theme, directory)
}

/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
#[cfg(test)]
pub fn default_layer() -> SyntaxLayer {
    let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
    SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use hjkl_buffer::View;
    use std::fmt::Write as _;
    use std::path::Path;

    const TID: BufferId = 0;

    // --- DiagSign ---

    #[test]
    fn diag_sign_new_roundtrip() {
        let s = DiagSign::new(7, 'W', 50);
        assert_eq!(s.row, 7);
        assert_eq!(s.ch, 'W');
        assert_eq!(s.priority, 50);
    }

    #[test]
    fn diag_sign_default_is_sensible() {
        let s = DiagSign::default();
        assert_eq!(s.row, 0);
        assert_eq!(s.ch, 'E');
        assert_eq!(s.priority, 0);
    }

    // --- PerfBreakdown ---

    #[test]
    fn perf_breakdown_default_zeros() {
        let p = PerfBreakdown::new();
        assert_eq!(p.source_build_us, 0);
        assert_eq!(p.parse_us, 0);
        assert_eq!(p.highlight_us, 0);
        assert_eq!(p.by_row_us, 0);
        assert_eq!(p.diag_us, 0);
    }

    // --- SetLanguageOutcome ---

    #[test]
    fn set_language_outcome_is_known() {
        assert!(SetLanguageOutcome::Ready.is_known());
        assert!(SetLanguageOutcome::Loading.is_known());
        assert!(!SetLanguageOutcome::Unknown.is_known());
    }

    // --- RenderOutput ---

    #[test]
    fn render_output_new_roundtrip() {
        let out = RenderOutput::new(
            99,
            vec![vec![]],
            vec![DiagSign::new(0, 'E', 100)],
            (7, 0, 30),
            PerfBreakdown::new(),
        );
        assert_eq!(out.buffer_id, 99);
        assert_eq!(out.key, (7, 0, 30));
        assert_eq!(out.signs.len(), 1);
    }

    #[test]
    fn render_output_partial_eq_same() {
        let a = RenderOutput::new(
            0,
            vec![vec![(0, 5, StyleSpec::default())]],
            vec![],
            (1, 0, 10),
            PerfBreakdown::default(),
        );
        let b = a.clone();
        assert_eq!(a, b);
    }

    // --- build_by_row ---

    #[test]
    fn build_by_row_empty_spans_gives_empty_rows() {
        let by_row = build_by_row(
            &[],
            b"hello\nworld\n",
            &[0, 6, 12],
            2,
            &DotFallbackTheme::dark(),
        );
        assert_eq!(by_row.len(), 2);
        assert!(by_row[0].is_empty());
        assert!(by_row[1].is_empty());
    }

    #[test]
    fn build_by_row_marks_every_full_row_of_a_multi_row_span() {
        // A multi-row span (markdown's fenced code block, a multi-line
        // string) records one byte PAST the content of every row it covers
        // to the end — including the row it ENDS on, so a code block renders
        // as a rectangle rather than a ragged shape with a short last line.
        //
        //   row 0 "aaa"  bytes 0..3   covered to eol      → 0..4
        //   row 1 ""     byte  4      blank, covered      → 0..1
        //   row 2 "bb"   bytes 5..7   ends AT this eol    → 0..3
        //   row 3 "cc"   bytes 8..10  past the span       → none
        let bytes = b"aaa\n\nbb\ncc\n";
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 0..7,
            capture: Arc::from("string"),
            metadata: None,
        };
        let by_row = build_by_row(
            &[span],
            bytes,
            &[0, 4, 5, 8, 11],
            4,
            &DotFallbackTheme::dark(),
        );
        assert_eq!(by_row[0].len(), 1);
        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
        assert_eq!(
            by_row[1].len(),
            1,
            "a blank row inside the span must still get one, or it renders as an untinted gap"
        );
        assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 1));
        assert_eq!(by_row[2].len(), 1);
        assert_eq!(
            (by_row[2][0].0, by_row[2][0].1),
            (0, 3),
            "the last row of the span reaches its eol, so it is marked too"
        );
        assert!(by_row[3].is_empty());
    }

    #[test]
    fn build_by_row_multi_row_span_ending_mid_row_stops_there() {
        // A multi-row span that ends BEFORE the last row's eol marks only the
        // rows it covers to the end — the final partial row keeps its exact
        // offset, so no bg spills past the text it actually covers.
        //
        //   row 0 "aaa" bytes 0..3  covered to eol → 0..4
        //   row 1 "bbb" bytes 4..7  ends at byte 6 → 0..2
        let bytes = b"aaa\nbbb\n";
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 0..6,
            capture: Arc::from("string"),
            metadata: None,
        };
        let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
        assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 2));
    }

    #[test]
    fn build_by_row_single_row_span_ends_at_its_content() {
        // The counterpart: a span confined to one row is never marked, even
        // when it reaches end-of-line — a hex-colour swatch or TODO marker
        // must not bleed its bg across the rest of the row.
        let bytes = b"aaa\nbbb\n";
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 0..3,
            capture: Arc::from("string"),
            metadata: None,
        };
        let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
        assert_eq!(by_row[0].len(), 1);
        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 3));
        assert!(by_row[1].is_empty());
    }

    #[test]
    fn build_by_row_hex_color_uses_metadata_colors() {
        let bytes = b"--accent: #bb9af7;";
        let mut metadata = std::collections::HashMap::new();
        metadata.insert(
            HEX_BG_KEY.to_string(),
            MetaValue::Str("#bb9af7".to_string()),
        );
        metadata.insert(
            HEX_FG_KEY.to_string(),
            MetaValue::Str("#ffffff".to_string()),
        );
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 10..17,
            capture: Arc::from(HEX_COLOR_CAPTURE),
            metadata: Some(Box::new(metadata)),
        };
        let by_row = build_by_row(&[span], bytes, &[0], 1, &DotFallbackTheme::dark());
        assert_eq!(by_row.len(), 1);
        assert_eq!(by_row[0].len(), 1);
        let (_, _, style) = by_row[0][0];
        let bg = style.bg.expect("hex color must set background");
        assert_eq!((bg.r, bg.g, bg.b), (0xbb, 0x9a, 0xf7));
        let fg = style.fg.expect("hex color must set foreground");
        assert_eq!((fg.r, fg.g, fg.b), (0xff, 0xff, 0xff));
    }

    #[test]
    fn build_by_row_row_count_beyond_row_starts_no_panic() {
        // `row_count` is caller-supplied; when it exceeds `row_starts.len()`
        // the walk must stop instead of indexing out of bounds.
        let bytes = b"foo";
        let mut metadata = std::collections::HashMap::new();
        metadata.insert(
            HEX_BG_KEY.to_string(),
            MetaValue::Str("#112233".to_string()),
        );
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 0..3,
            capture: Arc::from(HEX_COLOR_CAPTURE),
            metadata: Some(Box::new(metadata)),
        };
        let by_row = build_by_row(&[span], bytes, &[0], 3, &DotFallbackTheme::dark());
        assert_eq!(by_row.len(), 3);
        assert_eq!(by_row[0].len(), 1);
        assert!(by_row[1].is_empty());
        assert!(by_row[2].is_empty());
    }

    #[test]
    fn build_by_row_hex_color_without_metadata_skips() {
        let span = hjkl_bonsai::HighlightSpan {
            byte_range: 0..3,
            capture: Arc::from(HEX_COLOR_CAPTURE),
            metadata: None,
        };
        let by_row = build_by_row(&[span], b"foo", &[0], 1, &DotFallbackTheme::dark());
        assert_eq!(by_row.len(), 1);
        assert!(by_row[0].is_empty());
    }

    // --- SyntaxLayer basics (no network required) ---

    #[test]
    fn render_viewport_with_no_language_returns_none() {
        let buf = View::from_str("hello world");
        let mut layer = default_layer();
        assert!(
            !layer
                .set_language_for_path(TID, Path::new("a.unknownext"))
                .is_known()
        );
        assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
    }

    #[test]
    fn set_language_by_name_unknown_name_returns_unknown() {
        // Same contract as the path form: an unrecognised name must resolve
        // to Unknown (never Loading, never a panic) and leave the buffer
        // plain text.
        let buf = View::from_str("hello world");
        let mut layer = default_layer();
        assert!(
            !layer
                .set_language_by_name(TID, "definitely_not_a_real_language")
                .is_known()
        );
        assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
    }

    #[test]
    fn apply_edits_with_no_language_is_noop() {
        let mut layer = default_layer();
        let edits = vec![hjkl_engine::ContentEdit {
            start_byte: 0,
            old_end_byte: 0,
            new_end_byte: 1,
            start_position: (0, 0),
            old_end_position: (0, 0),
            new_end_position: (0, 1),
        }];
        layer.apply_edits(TID, &edits);
        // No grammar attached → call must be a no-op (no panic).
    }

    #[test]
    fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
        let mut layer = default_layer();
        let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
        assert!(!outcome.is_known());
        assert!(matches!(outcome, SetLanguageOutcome::Unknown));
    }

    #[test]
    fn poll_pending_loads_drains_ready_handles() {
        let mut layer = default_layer();
        let events = layer.poll_pending_loads();
        assert!(
            events.is_empty(),
            "expected no events with no pending loads"
        );
    }

    #[test]
    fn forget_removes_client_state() {
        let mut layer = default_layer();
        layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
        layer.forget(TID);
        assert!(!layer.clients.contains_key(&TID));
    }

    // --- Regression: fold extraction must return None when grammar not ready ---
    //
    // Before the fix (commit edbe2e99), `extract_fold_ranges` returned
    // `Vec::new()` for BOTH "grammar not ready" and "grammar ready but no
    // folds". The caller in `syntax_glue.rs::recompute_and_install` could not
    // distinguish the two cases, so it set `last_fold_dirty_gen = Some(dg)`
    // even when the grammar was still loading. When the grammar finished
    // loading, `dirty_gen` was unchanged → the fold-extraction condition
    // (`last_fold_dg != Some(dg)`) was false → folds were NEVER extracted.
    //
    // The fix changes `extract_fold_ranges` to return `Option<Vec<...>>`:
    // - `None`  = grammar not ready yet — caller must NOT update dirty_gen,
    //             so fold extraction retries on the next recompute.
    // - `Some`  = grammar was ready and ran (ranges may be empty if no
    //             folds.scm or no multi-line nodes).
    //
    // These tests exercise both branches WITHOUT requiring a downloaded grammar.

    #[test]
    fn extract_fold_ranges_returns_none_when_no_language_attached() {
        // Simulates the "grammar still loading" or "unknown extension" state.
        // `extract_fold_ranges` must return `None` so the caller knows NOT to
        // mark the dirty_gen as processed. Before the fix this returned
        // `Vec::new()`, which the caller misinterpreted as "ran successfully,
        // no folds" → dirty_gen stamped → folds never re-tried after load.
        let buf =
            View::from_str("fn hello() {\n    let x = 1;\n    x\n}\n\nfn world() {\n    2\n}\n");
        let mut layer = default_layer();
        // Deliberately use an unknown extension so no grammar is attached.
        layer.set_language_for_path(TID, Path::new("a.zzz_no_grammar_here"));
        let result = layer.extract_fold_ranges(TID, &buf);
        assert!(
            result.is_none(),
            "extract_fold_ranges must return None when no grammar is attached \
             (grammar still loading or unknown extension); got {result:?}"
        );
    }

    #[test]
    fn extract_fold_ranges_returns_none_when_no_client_registered() {
        // View ID with no prior `set_language_for_path` call — no client at all.
        let buf = View::from_str("fn foo() {}\n");
        let mut layer = default_layer();
        // Never called set_language_for_path for TID.
        let result = layer.extract_fold_ranges(TID, &buf);
        assert!(
            result.is_none(),
            "extract_fold_ranges must return None when buffer has no syntax client; \
             got {result:?}"
        );
    }

    // --- Network-dependent tests (grammar needed) ---

    #[test]
    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
    fn parse_and_render_small_rust_buffer() {
        let buf = View::from_str("fn main() { let x = 1; }\n");
        let mut layer = default_layer();
        assert!(
            layer
                .set_language_for_path(TID, Path::new("a.rs"))
                .is_known()
        );
        let out = layer
            .render_viewport(TID, &buf, 0, 10)
            .expect("render output");
        assert!(
            out.spans.iter().any(|r| !r.is_empty()),
            "expected at least one styled span"
        );
    }

    #[test]
    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
    fn diagnostics_emit_sign_for_syntax_error() {
        let buf = View::from_str("fn main() {\nlet x = ;\n}\n");
        let mut layer = default_layer();
        layer.set_language_for_path(TID, Path::new("a.rs"));
        let out = layer.render_viewport(TID, &buf, 0, 10).unwrap();
        assert!(
            !out.signs.is_empty(),
            "expected at least one diagnostic sign for `let x = ;`"
        );
        assert!(
            out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
            "expected an 'E' sign on row 1; got {:?}",
            out.signs
        );
    }

    #[test]
    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
    fn diagnostics_signs_correct_when_scrolled() {
        // Regression: `collect_diag_signs_range` used to pass a
        // window-relative byte range + window-only source to
        // `parse_errors_range`, which filters tree nodes by absolute
        // offsets — so once scrolled it reported errors from the top of
        // the document and shifted the resulting rows by the window
        // offset. The error below sits on rows 50–52; the viewport starts
        // at row 45.
        let mut src = String::new();
        for i in 0..50 {
            let _ = writeln!(src, "fn f{i}() {{}}");
        }
        src.push_str("fn broken() {\nlet x = ;\n}\n");
        let buf = View::from_str(&src);
        let mut layer = default_layer();
        layer.set_language_for_path(TID, Path::new("a.rs"));
        let out = layer.render_viewport(TID, &buf, 45, 20).unwrap();
        assert!(
            out.signs
                .iter()
                .any(|s| (50..=52).contains(&s.row) && s.ch == 'E'),
            "expected an 'E' sign on rows 50..=52; got {:?}",
            out.signs
        );
    }

    /// Regression: `apply_edits` used to clear the parse, row-start and sign
    /// caches but leave `cache_spans`, relying on the buffer's `dirty_gen`
    /// having moved by the time `render_viewport` next ran. Both buffers here
    /// are freshly constructed and so share a `dirty_gen`, which is what makes
    /// the omission visible: the incremental render returned the PRE-edit span
    /// table, one byte short across the board and without the `@type` capture
    /// that `Ymain`'s new capital earns.
    #[test]
    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
    fn incremental_path_matches_cold_for_small_edit() {
        let pre = View::from_str("fn main() { let x = 1; }");
        let mut layer = default_layer();
        layer.set_language_for_path(TID, Path::new("a.rs"));
        let _ = layer.render_viewport(TID, &pre, 0, 10).unwrap();
        layer.apply_edits(
            TID,
            &[hjkl_engine::ContentEdit {
                start_byte: 3,
                old_end_byte: 3,
                new_end_byte: 4,
                start_position: (0, 3),
                old_end_position: (0, 3),
                new_end_position: (0, 4),
            }],
        );
        let post = View::from_str("fn Ymain() { let x = 1; }");
        let inc = layer.render_viewport(TID, &post, 0, 10).unwrap();
        let mut cold_layer = default_layer();
        cold_layer.set_language_for_path(TID, Path::new("a.rs"));
        let cold = cold_layer.render_viewport(TID, &post, 0, 10).unwrap();
        assert_eq!(inc.spans, cold.spans);
    }

    /// Fold ranges for `buf`, waiting for the grammar to become available.
    ///
    /// `set_language_for_path` may only START a grammar load: on a machine with
    /// a warm `~/.cache/bonsai` the grammar is ready by the first
    /// `render_viewport`, but on a cold one — CI, which fetches and compiles it
    /// — `extract_fold_ranges` answers `None` for as long as the load is in
    /// flight. A single call plus `.expect("grammar ready")` therefore passes
    /// locally and panics in the `grammar tests` lane, which is exactly how
    /// these tests shipped red.
    ///
    /// Polls `poll_pending_loads` until extraction answers, then returns the
    /// ranges.
    ///
    /// The events `poll_pending_loads` returns are the whole point of reading
    /// them rather than discarding them: a load that *fails* removes itself
    /// from the pending list and emits `LoadEvent::Failed`, after which
    /// nothing is in flight and `extract_fold_ranges` answers `None`
    /// forever. Dropping the events made a hard failure look exactly like
    /// slow progress — the first version of this helper spun to a 300s
    /// deadline and reported "the load either failed or is not being polled",
    /// which is a confession that it could not tell, and it threw away the
    /// error text that says which. Fail on `Failed`, with the cause.
    fn fold_ranges_when_ready(
        layer: &mut SyntaxLayer,
        buf: &View,
        path: &str,
        rows: usize,
    ) -> Vec<(usize, usize)> {
        fold_ranges_when_ready_for(layer, TID, buf, path, rows)
    }

    /// [`fold_ranges_when_ready`] against an explicit buffer id, for a test
    /// that needs the grammar warmed WITHOUT touching the client state of the
    /// id it measures — the injected-fold memo is per buffer client, so
    /// warming on the same id would leave nothing for a cold pass to parse.
    fn fold_ranges_when_ready_for(
        layer: &mut SyntaxLayer,
        id: BufferId,
        buf: &View,
        path: &str,
        rows: usize,
    ) -> Vec<(usize, usize)> {
        let outcome = layer.set_language_for_path(id, Path::new(path));
        assert!(
            outcome.is_known(),
            "no grammar is registered for {path} — the test can never succeed"
        );
        let deadline = std::time::Duration::from_secs(300);
        let start = std::time::Instant::now();
        loop {
            for event in layer.poll_pending_loads() {
                if let LoadEvent::Failed { name, error, .. } = event {
                    panic!("grammar load for `{name}` ({path}) failed: {error}");
                }
            }
            let _ = layer.render_viewport(id, buf, 0, rows);
            if let Some(ranges) = layer.extract_fold_ranges(id, buf) {
                return ranges;
            }
            assert!(
                start.elapsed() < deadline,
                "grammar for {path} never became ready within {deadline:?}, \
                 and no load reported a failure — it is still building, or \
                 nothing was ever queued"
            );
            std::thread::sleep(std::time::Duration::from_millis(100));
        }
    }

    /// Markdown fold ranges, pinned against neovim's treesitter folds for the
    /// same document (`vim.treesitter.foldexpr`, folds enumerated with
    /// `foldclosed`/`foldclosedend`). Every range below was produced by nvim
    /// on this exact text.
    ///
    /// Before the fix hjkl returned `4..14`, `14..23`, `16..19`, `19..23`,
    /// `23..26` and `8..11` — each one row or more too long, so a closed
    /// section hid the NEXT section's heading and the last fold ran past the
    /// end of the buffer.
    #[test]
    #[ignore = "network + compiler: fetches the markdown grammar"]
    fn markdown_fold_ranges_match_neovim() {
        let src = concat!(
            "# Title\n\nIntro paragraph.\n\n",
            "## Section A\n\nText in A.\n\n```bash\nls -la\n```\n\nMore A text.\n\n",
            "## Section B\n\n- item one\n- item two\n\n",
            "### Nested B1\n\nDeep text.\n\n",
            "## Section C\n\nLast.\n",
        );
        let buf = View::from_str(src);
        let mut layer = default_layer();
        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
        assert_eq!(
            ranges,
            vec![
                (0, 25),  // # Title — to the last line, not one past it
                (4, 12),  // ## Section A — ends on "More A text.", not on "## Section B"
                (8, 10),  // the fenced block — ends on its closing fence
                (14, 21), // ## Section B
                (16, 17), // the list
                (19, 21), // ### Nested B1
                (23, 25), // ## Section C
            ]
        );
    }

    // ── Folds inside injected languages ─────────────────────────────────
    //
    // The fixtures below are the ones the 2026-08-03 injection work was
    // measured on. neovim's side was enumerated the same way as every other
    // fold test here (`vim.treesitter.foldexpr`, `foldclosed`/`foldclosedend`
    // per `foldlevel`), with one addition that is easy to get wrong: a
    // headless nvim parses injections LAZILY, so the run must force
    // `vim.treesitter.get_parser(0):parse(true)` before enumerating. Without
    // it nvim reports only the host language's folds (152 instead of 173 on
    // `.github/workflows/ci.yml`) and reads as agreement.

    /// A markdown document with ` ```rust ` and ` ```bash ` blocks.
    ///
    /// nvim on this fixture: `0,20`  `4,10`  `5,9`  `6,8`  `12,20`  `14,18`
    /// `15,17` — identical to the assertion below.
    ///
    /// Three of those seven are inside the fenced blocks and come from the
    /// INJECTED grammars: `(5, 9)` is `fn main()`, `(6, 8)` its `if`, and
    /// `(15, 17)` the bash `for … done`. Before injected folds hjkl emitted
    /// only the other four (`(0, 20)`, `(4, 10)`, `(12, 20)`, `(14, 18)`).
    ///
    /// The rows are also the proof that the region → host row offset is
    /// applied: the rust block's own tree puts those folds at rows 0..4 and
    /// 1..3, and the bash block's at 0..2. Drop the offset and the expected
    /// vector below cannot be produced.
    #[test]
    #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
    fn markdown_injected_fold_ranges_match_neovim() {
        let src = concat!(
            "# Title\n\nIntro text.\n\n",
            "```rust\nfn main() {\n    if true {\n        println!(\"hi\");\n    }\n}\n```\n\n",
            "## Shell\n\n",
            "```bash\nfor f in a b; do\n  echo \"$f\"\ndone\n```\n\n",
            "Trailing text.\n",
        );
        let buf = View::from_str(src);
        let mut layer = default_layer();
        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
        assert_eq!(
            ranges,
            vec![
                (0, 20),  // # Title
                (4, 10),  // the ```rust fence
                (5, 9),   // injected: fn main()
                (6, 8),   // injected: the if
                (12, 20), // ## Shell
                (14, 18), // the ```bash fence
                (15, 17), // injected: for … done
            ]
        );
    }

    /// Injected folds must follow their region when rows move above it.
    ///
    /// The memo behind injected folds (`InjectedFoldCache`) is keyed by the
    /// region's CONTENT, and stores region-relative rows. Inserting a line at
    /// the top of the document leaves every region's bytes identical — so the
    /// memo hits — while every host row shifts by one. If the memo stored host
    /// rows, or the offset were applied before storing instead of after
    /// reading, the second extraction below would return the FIRST document's
    /// rows for the injected folds and only the host's would move.
    #[test]
    #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
    fn injected_folds_shift_with_the_region_on_a_memo_hit() {
        let body = concat!(
            "# Title\n\nIntro text.\n\n",
            "```rust\nfn main() {\n    if true {\n        println!(\"hi\");\n    }\n}\n```\n\n",
            "## Shell\n\n",
            "```bash\nfor f in a b; do\n  echo \"$f\"\ndone\n```\n\n",
            "Trailing text.\n",
        );
        let mut layer = default_layer();
        let before = View::from_str(body);
        // Warm the markdown/rust/bash grammars on a DIFFERENT buffer id: on a
        // cold cache (CI) the first extraction answers None until the loads
        // finish, but warming on TID would also fill TID's injected-fold memo
        // and leave the "cold" count below at zero.
        const WARM: BufferId = TID + 1;
        let _ = fold_ranges_when_ready_for(&mut layer, WARM, &before, "a.md", 40);
        layer.forget(WARM);

        layer.set_language_for_path(TID, Path::new("a.md"));
        let _ = layer.render_viewport(TID, &before, 0, 40);
        hjkl_bonsai::injected_parse_counter::reset();
        let first = layer
            .extract_fold_ranges(TID, &before)
            .expect("grammar ready after the wait above");
        assert_eq!(
            hjkl_bonsai::injected_parse_counter::get(),
            2,
            "cold extraction must parse both injected regions (rust + bash)"
        );

        // Same document with one extra line on top. Same buffer id, same
        // layer — the injected-region memo is live and every region's content
        // is byte-identical, so this must be the memo-hit path.
        let shifted = View::from_str(&format!("Added line.\n{body}"));
        layer.reset(TID);
        let _ = layer.render_viewport(TID, &shifted, 0, 40);
        hjkl_bonsai::injected_parse_counter::reset();
        let second = layer
            .extract_fold_ranges(TID, &shifted)
            .expect("grammar ready after the wait above");
        assert_eq!(
            hjkl_bonsai::injected_parse_counter::get(),
            0,
            "moved-but-unchanged regions must come from the memo, not a reparse"
        );

        let expected: Vec<(usize, usize)> = first.iter().map(|&(s, e)| (s + 1, e + 1)).collect();
        assert_eq!(
            second, expected,
            "every fold, injected ones included, must move down exactly one row"
        );
    }

    /// An HTML page with a `<style>` and a `<script>` block.
    ///
    /// nvim on this fixture: `1,23`  `2,9`  `3,8`  `4,7`  `10,22`  `11,13`
    /// `14,21`  `15,20`  `16,18` — identical to the assertion below.
    ///
    /// `(4, 7)` is the CSS rule inside `<style>`; `(15, 20)` and `(16, 18)`
    /// are the JS function and its `if` inside `<script>`. hjkl emitted none
    /// of the three before injected folds.
    #[test]
    #[ignore = "network + compiler: fetches the html, css and javascript grammars"]
    fn html_injected_fold_ranges_match_neovim() {
        let src = concat!(
            "<!DOCTYPE html>\n<html>\n  <head>\n",
            "    <style>\n      body {\n        color: red;\n        margin: 0;\n      }\n",
            "    </style>\n  </head>\n  <body>\n",
            "    <div>\n      <p>hi</p>\n    </div>\n",
            "    <script>\n      function go(x) {\n        if (x) {\n",
            "          return 1;\n        }\n        return 0;\n      }\n",
            "    </script>\n  </body>\n</html>\n",
        );
        let buf = View::from_str(src);
        let mut layer = default_layer();
        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.html", 40);
        assert_eq!(
            ranges,
            vec![
                (1, 23),  // <html>
                (2, 9),   // <head>
                (3, 8),   // <style>
                (4, 7),   // injected css: the body rule
                (10, 22), // <body>
                (11, 13), // <div>
                (14, 21), // <script>
                (15, 20), // injected js: function go()
                (16, 18), // injected js: the if
            ]
        );
    }

    /// YAML folds are anchored on the pair / sequence item, matching neovim.
    /// Capturing `(block_mapping)` instead put the fold on the container,
    /// which starts at its FIRST CHILD: `top:` never got a fold of its own,
    /// and the document-level mapping produced one fold starting at the first
    /// key that swallowed every sibling below it (`0..7` here).
    #[test]
    #[ignore = "network + compiler: fetches the yaml grammar"]
    fn yaml_fold_ranges_match_neovim() {
        let src = "top:\n  a: 1\n  b:\n    - one\n    - two\n\nother:\n  c: 3\n";
        let buf = View::from_str(src);
        let mut layer = default_layer();
        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.yaml", 40);
        assert_eq!(ranges, vec![(0, 4), (2, 4), (6, 7)]);
    }

    // ── Fold ranges pinned against neovim, per language ──────────────────
    //
    // Every expectation below was measured on neovim 0.12.4 with
    // `vim.treesitter.foldexpr()` over these exact fixtures, enumerating the
    // real folds with `foldclosed`/`foldclosedend` at each `foldlevel` (a
    // `foldlevel()` array merges adjacent siblings into one run and reads as
    // a false difference). Rows are 0-based inclusive, like hjkl's.
    //
    // Where hjkl emits ranges neovim does not, it is because hjkl's bundled
    // query captures a different SET of node types on purpose — the comment
    // on each test says which. The ranges themselves agree everywhere.

    /// Extract hjkl's fold ranges for `src`, using `name` only to pick the
    /// grammar by extension.
    fn folds_for(name: &str, src: &str) -> Vec<(usize, usize)> {
        let buf = View::from_str(src);
        let mut layer = default_layer();
        fold_ranges_when_ready(&mut layer, &buf, name, 80)
    }

    /// nvim on this fixture: `(2, 4)`, `(6, 10)`, `(7, 9)` — identical.
    #[test]
    #[ignore = "network + compiler: fetches the go grammar"]
    fn go_fold_ranges_match_neovim() {
        let src = concat!(
            "package main\n\n",
            "import (\n  \"fmt\"\n)\n\n",
            "func main() {\n  if true {\n    fmt.Println(\"x\")\n  }\n}\n",
        );
        assert_eq!(folds_for("a.go", src), vec![(2, 4), (6, 10), (7, 9)]);
    }

    /// nvim on this fixture: `(0, 2)`, `(4, 9)`, `(5, 7)` — identical.
    #[test]
    #[ignore = "network + compiler: fetches the c grammar"]
    fn c_fold_ranges_match_neovim() {
        let src = concat!(
            "struct P {\n  int x;\n};\n\n",
            "int main(void) {\n  for (int i = 0; i < 2; i++) {\n    i++;\n  }\n  return 0;\n}\n",
        );
        assert_eq!(folds_for("a.c", src), vec![(0, 2), (4, 9), (5, 7)]);
    }

    /// nvim on this fixture: `(0, 9)`, `(2, 7)`, `(4, 6)` — identical.
    #[test]
    #[ignore = "network + compiler: fetches the cpp grammar"]
    fn cpp_fold_ranges_match_neovim() {
        let src = concat!(
            "namespace n {\n\n",
            "class B {\npublic:\n  int get() {\n    return 1;\n  }\n};\n\n",
            "}\n",
        );
        assert_eq!(folds_for("a.cpp", src), vec![(0, 9), (2, 7), (4, 6)]);
    }

    /// nvim on this fixture: `(0, 7)`, `(1, 5)`, `(3, 5)` — identical.
    ///
    /// Regression: `folds/cpp.scm` used to capture neither `(try_statement)`
    /// nor `(catch_clause)`, so the only fold anchored on `try {` was the try
    /// body's `(compound_statement)` — `(1, 3)` here, ending at the `}` that
    /// opens the catch instead of at the end of the whole statement. The
    /// catch clause got no fold of its own at all.
    #[test]
    #[ignore = "network + compiler: fetches the cpp grammar"]
    fn cpp_try_catch_fold_ranges_match_neovim() {
        let src = concat!(
            "int main() {\n",
            "  try {\n    f();\n",
            "  } catch (int e) {\n    g();\n  }\n",
            "  return 0;\n}\n",
        );
        assert_eq!(folds_for("a.cpp", src), vec![(0, 7), (1, 5), (3, 5)]);
    }

    /// nvim on this fixture: `(0, 7)`, `(1, 6)`, `(2, 4)` — identical.
    ///
    /// The anchors agree only because the braces are K&R. neovim folds Java's
    /// `(class_body)` / `(block)`, which start ON the `{`; hjkl folds
    /// `(class_declaration)` / `(method_declaration)`, which start on the
    /// signature — and, when annotations precede it, on the FIRST annotation.
    /// See `docs/backlog.md` §1.4b.
    #[test]
    #[ignore = "network + compiler: fetches the java grammar"]
    fn java_fold_ranges_match_neovim() {
        let src = concat!(
            "public class A {\n",
            "  public int run(int x) {\n",
            "    if (x > 0) {\n      return x;\n    }\n",
            "    return 0;\n  }\n}\n",
        );
        assert_eq!(folds_for("A.java", src), vec![(0, 7), (1, 6), (2, 4)]);
    }

    /// nvim on this fixture: `(2, 8)`, `(4, 7)`. hjkl adds `(5, 7)` — the
    /// Allman-braced `(compound_statement)` body, which neovim's PHP query
    /// does not capture at all. The two shared ranges are identical.
    #[test]
    #[ignore = "network + compiler: fetches the php grammar"]
    fn php_fold_ranges_match_neovim() {
        let src = concat!(
            "<?php\n\n",
            "class R\n{\n",
            "  public function area(): float\n  {\n    return 1.0;\n  }\n}\n",
        );
        assert_eq!(folds_for("a.php", src), vec![(2, 8), (4, 7), (5, 7)]);
    }

    /// nvim on this fixture: `(0, 6)`, `(1, 5)`, `(2, 4)` — identical.
    #[test]
    #[ignore = "network + compiler: fetches the ruby grammar"]
    fn ruby_fold_ranges_match_neovim() {
        let src = "module M\n  class R\n    def area\n      1\n    end\n  end\nend\n";
        assert_eq!(folds_for("a.rb", src), vec![(0, 6), (1, 5), (2, 4)]);
    }

    /// Regression: C# used to fold NOTHING.
    ///
    /// `bonsai.toml` has both `[language.c-sharp]` and `[language.c_sharp]`
    /// for the same grammar, and `GrammarRegistry` resolves an extension to
    /// the alphabetically first entry — so a `.cs` buffer loads under the name
    /// `c-sharp`, while `builtin_folds` was keyed only on `c_sharp` and
    /// returned `None`. This assertion was `[]` before the fix, against
    /// neovim's four folds below.
    ///
    /// nvim on this fixture: `(1, 13)`, `(3, 12)`, `(5, 11)`, `(7, 9)` — it
    /// anchors on the Allman `{` because its C# query captures
    /// `body: (declaration_list)` and `(block)`. hjkl also captures the
    /// declaration nodes, so it anchors one row earlier on the `namespace` /
    /// `class` / method signature and keeps neovim's brace-anchored ranges
    /// too. Every neovim range appears here.
    #[test]
    #[ignore = "network + compiler: fetches the c-sharp grammar"]
    fn c_sharp_fold_ranges_match_neovim() {
        let src = concat!(
            "namespace Demo\n{\n",
            "  public class R\n  {\n",
            "    public int Area()\n    {\n",
            "      if (true)\n      {\n        return 1;\n      }\n",
            "      return 0;\n    }\n  }\n}\n",
        );
        assert_eq!(
            folds_for("P.cs", src),
            vec![(0, 13), (2, 12), (4, 11), (5, 11), (6, 9), (7, 9)]
        );
    }

    /// nvim on this fixture: `(0, 4)`, `(1, 3)`, `(6, 11)`, `(7, 9)` —
    /// identical.
    #[test]
    #[ignore = "network + compiler: fetches the javascript grammar"]
    fn javascript_fold_ranges_match_neovim() {
        let src = concat!(
            "class R {\n  area() {\n    return 1;\n  }\n}\n\n",
            "function run(xs) {\n  const t = {\n    a: 1,\n  };\n  return t;\n}\n",
        );
        assert_eq!(
            folds_for("a.js", src),
            vec![(0, 4), (1, 3), (6, 11), (7, 9)]
        );
    }

    /// nvim on this fixture: `(0, 2)`, `(4, 6)`, `(8, 10)`, `(12, 14)` —
    /// identical. Covers the TypeScript-only nodes: `(interface_declaration)`,
    /// `(type_alias_declaration)` with an `(object_type)` body, and
    /// `(enum_declaration)`.
    #[test]
    #[ignore = "network + compiler: fetches the typescript grammar"]
    fn typescript_fold_ranges_match_neovim() {
        let src = concat!(
            "interface S {\n  area(): number;\n}\n\n",
            "type H = {\n  name: string;\n};\n\n",
            "enum C {\n  Red,\n}\n\n",
            "function run(): number {\n  return 1;\n}\n",
        );
        assert_eq!(
            folds_for("a.ts", src),
            vec![(0, 2), (4, 6), (8, 10), (12, 14)]
        );
    }

    #[test]
    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
    fn forget_drops_buffer_state() {
        let buf = View::from_str("fn main() {}");
        let mut layer = default_layer();
        layer.set_language_for_path(TID, Path::new("a.rs"));
        let _ = layer.render_viewport(TID, &buf, 0, 10).unwrap();
        assert!(layer.clients.contains_key(&TID));
        layer.forget(TID);
        assert!(!layer.clients.contains_key(&TID));
    }
}
