//! LSP glue — bridges `App` state with `hjkl_lsp::LspManager`.

use std::path::PathBuf;

use hjkl_buffer::floor_char_boundary;
use hjkl_buffer_tui::Sign;
use ratatui::style::{Color, Modifier, Style};
use serde_json::json;

use crate::completion::{Completion, item_from_lsp};

use super::{App, DiagSeverity, LspDiag, LspPendingRequest, LspServerInfo};

/// Default timeout before the sweep drops an unanswered pending request
/// (clearing the status spinner). Generous enough for a cold rust-analyzer
/// goto, short enough that a dead/misconfigured server doesn't spin forever.
const LSP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);

/// Shorter timeout for auto-fired completion: it should feel instant, and the
/// popup already shows buffer words, so an unresponsive server (e.g. taplo on
/// TOML) shouldn't keep the spinner lit for long.
const LSP_AUTO_COMPLETION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);

/// Per-request timeout used by the stale-pending sweep.
fn pending_request_timeout(req: &LspPendingRequest) -> std::time::Duration {
    match req {
        LspPendingRequest::Completion { auto: true, .. } => LSP_AUTO_COMPLETION_TIMEOUT,
        _ => LSP_REQUEST_TIMEOUT,
    }
}

/// Resolve a (possibly relative) buffer path against `current_dir` so the
/// resulting `PathBuf` is absolute. `url::Url::from_file_path` (used by
/// `hjkl_lsp::uri::from_path`) requires an absolute path; without this
/// helper, opening hjkl with a relative path like `apps/hjkl/src/main.rs`
/// silently fails to attach to the LSP server.
fn absolutize(p: &std::path::Path) -> PathBuf {
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        std::env::current_dir()
            .ok()
            .map_or_else(|| p.to_path_buf(), |cwd| cwd.join(p))
    }
}

/// JSON-pointer to the server-capability that gates an LSP request `method`,
/// or `None` for methods that need no capability check (e.g. notifications or
/// `workspace/executeCommand`). Used to skip requests a server can't service.
fn capability_pointer_for_method(method: &str) -> Option<&'static str> {
    Some(match method {
        "textDocument/definition" => "/definitionProvider",
        "textDocument/declaration" => "/declarationProvider",
        "textDocument/typeDefinition" => "/typeDefinitionProvider",
        "textDocument/implementation" => "/implementationProvider",
        "textDocument/references" => "/referencesProvider",
        "textDocument/hover" => "/hoverProvider",
        "textDocument/completion" => "/completionProvider",
        "textDocument/codeAction" => "/codeActionProvider",
        "textDocument/rename" => "/renameProvider",
        "textDocument/formatting" => "/documentFormattingProvider",
        _ => return None,
    })
}

// ── Position-encoding conversion (audit R2, UTF-16 fix) ────────────────────
//
// hjkl's internal `Position`/`Pos` columns (cursor, diagnostics, goto
// targets, workspace-edit ranges — everything EXCEPT the incremental
// `didChange` path's `ContentEdit`/`TextChange`, which are byte-indexed and
// already gated on negotiated UTF-8, see `build_text_changes`) are **char
// indices**: the Nth Unicode scalar value on the line, not the Nth byte and
// not the Nth UTF-16 code unit. See `hjkl_buffer::Position`'s doc comment and
// `hjkl_engine::buffer_impl::pos_to_position`.
//
// The LSP wire format never speaks char indices: `character` is either a
// UTF-8 byte offset (encoding `"utf-8"`) or a UTF-16 code-unit offset
// (encoding `"utf-16"`, the spec default). For any line containing a
// character whose UTF-8/UTF-16 encoded length differs from 1 — i.e. anything
// outside ASCII — a raw cast between hjkl's char index and the wire
// `character` field is wrong. These two helpers are the only place that
// conversion happens; every LSP-JSON crossing that carries a `character`
// field must go through one of them with the server's negotiated
// [`hjkl_lsp::PositionEncoding`].

/// Convert an internal char-index column on `line` to the wire unit
/// (`character` field) expected by `encoding`.
///
/// `col_chars` past the line's char count is harmless: `Chars::take` simply
/// stops early, so the full line's wire length is returned (defensive
/// clamping — callers should never construct such a column, but a stale
/// cursor snapshot racing a concurrent edit is cheap insurance).
pub fn col_to_wire(line: &str, col_chars: usize, encoding: hjkl_lsp::PositionEncoding) -> u32 {
    line.chars()
        .take(col_chars)
        .map(|c| match encoding {
            hjkl_lsp::PositionEncoding::Utf8 => c.len_utf8(),
            hjkl_lsp::PositionEncoding::Utf16 => c.len_utf16(),
        })
        .sum::<usize>() as u32
}

/// Convert a wire `character` column on `line` (in `encoding`'s units) back
/// to hjkl's internal char index.
///
/// Sums each char's encoded width until the running total reaches
/// `wire_col`, returning that char's index. A `wire_col` that lands in the
/// middle of a multi-unit char (shouldn't happen for a spec-compliant
/// server, but nothing stops a buggy one) resolves to the START of that
/// char, matching how `floor_char_boundary` handles the same situation on
/// the byte side. A `wire_col` at or past the line's total encoded length
/// (server overshoot) clamps to the line's char count — one past the last
/// char, same "insert mode lives there" convention `Position` uses.
pub fn wire_to_col(line: &str, wire_col: u32, encoding: hjkl_lsp::PositionEncoding) -> usize {
    let target = wire_col as usize;
    let mut consumed = 0usize;
    for (idx, c) in line.chars().enumerate() {
        if consumed == target {
            return idx;
        }
        let width = match encoding {
            hjkl_lsp::PositionEncoding::Utf8 => c.len_utf8(),
            hjkl_lsp::PositionEncoding::Utf16 => c.len_utf16(),
        };
        // `target` lands strictly inside this char (e.g. between the two
        // UTF-16 units of a surrogate pair) — floor to this char's start,
        // matching `floor_char_boundary`'s floor convention on the byte
        // side rather than overshooting into the next char.
        if consumed + width > target {
            return idx;
        }
        consumed += width;
    }
    line.chars().count()
}

/// Build an `hjkl_engine::Pos` from a wire-unit LSP `Position`, converting
/// `character` to hjkl's internal char index via [`wire_to_col`] using
/// `rope`'s CURRENT line text for `line`. Used at the workspace-edit /
/// formatting-edit application boundary — the single most dangerous crossing
/// in this fix, since an unconverted UTF-16 column fed straight into
/// [`hjkl_engine::BufferEdit::replace_range`] silently slices the wrong
/// bytes out of the buffer (audit R2's motivating corruption case).
///
/// Falls back to the raw wire value when `line` is out of range for `rope`:
/// `BufferEdit::replace_range` already rejects an out-of-bounds `Pos` as a
/// no-op (see `hjkl_buffer::Position`'s doc comment), so an unconvertible
/// column on an already-invalid row is harmless — and reading a
/// nonexistent line here would panic instead (`ropey::Rope::line` panics
/// out of range), which the pre-fix code never risked since it never
/// touched the rope to interpret a column.
fn wire_position_to_pos(
    rope: &ropey::Rope,
    line: u32,
    character: u32,
    encoding: hjkl_lsp::PositionEncoding,
) -> hjkl_engine::Pos {
    let col = if (line as usize) < rope.len_lines() {
        let text = hjkl_buffer::rope_line_str(rope, line as usize);
        wire_to_col(&text, character, encoding) as u32
    } else {
        character
    };
    hjkl_engine::Pos { line, col }
}

/// True when `edits`, applied in the given array order, are ascending and
/// non-overlapping in byte space: each edit's `start_byte` is at or past
/// the previous edit's `new_end_byte`.
///
/// Only a batch that satisfies this can [`build_text_changes`] safely
/// slice replacement text from the POST-edit rope (see its doc comment):
/// for such a batch, everything recorded after edit *i* sits strictly to
/// its right, so nothing shifts edit *i*'s `[start_byte, new_end_byte)`
/// between the moment it was recorded and the final rope.
///
/// A batch recorded in end-DESCENDING application order — e.g.
/// `apply_workspace_edit`'s deliberate sort (so applying a later edit
/// doesn't shift an earlier edit's positions), or a block-delete fan-out —
/// fails this check: edits recorded earlier sit to the RIGHT of edits
/// applied after them, so later (leftward) edits shift the earlier ones'
/// byte ranges in the final rope out from under this slicing approach.
fn edits_ascending_disjoint(edits: &[hjkl_engine::ContentEdit]) -> bool {
    edits
        .windows(2)
        .all(|w| w[0].new_end_byte <= w[1].start_byte)
}

/// Build the `TextChange[]` array for `textDocument/didChange` incremental
/// sync from the engine's `ContentEdit` batch.
///
/// LSP positions are interpreted relative to the document state *before*
/// each change is applied (and *after* every preceding change in the same
/// array). Our `ContentEdit`s were recorded against the buffer's evolving
/// state during the edit run, so they already satisfy this contract — we
/// just need the replacement text. Slice it directly from the rope via
/// `byte_slice(start..end).to_string()`: ropey returns a `RopeSlice` in
/// O(log N) and converts only the slice's bytes to a `String` — no
/// document-wide allocation. Replaces the prior path which forced a
/// full `content_joined()` build (~3 MB on a 1.86 M-line file, ~15 % of
/// per-keystroke CPU when LSP was attached).
///
/// Slicing from the FINAL rope is only correct when the batch is
/// [`edits_ascending_disjoint`] — returns `None` otherwise (e.g. for
/// `apply_workspace_edit`'s end-descending batches) so the caller falls
/// back to a full-document sync instead of deriving the wrong replacement
/// text (audit R2, fix 2).
///
/// Caller MUST verify the server uses UTF-8 `positionEncoding`; this
/// function passes byte columns straight through.
fn build_text_changes(
    rope: &ropey::Rope,
    edits: &[hjkl_engine::ContentEdit],
) -> Option<Vec<hjkl_lsp::TextChange>> {
    if !edits_ascending_disjoint(edits) {
        return None;
    }
    let len_bytes = rope.len_bytes();
    Some(
        edits
            .iter()
            .map(|e| {
                // Clamp then snap to char boundaries: `.min(len_bytes)` can land
                // in the middle of a multi-byte char (e.g. on emoji/CJK content),
                // causing `byte_slice` to panic. `floor_char_boundary` floors
                // each bound to the start of the char that contains it.
                let start = floor_char_boundary(rope, e.start_byte.min(len_bytes));
                let end = floor_char_boundary(rope, e.new_end_byte.min(len_bytes)).max(start);
                let text = rope.byte_slice(start..end).to_string();
                hjkl_lsp::TextChange {
                    start_line: e.start_position.0,
                    start_col: e.start_position.1,
                    end_line: e.old_end_position.0,
                    end_col: e.old_end_position.1,
                    text,
                }
            })
            .collect(),
    )
}

/// Extensions whose LSP language id differs from the tree-sitter grammar name
/// the bonsai manifest resolves them to. Everything else goes through the
/// registry ([`language_id_for_ext`]) — the two vocabularies agree for the
/// overwhelming majority of languages.
///
/// - `tsx` / `jsx`: tree-sitter ships separate `tsx` / `jsx` grammars, but
///   tsserver speaks `typescript` / `javascript` (and that's the key users
///   write in `[lsp.servers.*]`).
/// - `h`: the manifest routes `.h` to the `cpp` grammar (the superset parses
///   both), but a C header should attach to the `c` server.
fn language_id_override(ext: &str) -> Option<&'static str> {
    Some(match ext {
        "tsx" => "typescript",
        "jsx" => "javascript",
        "h" => "c",
        _ => return None,
    })
}

/// File extension → LSP language id, resolved through the hjkl-lang registry
/// (the bonsai manifest) with a small override table layered on top.
///
/// Using the registry — the same source of truth that drives syntax
/// highlighting and commentstrings — means a language added to the manifest
/// can attach an LSP server as soon as one is configured for it, instead of
/// silently getting highlighting only because a hand-maintained table in this
/// file never heard of it.
pub(super) fn language_id_for_ext(
    directory: &hjkl_lang::LanguageDirectory,
    ext: &str,
) -> Option<String> {
    let lower = ext.to_ascii_lowercase();
    if let Some(id) = language_id_override(&lower) {
        return Some(id.to_string());
    }
    directory.name_for_ext(&lower)
}

/// Language-name → LSP language id — the name counterpart of
/// [`language_id_for_ext`]. The tree-sitter grammar name and the LSP id agree
/// for almost every language; the exceptions mirror the extension overrides:
/// `tsx` → `typescript`, `jsx` → `javascript`, and `cpp` → `c` (the grammar
/// the manifest routes `.h` to, whose filetype wants the c server).
fn language_id_for_name(name: &str) -> String {
    match name {
        "tsx" => "typescript".to_string(),
        "jsx" => "javascript".to_string(),
        "cpp" => "c".to_string(),
        other => other.to_string(),
    }
}

/// Convert a `lsp_types::DiagnosticSeverity` to our `DiagSeverity`.
fn convert_severity(s: Option<lsp_types::DiagnosticSeverity>) -> DiagSeverity {
    match s {
        Some(lsp_types::DiagnosticSeverity::ERROR) => DiagSeverity::Error,
        Some(lsp_types::DiagnosticSeverity::WARNING) => DiagSeverity::Warning,
        Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagSeverity::Info,
        Some(lsp_types::DiagnosticSeverity::HINT) => DiagSeverity::Hint,
        _ => DiagSeverity::Error, // default unknown to Error
    }
}

/// Style for a gutter sign by severity.
fn severity_sign(sev: DiagSeverity) -> (char, Style) {
    match sev {
        DiagSeverity::Error => (
            'E',
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        ),
        DiagSeverity::Warning => (
            'W',
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        DiagSeverity::Info => ('I', Style::default().fg(Color::Blue)),
        DiagSeverity::Hint => ('H', Style::default().fg(Color::Cyan)),
    }
}

/// Priority for gutter signs by severity (lower number = higher priority).
fn severity_priority(sev: DiagSeverity) -> u8 {
    match sev {
        DiagSeverity::Error => 100,
        DiagSeverity::Warning => 80,
        DiagSeverity::Info => 60,
        DiagSeverity::Hint => 40,
    }
}

impl App {
    /// Drain all pending LSP events and dispatch them.
    /// Called at the top of every event-loop iteration.
    pub fn drain_lsp_events(&mut self) {
        // Collect events first to avoid holding the borrow on self.lsp
        // while we mutate self in handlers.
        let events: Vec<hjkl_lsp::LspEvent> = if let Some(ref mgr) = self.lsp {
            let mut v = Vec::new();
            while let Some(evt) = mgr.try_recv_event() {
                v.push(evt);
            }
            v
        } else {
            return;
        };

        for evt in events {
            match evt {
                hjkl_lsp::LspEvent::ServerInitialized { key, capabilities } => {
                    tracing::info!(?key, "lsp server initialized");
                    self.lsp_state.insert(
                        key,
                        LspServerInfo {
                            initialized: true,
                            capabilities,
                        },
                    );
                }
                hjkl_lsp::LspEvent::ServerExited { key, status } => {
                    tracing::warn!(?key, ?status, "lsp server exited");
                    self.lsp_state.remove(&key);
                    if let Some(mgr) = &self.lsp {
                        mgr.server_exited(key);
                    }
                }
                hjkl_lsp::LspEvent::Notification {
                    key,
                    method,
                    params,
                } => {
                    tracing::debug!(?key, method, "lsp notification");
                    if method == "textDocument/publishDiagnostics" {
                        // The server that emitted this notification IS `key`
                        // — look its negotiated encoding up directly rather
                        // than via a slot/language detour (audit R2, UTF-16
                        // fix).
                        let encoding = self
                            .lsp_state
                            .get(&key)
                            .map(|info| {
                                hjkl_lsp::PositionEncoding::from_capabilities(&info.capabilities)
                            })
                            .unwrap_or_default();
                        self.handle_publish_diagnostics(params, encoding);
                    }
                }
                hjkl_lsp::LspEvent::Response { request_id, result } => {
                    tracing::debug!(request_id, "lsp response");
                    if let Some(pending) = self.lsp_pending.remove(&request_id) {
                        self.handle_lsp_response(pending, result);
                    }
                }
            }
        }

        // Drop pending requests whose server exited or never answered, so the
        // "LSP:…" status spinner can't hang forever (e.g. a misconfigured TOML
        // server that exits without responding).
        self.sweep_stale_lsp_pending_at(std::time::Instant::now());
    }

    /// Stamp newly-seen pending requests with `now`, then drop any that have
    /// outlived their per-request timeout (or whose id is no longer pending).
    /// Split from the wall-clock so it can be unit-tested with a controlled
    /// clock.
    pub(crate) fn sweep_stale_lsp_pending_at(&mut self, now: std::time::Instant) {
        // Record first-sight time for any request not yet tracked.
        let ids: Vec<i64> = self.lsp_pending.keys().copied().collect();
        for id in ids {
            self.lsp_pending_seen_at.entry(id).or_insert(now);
        }
        // Collect ids to drop: resolved (no longer pending) or timed out.
        let mut drop_ids: Vec<i64> = Vec::new();
        for (id, seen) in self.lsp_pending_seen_at.iter() {
            let timed_out = match self.lsp_pending.get(id) {
                Some(req) => now.saturating_duration_since(*seen) >= pending_request_timeout(req),
                None => true, // already resolved — clean up its timestamp
            };
            if timed_out {
                drop_ids.push(*id);
            }
        }
        for id in drop_ids {
            if self.lsp_pending.remove(&id).is_some() {
                tracing::warn!(request_id = id, "lsp request timed out; dropping pending");
            }
            self.lsp_pending_seen_at.remove(&id);
        }
    }

    /// Handle a `textDocument/publishDiagnostics` notification.
    ///
    /// `encoding` is the negotiated encoding of the server that sent this
    /// notification; every diagnostic's `range` is converted from that
    /// server's wire units to hjkl's internal char-index columns before
    /// being stored on `LspDiag` (audit R2, UTF-16 fix) — every downstream
    /// consumer (cursor-jump navigation, gutter/underline overlays,
    /// `:lopen` entries) already expects char-index columns per
    /// `LspDiag`'s doc comment; only the population site was wrong.
    pub(crate) fn handle_publish_diagnostics(
        &mut self,
        params: serde_json::Value,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let parsed: lsp_types::PublishDiagnosticsParams = match serde_json::from_value(params) {
            Ok(p) => p,
            Err(e) => {
                tracing::warn!("publishDiagnostics: failed to parse: {e}");
                return;
            }
        };

        // Convert the LSP URI to a PathBuf for slot matching.
        // lsp_types::Uri is a newtype around url::Url.
        let uri_url: url::Url = match url::Url::parse(parsed.uri.as_str()) {
            Ok(u) => u,
            Err(e) => {
                tracing::warn!("publishDiagnostics: bad URI: {e}");
                return;
            }
        };
        let Some(uri_path) = hjkl_lsp::uri::to_path(&uri_url) else {
            tracing::debug!("publishDiagnostics: non-file URI, skipping");
            return;
        };

        // Find the slot whose filename matches the URI path.
        let slot_idx = self.slots.iter().position(|s| {
            s.filename.as_ref().is_some_and(|p| {
                let abs = if p.is_absolute() {
                    p.clone()
                } else {
                    std::env::current_dir().unwrap_or_default().join(p)
                };
                abs == uri_path
            })
        });

        let Some(slot_idx) = slot_idx else {
            tracing::debug!("publishDiagnostics: no matching slot for {:?}", uri_path);
            return;
        };

        // Convert LSP diagnostics to our internal format.
        let mut lsp_diags: Vec<LspDiag> = Vec::new();
        let mut sign_map: std::collections::HashMap<usize, (DiagSeverity, char, Style, u8)> =
            std::collections::HashMap::new();
        let rope = self.slots[slot_idx].buffer().rope();

        for d in &parsed.diagnostics {
            let start_row = d.range.start.line as usize;
            let end_row = d.range.end.line as usize;
            // Convert wire units -> char index using this line's CURRENT
            // text. Falls back to the raw wire value when the row is out of
            // range (a server reporting a diagnostic past EOF, or racing a
            // concurrent truncation) — matches `wire_position_to_pos`'s
            // reasoning: reading a nonexistent line would panic, and a
            // column on an already-nonsensical row is moot either way.
            let start_col = if start_row < rope.len_lines() {
                wire_to_col(
                    &hjkl_buffer::rope_line_str(&rope, start_row),
                    d.range.start.character,
                    encoding,
                )
            } else {
                d.range.start.character as usize
            };
            let end_col = if end_row < rope.len_lines() {
                wire_to_col(
                    &hjkl_buffer::rope_line_str(&rope, end_row),
                    d.range.end.character,
                    encoding,
                )
            } else {
                d.range.end.character as usize
            };
            let severity = convert_severity(d.severity);
            let code = d.code.as_ref().map(|c| match c {
                lsp_types::NumberOrString::Number(n) => n.to_string(),
                lsp_types::NumberOrString::String(s) => s.clone(),
            });
            let source = d.source.clone();

            lsp_diags.push(LspDiag {
                start_row,
                start_col,
                end_row,
                end_col,
                severity,
                message: d.message.clone(),
                source,
                code,
            });

            // For the gutter sign: highest-priority severity per row wins.
            let prio = severity_priority(severity);
            let entry = sign_map
                .entry(start_row)
                .or_insert_with(|| (severity, 'E', Style::default(), 0));
            if prio > entry.3 {
                let (ch, style) = severity_sign(severity);
                *entry = (severity, ch, style, prio);
            }
        }

        // Build gutter signs from the map.
        let diag_signs_lsp: Vec<Sign> = sign_map
            .into_iter()
            .map(|(row, (_, ch, style, priority))| Sign {
                row,
                ch,
                style,
                priority,
            })
            .collect();

        tracing::debug!(
            slot = slot_idx,
            n_diags = lsp_diags.len(),
            n_signs = diag_signs_lsp.len(),
            "lsp publishDiagnostics applied"
        );
        let slot = &mut self.slots[slot_idx];
        slot.lsp_diags = lsp_diags;
        slot.diag_signs_lsp = diag_signs_lsp;
    }

    /// Send a `textDocument/didChange` notification for the active buffer,
    /// but only when the buffer's `dirty_gen` has advanced since the last
    /// send. This naturally batches rapid keystroke edits.
    ///
    /// `edits` is the batch of [`hjkl_engine::ContentEdit`]s that produced
    /// the new buffer state — same list passed to `syntax.apply_edits`. When
    /// non-empty and the active server supports incremental sync over
    /// UTF-8 positions, sends an incremental `contentChanges` array (one
    /// element per edit, with replacement text sliced from the post-edit
    /// `content_joined()` cache). Otherwise falls back to full-document
    /// sync — the only choice when the buffer was wholesale-replaced
    /// (`:e!` / formatter), the server uses a non-UTF-8 position
    /// encoding, or no edits are tracked.
    pub(crate) fn lsp_notify_change_active(&mut self, edits: &[hjkl_engine::ContentEdit]) {
        let slot_idx = self.focused_slot_idx();
        self.lsp_notify_change_for_slot(slot_idx, edits);
    }

    /// Send a `textDocument/didChange` notification for the buffer in
    /// `slot_idx`, which need not be the focused slot — `apply_workspace_edit`
    /// uses this to keep OTHER files' server-side documents in sync when a
    /// workspace edit touches buffers the user hasn't focused (audit R2, fix
    /// 3). Without a per-slot notify, a non-focused buffer's server copy
    /// stayed stale — wrong diagnostics / cross-file positions — until the
    /// user happened to focus that slot.
    ///
    /// Only sends when the buffer's `dirty_gen` has advanced since the last
    /// send to `slot_idx` — this naturally batches rapid keystroke edits on
    /// the focused slot and is a no-op resend guard for every other slot.
    pub(crate) fn lsp_notify_change_for_slot(
        &mut self,
        slot_idx: usize,
        edits: &[hjkl_engine::ContentEdit],
    ) {
        if self.lsp.as_ref().is_none() {
            return;
        }
        // Compute sync-mode decision before taking the mutable slot
        // borrow — `lsp_supports_incremental_utf8_for_slot` walks
        // `self.lsp_state`. `edits_ascending_disjoint` also gates
        // incremental sync: a batch recorded in end-descending order (e.g.
        // `apply_workspace_edit`) can't be safely sliced from the final
        // rope — see `build_text_changes`'s doc comment (audit R2, fix 2).
        let use_incremental = !edits.is_empty()
            && self.lsp_supports_incremental_utf8_for_slot(slot_idx)
            && edits_ascending_disjoint(edits);

        let mgr = self.lsp.as_ref().unwrap();
        let Some(slot) = self.slots.get_mut(slot_idx) else {
            return;
        };
        let dg = slot.buffer().dirty_gen();

        // Skip if dirty_gen unchanged since last send.
        if slot.last_lsp_dirty_gen == Some(dg) {
            return;
        }
        slot.last_lsp_dirty_gen = Some(dg);

        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;

        if use_incremental {
            // Slice per-edit text directly from the rope — avoids the
            // ~3 MB content_joined build that dominated the LSP path on
            // huge files. `View::rope()` is an O(1) Arc-clone.
            let rope = slot.buffer().rope();
            let changes = build_text_changes(&rope, edits).expect(
                "use_incremental already checked edits_ascending_disjoint, \
                so build_text_changes must succeed",
            );
            tracing::debug!(
                buffer_id,
                dg,
                n_changes = changes.len(),
                "lsp didChange incremental"
            );
            mgr.notify_change_incremental(buffer_id, changes);
        } else {
            // Full-sync fallback (server doesn't support incremental,
            // or `:e!` / formatter wiped the edit log): we still need
            // the whole document, so pay for `content_joined` here.
            let text = slot.buffer().content_joined();
            tracing::debug!(
                buffer_id,
                dg,
                n_edits = edits.len(),
                text_len = text.len(),
                "lsp didChange full"
            );
            mgr.notify_change(buffer_id, text);
        }
    }

    /// Notify the LSP server that the buffer in `slot_idx` was saved. Flushes
    /// the current (post-format-on-save) text with a full `didChange` first so
    /// the server flychecks exactly what was written, then sends `didSave` —
    /// which is what triggers rust-analyzer's `cargo clippy` run. No-op when LSP
    /// is disabled or the buffer isn't attached to a server.
    pub(crate) fn lsp_notify_save_slot(&mut self, slot_idx: usize) {
        if self.lsp.is_none() {
            return;
        }
        let Some(slot) = self.slots.get(slot_idx) else {
            return;
        };
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
        let text = slot.buffer().content_joined();
        let dg = slot.buffer().dirty_gen();
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.notify_change(buffer_id, text);
            mgr.notify_save(buffer_id);
        }
        // Record the synced gen so the next edit's incremental didChange isn't
        // a redundant resend of the same state.
        self.slots[slot_idx].last_lsp_dirty_gen = Some(dg);
    }

    /// True when the LSP server attached to `slot_idx`'s language announced
    /// both incremental sync (`textDocumentSync.change == 2`) and UTF-8
    /// `positionEncoding`. Falls back conservatively when capabilities are
    /// missing. Parameterized on `slot_idx` (rather than always the focused
    /// buffer) so [`Self::lsp_notify_change_for_slot`] can gate incremental
    /// sync correctly for a non-focused slot too (audit R2, fix 3).
    fn lsp_supports_incremental_utf8_for_slot(&self, slot_idx: usize) -> bool {
        // Incremental sync only ever uses the byte-column `TextChange` path
        // (see `build_text_changes`), which is only safe over UTF-8 —
        // everything else falls back to full-document sync regardless of
        // encoding (no positions to convert there).
        if self.position_encoding_for_slot(slot_idx) != hjkl_lsp::PositionEncoding::Utf8 {
            return false;
        }
        let Some(info) = self.lsp_server_info_for_slot(slot_idx) else {
            return false;
        };
        let caps = &info.capabilities;

        // textDocumentSync.change == 2 = Incremental.
        // The field can be either an integer (legacy shape) or a
        // `TextDocumentSyncOptions` object with a `change` integer.
        let sync = caps.get("textDocumentSync");
        let change_kind = sync
            .and_then(|v| {
                v.as_i64()
                    .or_else(|| v.get("change").and_then(|c| c.as_i64()))
            })
            .unwrap_or(0);
        change_kind == 2
    }

    /// LSP language id for `slot_idx` — the slot's resolved filetype
    /// (open-time detection through the filetype seam, or a manual
    /// `:set filetype=`) when set, else the extension lookup. One seam for
    /// every language-keyed consumer, so an extensionless file detected by
    /// shebang/modeline reaches its LSP server, comment lead and status label.
    pub(crate) fn slot_language_id(&self, slot_idx: usize) -> Option<String> {
        let slot = self.slots.get(slot_idx)?;
        if !slot.settings.filetype.is_empty() {
            return Some(language_id_for_name(&slot.settings.filetype));
        }
        let ext = slot.filename.as_ref()?.extension()?.to_str()?;
        language_id_for_ext(&self.directory, ext)
    }

    /// The initialized [`LspServerInfo`] for the server attached to
    /// `slot_idx`'s language, if any. Shared lookup used by both the
    /// incremental-sync capability gate and [`Self::position_encoding_for_slot`].
    fn lsp_server_info_for_slot(&self, slot_idx: usize) -> Option<&LspServerInfo> {
        let lang = self.slot_language_id(slot_idx)?;
        self.lsp_state
            .iter()
            .find(|(k, _)| k.language == lang)
            .map(|(_, info)| info)
    }

    /// The [`hjkl_lsp::PositionEncoding`] negotiated with the server attached
    /// to `slot_idx`'s language. Defaults to UTF-16 (the LSP spec's default)
    /// when no server is attached/initialized for this slot yet — the
    /// conservative choice, since UTF-16 conversion is correct (if
    /// unnecessary CPU) for a server that turns out to speak UTF-8, whereas
    /// treating an unknown server as UTF-8 could silently corrupt text on a
    /// server that actually wants UTF-16.
    pub(crate) fn position_encoding_for_slot(&self, slot_idx: usize) -> hjkl_lsp::PositionEncoding {
        self.lsp_server_info_for_slot(slot_idx)
            .map(|info| hjkl_lsp::PositionEncoding::from_capabilities(&info.capabilities))
            .unwrap_or_default()
    }

    /// [`Self::position_encoding_for_slot`] for the focused slot.
    pub(crate) fn active_position_encoding(&self) -> hjkl_lsp::PositionEncoding {
        self.position_encoding_for_slot(self.focused_slot_idx())
    }

    /// True when an *initialized* LSP server for the active buffer's language
    /// advertises the capability at `pointer` (a JSON pointer such as
    /// `"/completionProvider"`). A capability explicitly set to `false` counts
    /// as unsupported.
    ///
    /// Gating requests on this prevents queuing a pending request that the
    /// server will never answer — either because it hasn't finished its
    /// `initialize` handshake or because it doesn't implement the feature.
    /// Without it, an auto-fired request (e.g. completion on every keystroke to
    /// a TOML server with no completion support) leaves the "LSP:…" status
    /// spinner stuck.
    fn lsp_active_supports(&self, pointer: &str) -> bool {
        let Some(lang) = self.slot_language_id(self.focused_slot_idx()) else {
            return false;
        };
        self.lsp_state.iter().any(|(k, info)| {
            k.language == lang
                && info.initialized
                && info
                    .capabilities
                    .pointer(pointer)
                    .is_some_and(|v| *v != serde_json::Value::Bool(false))
        })
    }

    /// File-type label for the active buffer — the detected language id when
    /// one is resolved (extension, known basename, shebang, modeline `ft=`, or
    /// a manual `:set filetype=`), otherwise the raw extension when the file
    /// has one, otherwise `"(none)"`.
    ///
    /// Used by the status-line right-click menu to show `Filetype: rust`.
    pub(crate) fn active_filetype_label(&self) -> String {
        if let Some(lang) = self.slot_language_id(self.focused_slot_idx()) {
            return lang;
        }
        // No detection — keep the old raw-extension fallback for files that
        // have an extension no grammar claims.
        self.active()
            .filename
            .as_ref()
            .and_then(|p| p.extension())
            .and_then(|e| e.to_str())
            .filter(|e| !e.is_empty())
            .map_or_else(|| "(none)".to_string(), |e| e.to_string())
    }

    /// Single-line comment lead for the active buffer's language (e.g. `"//"`
    /// for Rust/JS/C, `"#"` for Python/shell, `"--"` for Lua/SQL). Used to
    /// prefix end-of-line ghost-text hints (inline blame, diagnostics) so they
    /// read like a trailing comment. Falls back to `"//"` for unknown
    /// languages.
    pub(crate) fn active_comment_lead(&self) -> &'static str {
        self.slot_language_id(self.focused_slot_idx())
            .as_deref()
            .and_then(|lang| hjkl_lang::comment::commentstring_for_lang(lang))
            .map_or("//", |(start, _)| start)
    }

    /// LSP server name for the active buffer, if one is attached.
    ///
    /// Returns the `language` string from the first matching [`ServerKey`]
    /// in `lsp_state` (which doubles as the server name in the simple
    /// one-server-per-language setup used today).
    pub(crate) fn active_lsp_server_name(&self) -> Option<String> {
        self.lsp.as_ref()?;
        let lang = self.slot_language_id(self.focused_slot_idx())?;
        self.lsp_state
            .keys()
            .find(|k| k.language == lang)
            .map(|k| k.language.clone())
    }

    /// Gracefully shut down the LSP subsystem, if one is attached.
    ///
    /// Takes `self.lsp` (leaving `None` behind) and calls
    /// [`hjkl_lsp::LspManager::shutdown`], which sends `ShutdownAll` and
    /// blocks (bounded ~2s) joining the background thread so every child
    /// language-server process (rust-analyzer, gopls, tsserver, …) is killed
    /// and reaped before the call returns — instead of being orphaned when
    /// the process exits, which is all `Drop for LspManager` can guarantee
    /// (it only fire-and-forgets `ShutdownAll` on a detached thread that the
    /// process exit races).
    ///
    /// Idempotent: a second call finds `self.lsp` already `None` and is a
    /// no-op. Must be called on every normal exit path from `main` —
    /// including the error-return path — so a language server never
    /// outlives the editor just because it exited with an error.
    pub fn shutdown(&mut self) {
        if let Some(lsp) = self.lsp.take() {
            lsp.shutdown();
        }
    }

    /// Restart the LSP server for the active buffer: detach, then re-attach.
    ///
    /// Detaching stops the server and clears state; re-attaching restarts it.
    /// Mirrors what a `:LspRestart` command would do.
    pub(crate) fn restart_lsp(&mut self) {
        let slot_idx = self.focused_slot_idx();
        self.lsp_detach_buffer(slot_idx);
        self.lsp_attach_buffer(slot_idx);
        self.bus.info("LSP restarted");
    }

    /// Return `true` when the active buffer has a running, initialized LSP
    /// server attached (i.e. its file extension maps to a configured language
    /// and the server has completed the `initialize` handshake).
    ///
    /// Used by the right-click context menu to enable/disable LSP items.
    pub(crate) fn active_has_lsp(&self) -> bool {
        if self.lsp.is_none() {
            return false;
        }
        let Some(lang) = self.slot_language_id(self.focused_slot_idx()) else {
            return false;
        };
        self.lsp_state.keys().any(|k| k.language == lang)
    }

    /// Attach `slot_idx` to the appropriate language server (if configured).
    pub(crate) fn lsp_attach_buffer(&mut self, slot_idx: usize) {
        if !self.slots[slot_idx].features.lsp {
            return;
        }
        let Some(mgr) = self.lsp.as_ref() else { return };

        let slot = &self.slots[slot_idx];
        let path = match slot.filename.as_ref() {
            Some(p) => absolutize(p),
            None => return,
        };

        let Some(language_id) = self.slot_language_id(slot_idx) else {
            return;
        };

        // Only attach if there's a configured server for this language.
        if !self.config.lsp.servers.contains_key(language_id.as_str()) {
            return;
        }

        // `content_joined()` returns a `dirty_gen`-cached `Arc<String>`,
        // shared with any other per-tick consumer. Beats `lines().join`,
        // which clones every row out of the rope. The `Arc` is handed across
        // the LSP boundary as-is — no `to_string()` copy — and the runtime
        // serializes `didOpen` straight from the borrow.
        let text = self.slots[slot_idx].buffer().content_joined();

        let buffer_id = self.slots[slot_idx].buffer_id as hjkl_lsp::BufferId;
        mgr.attach_buffer(buffer_id, &path, &language_id, text);
    }

    /// Close the LSP document for `slot_idx`.
    pub(crate) fn lsp_detach_buffer(&mut self, slot_idx: usize) {
        let Some(mgr) = self.lsp.as_ref() else { return };
        let buffer_id = self.slots[slot_idx].buffer_id as hjkl_lsp::BufferId;
        mgr.detach_buffer(buffer_id);
    }

    /// Allocate a fresh monotonic request id for an outgoing LSP request.
    pub(crate) fn lsp_alloc_request_id(&mut self) -> i64 {
        let id = self.lsp_next_request_id;
        self.lsp_next_request_id += 1;
        id
    }

    // ── Request helpers ───────────────────────────────────────────────────

    /// Build `TextDocumentPositionParams` JSON for the current cursor position.
    ///
    /// `character` is converted from hjkl's internal char-index column to the
    /// active server's negotiated wire units via [`col_to_wire`] — sending
    /// the raw char index would be wrong for any line with a multi-byte char
    /// before the cursor, for BOTH encodings (UTF-8 wants byte offsets,
    /// UTF-16 wants code-unit offsets; only ASCII lines have all three
    /// coincide) (audit R2, UTF-16 fix). The returned `(usize, usize)` origin
    /// stays in internal char-index units — it's used for local bookkeeping
    /// (cursor jumps, popups), never re-sent over the wire.
    fn lsp_position_params(
        &self,
    ) -> Option<(
        serde_json::Value,
        hjkl_lsp::BufferId,
        (usize, usize),
        hjkl_lsp::PositionEncoding,
    )> {
        let slot = self.active();
        let path = absolutize(slot.filename.as_ref()?);
        let uri = hjkl_lsp::uri::from_path(&path).ok()?;
        let cursor = self.active_editor().buffer().cursor();
        let row = cursor.row;
        let col = cursor.col;
        let encoding = self.active_position_encoding();
        let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), row);
        let wire_col = col_to_wire(&line, col, encoding);
        let params = json!({
            "textDocument": { "uri": uri.as_str() },
            "position": { "line": row as u32, "character": wire_col },
        });
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
        Some((params, buffer_id, (row, col), encoding))
    }

    /// Internal: send a goto-flavour request and register it as pending.
    /// `extras` is merged into the top-level params object before sending —
    /// most goto methods take only `TextDocumentPositionParams`, but
    /// `textDocument/references` requires an additional `context` field
    /// (`{ includeDeclaration: bool }`) per the LSP spec.
    fn lsp_send_goto(
        &mut self,
        method: &str,
        extras: Option<serde_json::Value>,
        make_pending: impl FnOnce(
            hjkl_lsp::BufferId,
            (usize, usize),
            hjkl_lsp::PositionEncoding,
        ) -> LspPendingRequest,
    ) {
        if self.lsp.is_none() {
            self.bus
                .error("LSP: not enabled (set [lsp] enabled = true in config)");
            return;
        }
        if let Some(ptr) = capability_pointer_for_method(method)
            && !self.lsp_active_supports(ptr)
        {
            self.bus
                .error(format!("LSP: server does not support {method}"));
            return;
        }
        let Some((mut params, buffer_id, origin, encoding)) = self.lsp_position_params() else {
            self.bus
                .error("LSP: no file open in this buffer (use :e <file> or open from the picker)");
            return;
        };
        if let (Some(extra), Some(obj)) = (extras, params.as_object_mut())
            && let Some(extra_obj) = extra.as_object()
        {
            for (k, v) in extra_obj {
                obj.insert(k.clone(), v.clone());
            }
        }
        let request_id = self.lsp_alloc_request_id();
        let pending = make_pending(buffer_id, origin, encoding);
        self.lsp_pending.insert(request_id, pending);
        // Reborrow after mutable ops are done.
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, method, params);
        }
    }

    // ── Public goto / hover entry points ─────────────────────────────────

    /// `gd` — goto definition.
    pub(crate) fn lsp_goto_definition(&mut self) {
        self.lsp_send_goto("textDocument/definition", None, |buf, orig, encoding| {
            LspPendingRequest::GotoDefinition {
                buffer_id: buf,
                origin: orig,
                encoding,
            }
        });
    }

    /// `gD` — goto declaration.
    pub(crate) fn lsp_goto_declaration(&mut self) {
        self.lsp_send_goto("textDocument/declaration", None, |buf, orig, encoding| {
            LspPendingRequest::GotoDeclaration {
                buffer_id: buf,
                origin: orig,
                encoding,
            }
        });
    }

    /// `gy` — goto type definition.
    pub(crate) fn lsp_goto_type_definition(&mut self) {
        self.lsp_send_goto(
            "textDocument/typeDefinition",
            None,
            |buf, orig, encoding| LspPendingRequest::GotoTypeDefinition {
                buffer_id: buf,
                origin: orig,
                encoding,
            },
        );
    }

    /// `gi` — goto implementation.
    pub(crate) fn lsp_goto_implementation(&mut self) {
        self.lsp_send_goto(
            "textDocument/implementation",
            None,
            |buf, orig, encoding| LspPendingRequest::GotoImplementation {
                buffer_id: buf,
                origin: orig,
                encoding,
            },
        );
    }

    /// `gr` — goto references (always opens picker). LSP requires a
    /// `context: { includeDeclaration }` field on top of the standard
    /// position params; servers reject the request with a deserialization
    /// error when it's missing.
    pub(crate) fn lsp_goto_references(&mut self) {
        self.lsp_send_goto(
            "textDocument/references",
            Some(json!({ "context": { "includeDeclaration": true } })),
            |buf, orig, encoding| LspPendingRequest::GotoReferences {
                buffer_id: buf,
                origin: orig,
                encoding,
            },
        );
    }

    /// `K` — show hover info.
    pub(crate) fn lsp_hover(&mut self) {
        if !self.active().features.hover {
            return;
        }
        // In BLAME mode `K` shows the cursor line's commit message (the same
        // markdown popup), not an LSP symbol hover — the buffer is a read-only
        // blame view.
        if self.active_editor().is_blame() {
            let (row, col) = self.active_editor().cursor();
            let win_id = self.focused_window();
            let cell = crate::app::mouse::doc_to_cell(self, win_id, row, col).unwrap_or((0, 0));
            self.show_blame_commit_hover(row, cell);
            return;
        }
        self.lsp_send_goto("textDocument/hover", None, |buf, orig, _encoding| {
            LspPendingRequest::Hover {
                buffer_id: buf,
                origin: orig,
            }
        });
    }

    /// Mouse-hover variant: send `textDocument/hover` for an explicit doc
    /// position without moving the cursor. Used by the Phase 5 hover-popup
    /// timer so the user's cursor stays in place.
    ///
    /// `doc_col` is a char-index column (same convention as the cursor);
    /// converted to the active server's wire units before sending (audit R2,
    /// UTF-16 fix) — no encoding needs to be remembered for the response
    /// since hover results carry no position we convert back.
    pub(crate) fn lsp_hover_at_doc(&mut self, doc_row: usize, doc_col: usize) {
        if !self.active().features.hover {
            return;
        }
        if self.lsp.is_none() {
            return; // LSP not running — silently skip mouse hover
        }
        if !self.lsp_active_supports("/hoverProvider") {
            return; // server can't hover — silently skip (mouse-idle fire)
        }
        let slot = self.active();
        let path = match slot.filename.as_ref() {
            Some(p) => absolutize(p),
            None => return,
        };
        let Ok(uri) = hjkl_lsp::uri::from_path(&path) else {
            return;
        };
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
        let encoding = self.active_position_encoding();
        let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), doc_row);
        let wire_col = col_to_wire(&line, doc_col, encoding);
        let params = serde_json::json!({
            "textDocument": { "uri": uri.as_str() },
            "position": { "line": doc_row as u32, "character": wire_col },
        });
        let request_id = self.lsp_alloc_request_id();
        let pending = LspPendingRequest::HoverAtMouse {
            buffer_id,
            origin: (doc_row, doc_col),
        };
        self.lsp_pending.insert(request_id, pending);
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "textDocument/hover", params);
        }
    }

    // ── Response handlers ─────────────────────────────────────────────────

    /// Dispatch a received LSP response to the appropriate handler.
    pub(crate) fn handle_lsp_response(
        &mut self,
        pending: LspPendingRequest,
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
    ) {
        match pending {
            LspPendingRequest::GotoDefinition {
                buffer_id,
                origin,
                encoding,
            } => {
                self.handle_goto_response(buffer_id, origin, result, "definition", encoding);
            }
            LspPendingRequest::GotoDeclaration {
                buffer_id,
                origin,
                encoding,
            } => {
                self.handle_goto_response(buffer_id, origin, result, "declaration", encoding);
            }
            LspPendingRequest::GotoTypeDefinition {
                buffer_id,
                origin,
                encoding,
            } => {
                self.handle_goto_response(buffer_id, origin, result, "type definition", encoding);
            }
            LspPendingRequest::GotoImplementation {
                buffer_id,
                origin,
                encoding,
            } => {
                self.handle_goto_response(buffer_id, origin, result, "implementation", encoding);
            }
            LspPendingRequest::GotoReferences {
                buffer_id,
                origin,
                encoding,
            } => {
                self.handle_references_response(buffer_id, origin, result, encoding);
            }
            LspPendingRequest::Hover { buffer_id, origin } => {
                self.handle_hover_response(buffer_id, origin, result);
            }
            LspPendingRequest::HoverAtMouse { buffer_id, origin } => {
                self.handle_hover_at_mouse_response(buffer_id, origin, result);
            }
            LspPendingRequest::Completion {
                buffer_id,
                anchor_row,
                anchor_col,
                auto,
            } => {
                self.handle_completion_response(buffer_id, anchor_row, anchor_col, auto, result);
            }
            LspPendingRequest::CodeAction {
                buffer_id,
                anchor_row,
                anchor_col,
                encoding,
            } => {
                self.handle_code_action_response(
                    buffer_id, anchor_row, anchor_col, result, encoding,
                );
            }
            LspPendingRequest::Rename {
                buffer_id,
                anchor_row,
                anchor_col,
                new_name,
                encoding,
            } => {
                self.handle_rename_response(
                    buffer_id, anchor_row, anchor_col, new_name, result, encoding,
                );
            }
            LspPendingRequest::Format {
                buffer_id,
                range,
                encoding,
            } => {
                self.handle_format_response(buffer_id, range, result, encoding);
            }
        }
    }

    /// Normalize a goto-style response into a `Vec<lsp_types::Location>`.
    fn parse_goto_locations(result: serde_json::Value) -> Vec<lsp_types::Location> {
        // The result can be: null, Location, Location[], or LocationLink[].
        if result.is_null() {
            return Vec::new();
        }
        // Try GotoDefinitionResponse (covers all three variants).
        if let Ok(resp) =
            serde_json::from_value::<lsp_types::GotoDefinitionResponse>(result.clone())
        {
            return match resp {
                lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
                lsp_types::GotoDefinitionResponse::Array(locs) => locs,
                lsp_types::GotoDefinitionResponse::Link(links) => links
                    .into_iter()
                    .map(|l| lsp_types::Location {
                        uri: l.target_uri,
                        range: l.target_selection_range,
                    })
                    .collect(),
            };
        }
        // Fall back to a plain Vec<Location>.
        if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(result.clone()) {
            return locs;
        }
        // Single Location.
        if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(result) {
            return vec![loc];
        }
        Vec::new()
    }

    /// Read line `row` (0-based) of the file at `path`, for converting an LSP
    /// location's wire column into hjkl's internal char index when the
    /// target buffer may not be open yet.
    ///
    /// Prefers an already-open slot's rope — authoritative, reflects unsaved
    /// edits the server doesn't know about yet — and falls back to reading
    /// the file straight off disk (a goto/reference target is usually
    /// unopened). Returns `None` when neither source has the line; callers
    /// treat that as "cannot convert" and fall back to the raw wire column
    /// rather than panic — a defensible degrade since a target file that
    /// vanished between the response and this call is already an edge case.
    fn line_text_for_path(&self, path: &std::path::Path, row: usize) -> Option<String> {
        if let Some(slot) = self.slots.iter().find(|s| {
            s.filename.as_ref().is_some_and(|p| {
                let abs = if p.is_absolute() {
                    p.clone()
                } else {
                    std::env::current_dir().unwrap_or_default().join(p)
                };
                abs == path
            })
        }) {
            let rope = slot.buffer().rope();
            return if row < rope.len_lines() {
                Some(hjkl_buffer::rope_line_str(&rope, row))
            } else {
                None
            };
        }
        let text = std::fs::read_to_string(path).ok()?;
        text.lines().nth(row).map(|s| s.to_string())
    }

    /// Convert an LSP `Location`'s wire-unit start position to hjkl's
    /// internal char-index `(row, col)`, using `encoding` and the target
    /// file's line text (looked up via [`Self::line_text_for_path`]). Falls
    /// back to treating the wire column as-is when the line text can't be
    /// found (see that method's doc comment) — better than refusing to jump
    /// at all, and only wrong on the rare path where the fallback triggers.
    fn convert_location_start(
        &self,
        path: &std::path::Path,
        loc: &lsp_types::Location,
        encoding: hjkl_lsp::PositionEncoding,
    ) -> (usize, usize) {
        let row = loc.range.start.line as usize;
        let wire_col = loc.range.start.character;
        let col = match self.line_text_for_path(path, row) {
            Some(line) => wire_to_col(&line, wire_col, encoding),
            None => wire_col as usize,
        };
        (row, col)
    }

    /// Jump the cursor (and possibly switch buffer) to `loc`.
    fn jump_to_location(
        &mut self,
        loc: &lsp_types::Location,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let target_path: Option<PathBuf> = {
            let url: url::Url = match url::Url::parse(loc.uri.as_str()) {
                Ok(u) => u,
                Err(_) => return,
            };
            hjkl_lsp::uri::to_path(&url)
        };
        let (row, col) = match target_path.as_ref() {
            Some(tp) => self.convert_location_start(tp, loc, encoding),
            None => (
                loc.range.start.line as usize,
                loc.range.start.character as usize,
            ),
        };

        // Determine if target matches an already-open slot.
        let slot_idx = if let Some(ref tp) = target_path {
            self.slots.iter().position(|s| {
                s.filename.as_ref().is_some_and(|p| {
                    let abs_p = if p.is_absolute() {
                        p.clone()
                    } else {
                        std::env::current_dir().unwrap_or_default().join(p)
                    };
                    &abs_p == tp
                })
            })
        } else {
            None
        };

        if let Some(idx) = slot_idx {
            // Already open — switch if needed, then move cursor.
            if idx != self.focused_slot_idx() {
                self.switch_to(idx);
            }
        } else if let Some(ref tp) = target_path {
            // Open new slot.
            match self.open_new_slot(tp.clone()) {
                Ok(idx) => {
                    self.switch_to(idx);
                }
                Err(e) => {
                    self.bus.error(format!("LSP goto: {e}"));
                    return;
                }
            }
        } else {
            self.bus.error("LSP goto: non-file URI");
            return;
        }

        self.active_editor_mut().jump_cursor(row, col);
        // jump_cursor only sets cursor; the engine doesn't auto-scroll on
        // host-side jumps. Reveal the cursor before syncing the focused
        // window's stored top_row/top_col back from the editor viewport.
        self.active_editor_mut().ensure_cursor_in_scrolloff();
        self.sync_viewport_from_editor();
    }

    /// Handle a goto-flavour response (definition/declaration/type/implementation).
    pub(crate) fn handle_goto_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        _origin: (usize, usize),
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
        kind_label: &str,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP {kind_label}: {}", e.message));
                return;
            }
        };
        let locs = Self::parse_goto_locations(val);
        if locs.is_empty() {
            self.bus.warn(format!("no {kind_label} found"));
            return;
        }
        if locs.len() == 1 {
            self.jump_to_location(&locs[0], encoding);
        } else {
            self.open_lsp_locations_picker(&locs, kind_label, encoding);
        }
    }

    /// Handle a references response — always opens picker (even single result).
    pub(crate) fn handle_references_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        _origin: (usize, usize),
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP references: {}", e.message));
                return;
            }
        };
        let locs = Self::parse_goto_locations(val);
        if locs.is_empty() {
            self.bus.warn("no references found");
            return;
        }
        // Populate the location list (#184 phase 3) so `:lopen` / `]l` / `[l`
        // work on the references, in addition to the fuzzy picker below.
        self.set_loclist_from_locations(&locs, encoding);
        self.open_lsp_locations_picker(&locs, "references", encoding);
    }

    /// Convert LSP locations into location-list entries (#184 phase 3).
    fn set_loclist_from_locations(
        &mut self,
        locs: &[lsp_types::Location],
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let entries: Vec<hjkl_quickfix::QfEntry> = locs
            .iter()
            .filter_map(|loc| {
                let url: url::Url = url::Url::parse(loc.uri.as_str()).ok()?;
                let path = hjkl_lsp::uri::to_path(&url)?;
                let (row, col) = self.convert_location_start(&path, loc, encoding);
                Some(hjkl_quickfix::QfEntry {
                    path,
                    row,
                    col,
                    kind: hjkl_quickfix::QfKind::Info,
                    message: "reference".to_string(),
                })
            })
            .collect();
        self.set_loclist(entries);
    }

    /// Open a picker over a set of LSP locations.
    fn open_lsp_locations_picker(
        &mut self,
        locs: &[lsp_types::Location],
        kind_label: &str,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        use crate::picker_action::AppAction;

        // Strip the editor's cwd so files inside the project show as
        // relative paths (`apps/hjkl/src/main.rs`) instead of absolute
        // ones. Files outside cwd keep their full path.
        let cwd = std::env::current_dir().ok();

        // Build (label, action) pairs.
        let entries: Vec<(String, AppAction)> = locs
            .iter()
            .filter_map(|loc| {
                let url: url::Url = url::Url::parse(loc.uri.as_str()).ok()?;
                let path = hjkl_lsp::uri::to_path(&url)?;
                let (row, col) = self.convert_location_start(&path, loc, encoding);
                let display_path = cwd
                    .as_ref()
                    .and_then(|c| path.strip_prefix(c).ok())
                    .map_or_else(|| path.display().to_string(), |p| p.display().to_string());
                let label = format!("{display_path}:{}: col {}", row + 1, col + 1);
                // Use OpenPathAtLine for the action — goto_line is 1-based.
                Some((label, AppAction::OpenPathAtLine(path, row as u32 + 1)))
            })
            .collect();

        if entries.is_empty() {
            self.bus.warn(format!("no {kind_label} found"));
            return;
        }

        let source = Box::new(crate::picker_sources::StaticListSource::new(
            kind_label.to_string(),
            entries,
        ));
        self.picker = Some(crate::picker::Picker::new(source));
    }

    // ── Completion ────────────────────────────────────────────────────────

    /// Locate the `completionProvider` capability object for the active
    /// buffer's language server, if one is attached. Returns the object so
    /// callers can inspect `triggerCharacters` etc.
    fn active_completion_provider(&self) -> Option<&serde_json::Value> {
        let lang = self
            .active()
            .filename
            .as_ref()
            .and_then(|p| p.extension())
            .and_then(|e| e.to_str())
            .and_then(|e| language_id_for_ext(&self.directory, e))?;
        self.lsp_state.iter().find_map(|(key, info)| {
            (key.language == lang)
                .then(|| info.capabilities.pointer("/completionProvider"))
                .flatten()
        })
    }

    /// `true` when an LSP server is attached for the buffer in `slot_idx`
    /// (its language has an initialized entry in `lsp_state`). When a server
    /// owns diagnostics for a buffer, the noisy tree-sitter parse-error gutter
    /// signs are suppressed — a single syntax error makes the TS error-recovery
    /// flag a cascade of downstream lines, which conflicts with the server's
    /// precise diagnostics. TS signs remain a fallback for non-LSP buffers.
    pub(crate) fn slot_has_lsp(&self, slot_idx: usize) -> bool {
        let Some(slot) = self.slots.get(slot_idx) else {
            return false;
        };
        let lang = slot
            .filename
            .as_ref()
            .and_then(|p| p.extension())
            .and_then(|e| e.to_str())
            .and_then(|e| language_id_for_ext(&self.directory, e));
        match lang {
            Some(l) => self.lsp_state.keys().any(|k| k.language == l),
            None => false,
        }
    }

    /// `true` when a `textDocument/completion` request is already in flight.
    /// Used to suppress request storms while auto-completing as the user types.
    fn lsp_has_pending_completion(&self) -> bool {
        self.lsp_pending
            .values()
            .any(|p| matches!(p, LspPendingRequest::Completion { .. }))
    }

    /// Auto-fire completion as the user types in insert mode.
    ///
    /// Fires when `ch` is either a server-declared trigger character (e.g. `.`,
    /// `::`) **or** an identifier character (letter / digit / `_`) so the popup
    /// surfaces keywords, locals, fields and types mid-word — not only after a
    /// member-access dot.
    ///
    /// When an LSP server is attached, fires a `textDocument/completion` request
    /// (its response is augmented with buffer words). When no server is
    /// attached, opens a buffer-words-only popup synchronously so completion of
    /// already-typed identifiers still works without a language server.
    pub(crate) fn maybe_auto_trigger_completion(&mut self, ch: char) {
        // Decide whether to fire, and whether an LSP server is available, while
        // only borrowing `self` immutably — then act with the mutable borrow.
        let (has_provider, is_trigger) = match self.active_completion_provider() {
            Some(p) => {
                let is_trigger = p
                    .pointer("/triggerCharacters")
                    .and_then(|v| v.as_array())
                    .is_some_and(|arr| {
                        arr.iter()
                            .any(|s| s.as_str() == Some(ch.to_string().as_str()))
                    });
                (true, is_trigger)
            }
            None => (false, false),
        };
        let is_ident = ch.is_alphanumeric() || ch == '_';
        if !(is_trigger || is_ident) {
            return;
        }
        // Surface buffer-dictionary words immediately when typing an identifier,
        // so completion works regardless of whether a language server is
        // attached or responsive (e.g. a TOML server that never answers). When
        // an LSP response lands it replaces this popup with merged server +
        // buffer-word results.
        if is_ident {
            self.open_buffer_word_completion();
        }
        // Augment with LSP results when a server offers completion. The pending
        // guard avoids a request storm; the local prefix filter narrows the
        // open popup until the response lands.
        if has_provider && !self.lsp_has_pending_completion() {
            self.lsp_request_completion_inner(true);
        }
    }

    /// Open a completion popup populated purely from unique identifier tokens
    /// found across all open buffers (vim's keyword completion, `<C-n>`). Used
    /// when no LSP server is attached so word completion still works.
    pub(crate) fn open_buffer_word_completion(&mut self) {
        let cursor = self.active_editor().buffer().cursor();
        let (row, col) = (cursor.row, cursor.col);
        let anchor_col = self.identifier_start_col(row, col);
        let token = self.token_between(row, anchor_col, col);
        let items = self.buffer_word_items(&token);
        if items.is_empty() {
            return;
        }
        let mut popup = Completion::new(row, anchor_col, items);
        if !token.is_empty() {
            popup.set_prefix(&token);
            if popup.is_empty() {
                return;
            }
        }
        self.completion = Some(popup);
    }

    /// The text between CHAR columns `[lo, hi)` on `row` (the partial word
    /// under the cursor). Empty when out of range.
    ///
    /// `lo`/`hi` are CHAR indices — the same unit as `View::cursor().col`,
    /// `Completion::anchor_col`, and every caller of this method (audit-r2
    /// fix 7: this used to slice `line[lo..hi]` treating them as BYTE
    /// offsets, silently truncating or misaligning the token on any line
    /// with multibyte content before the cursor). Convert to byte offsets
    /// via [`hjkl_buffer::Position::byte_offset`] before slicing.
    pub(crate) fn token_between(&self, row: usize, lo: usize, hi: usize) -> String {
        let rope = self.active_editor().buffer().rope();
        if row >= rope.len_lines() {
            return String::new();
        }
        let line = hjkl_buffer::rope_line_str(&rope, row);
        let char_count = line.chars().count();
        let lo = lo.min(char_count);
        let hi = hi.min(char_count);
        if lo > hi {
            return String::new();
        }
        let byte_lo = hjkl_buffer::Position::new(row, lo).byte_offset(&line);
        let byte_hi = hjkl_buffer::Position::new(row, hi).byte_offset(&line);
        line[byte_lo..byte_hi].to_string()
    }

    /// Collect unique identifier tokens from every open buffer as completion
    /// items (kind `Other`). Tokens must start with a letter or `_` and be at
    /// least two chars; `exclude` (the partial word currently under the cursor)
    /// is skipped so the popup never suggests exactly what's being typed.
    ///
    /// Bounded: scans at most `MAX_SCAN_BYTES` per buffer and returns at most
    /// `MAX_WORDS` items so a giant buffer can't stall the insert path.
    pub(crate) fn buffer_word_items(
        &self,
        exclude: &str,
    ) -> Vec<crate::completion::CompletionItem> {
        use crate::completion::CompletionItem;
        const MAX_WORDS: usize = 2000;
        const MAX_SCAN_BYTES: usize = 1_000_000;

        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
        let mut items: Vec<CompletionItem> = Vec::new();

        let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
        let is_word_start = |c: char| c.is_alphabetic() || c == '_';

        'buffers: for slot in &self.slots {
            let rope = slot.buffer().rope();
            let mut word = String::new();
            let mut scanned = 0usize;
            for ch in rope.chars() {
                scanned += ch.len_utf8();
                if is_word_char(ch) {
                    word.push(ch);
                } else if !word.is_empty() {
                    Self::push_buffer_word(
                        &mut word,
                        exclude,
                        &mut seen,
                        &mut items,
                        is_word_start,
                    );
                    if items.len() >= MAX_WORDS {
                        break 'buffers;
                    }
                }
                if scanned >= MAX_SCAN_BYTES {
                    break;
                }
            }
            if !word.is_empty() {
                Self::push_buffer_word(&mut word, exclude, &mut seen, &mut items, is_word_start);
                if items.len() >= MAX_WORDS {
                    break 'buffers;
                }
            }
        }
        items
    }

    /// Helper for [`Self::buffer_word_items`]: accept `word` as a candidate when
    /// it is a valid identifier, not the excluded prefix, and not already seen.
    /// Clears `word` for reuse either way.
    fn push_buffer_word(
        word: &mut String,
        exclude: &str,
        seen: &mut std::collections::HashSet<String>,
        items: &mut Vec<crate::completion::CompletionItem>,
        is_word_start: impl Fn(char) -> bool,
    ) {
        if word.len() >= 2
            && word.as_str() != exclude
            && word.chars().next().is_some_and(&is_word_start)
            && seen.insert(word.clone())
        {
            items.push(crate::completion::CompletionItem::new(word.clone()));
        }
        word.clear();
    }

    /// Send a `textDocument/completion` request for the current cursor position
    /// (manual `<C-n>`/`<C-p>` invocation).
    pub(crate) fn lsp_request_completion(&mut self) {
        self.lsp_request_completion_inner(false);
    }

    /// Shared completion request. `auto` marks implicit (as-you-type) requests
    /// so an empty server response stays silent instead of flashing a warning.
    ///
    /// The popup anchor is snapped back to the **start of the identifier under
    /// the cursor** (not the raw cursor column) so the local prefix filter
    /// tracks the whole partial word as the user keeps typing. The LSP request
    /// itself still uses the true cursor position.
    fn lsp_request_completion_inner(&mut self, auto: bool) {
        if self.lsp.is_none() {
            if !auto {
                self.bus
                    .error("LSP: not enabled (set [lsp] enabled = true in config)");
            }
            return;
        }
        // Skip entirely when no initialized server advertises completion — an
        // auto-fire on every keystroke to a server that never answers would
        // otherwise pile up pending requests and hang the status spinner.
        if !self.lsp_active_supports("/completionProvider") {
            if !auto {
                self.bus.error("LSP: server has no completion support");
            }
            return;
        }
        let Some((params, buffer_id, (row, col), _encoding)) = self.lsp_position_params() else {
            if !auto {
                self.bus.error(
                    "LSP: no file open in this buffer (use :e <file> or open from the picker)",
                );
            }
            return;
        };
        let anchor_col = self.identifier_start_col(row, col);
        let request_id = self.lsp_alloc_request_id();
        self.lsp_pending.insert(
            request_id,
            LspPendingRequest::Completion {
                buffer_id,
                anchor_row: row,
                anchor_col,
                auto,
            },
        );
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "textDocument/completion", params);
        }
    }

    /// Scan left from CHAR column `col` on `row` over identifier characters
    /// (alphanumeric / `_`) and return the CHAR column where the current word
    /// begins. When the character before the cursor is not an identifier char
    /// (e.g. right after a `.`), this returns `col` unchanged.
    ///
    /// CHAR-based to match `View::cursor().col`, `Completion::anchor_col`,
    /// and `token_between` — every real caller passes a char column (audit-r2
    /// fix 7: this used to slice `line[..end]` treating `col` as a BYTE
    /// offset, which silently mis-scanned on any line with multibyte content
    /// before the cursor — e.g. an accented letter or emoji shifted every
    /// identifier boundary after it left by however many extra bytes it
    /// contributed, truncating the completion prefix).
    pub(crate) fn identifier_start_col(&self, row: usize, col: usize) -> usize {
        let rope = self.active_editor().buffer().rope();
        if row >= rope.len_lines() {
            return col;
        }
        let line = hjkl_buffer::rope_line_str(&rope, row);
        let chars: Vec<char> = line.chars().collect();
        let end = col.min(chars.len());
        let mut start = end;
        // Walk back from the cursor over the contiguous run of identifier
        // chars, in char-index space throughout — no byte offsets needed.
        for i in (0..end).rev() {
            if chars[i].is_alphanumeric() || chars[i] == '_' {
                start = i;
            } else {
                break;
            }
        }
        start
    }

    // ── Code actions ──────────────────────────────────────────────────────

    /// `<leader>ca` — request code actions at the cursor.
    pub(crate) fn lsp_code_actions(&mut self) {
        if self.lsp.is_none() {
            self.bus
                .error("LSP: not enabled (set [lsp] enabled = true in config)");
            return;
        }
        if !self.lsp_active_supports("/codeActionProvider") {
            self.bus.error("LSP: server has no code-action support");
            return;
        }
        let slot = self.active();
        let path = match slot.filename.as_ref() {
            Some(p) => absolutize(p),
            None => {
                self.bus.error(
                    "LSP: no file open in this buffer (use :e <file> or open from the picker)",
                );
                return;
            }
        };
        let Some(uri) = hjkl_lsp::uri::from_path(&path).ok() else {
            self.bus.error("LSP: cannot build URI");
            return;
        };
        let cursor = self.active_editor().buffer().cursor();
        let encoding = self.active_position_encoding();
        let rope = self.active_editor().buffer().rope();
        let cursor_line = hjkl_buffer::rope_line_str(&rope, cursor.row);
        let wire_col = col_to_wire(&cursor_line, cursor.col, encoding);
        let row = cursor.row as u32;
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;

        // Collect diagnostics that overlap the cursor position. `d.start_col`
        // / `d.end_col` are hjkl's internal char-index columns (converted
        // from wire units when the diagnostic was received); convert them
        // back to this server's wire units for the outgoing `context`
        // (audit R2, UTF-16 fix) — sending the char index straight through
        // would round-trip wrong for a multibyte line even though the value
        // originated from this same server, since char index is neither of
        // the two wire encodings.
        let overlapping_diags: Vec<lsp_types::Diagnostic> = slot
            .lsp_diags
            .iter()
            .filter(|d| {
                let after_start = (cursor.row, cursor.col) >= (d.start_row, d.start_col);
                let before_end = cursor.row < d.end_row
                    || (cursor.row == d.end_row && cursor.col < d.end_col)
                    || (cursor.row == d.start_row && d.start_row == d.end_row);
                after_start && (before_end || cursor.row == d.start_row)
            })
            .map(|d| {
                let severity = match d.severity {
                    super::DiagSeverity::Error => Some(lsp_types::DiagnosticSeverity::ERROR),
                    super::DiagSeverity::Warning => Some(lsp_types::DiagnosticSeverity::WARNING),
                    super::DiagSeverity::Info => Some(lsp_types::DiagnosticSeverity::INFORMATION),
                    super::DiagSeverity::Hint => Some(lsp_types::DiagnosticSeverity::HINT),
                };
                let code = d.code.as_ref().map(|c| {
                    if let Ok(n) = c.parse::<i32>() {
                        lsp_types::NumberOrString::Number(n)
                    } else {
                        lsp_types::NumberOrString::String(c.clone())
                    }
                });
                let start_line_text = hjkl_buffer::rope_line_str(&rope, d.start_row);
                let end_line_text = hjkl_buffer::rope_line_str(&rope, d.end_row);
                lsp_types::Diagnostic {
                    range: lsp_types::Range {
                        start: lsp_types::Position {
                            line: d.start_row as u32,
                            character: col_to_wire(&start_line_text, d.start_col, encoding),
                        },
                        end: lsp_types::Position {
                            line: d.end_row as u32,
                            character: col_to_wire(&end_line_text, d.end_col, encoding),
                        },
                    },
                    severity,
                    code,
                    source: d.source.clone(),
                    message: d.message.clone(),
                    ..Default::default()
                }
            })
            .collect();

        let params = json!({
            "textDocument": { "uri": uri.as_str() },
            "range": {
                "start": { "line": row, "character": wire_col },
                "end": { "line": row, "character": wire_col },
            },
            "context": {
                "diagnostics": overlapping_diags,
                "triggerKind": 1, // CodeActionTriggerKind::Invoked
            },
        });

        let request_id = self.lsp_alloc_request_id();
        self.lsp_pending.insert(
            request_id,
            LspPendingRequest::CodeAction {
                buffer_id,
                anchor_row: cursor.row,
                anchor_col: cursor.col,
                encoding,
            },
        );
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "textDocument/codeAction", params);
        }
    }

    /// Handle a `textDocument/codeAction` response.
    pub(crate) fn handle_code_action_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        _anchor_row: usize,
        _anchor_col: usize,
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP codeAction: {}", e.message));
                return;
            }
        };

        if val.is_null() {
            self.bus.warn("no code actions");
            return;
        }

        let actions: Vec<lsp_types::CodeActionOrCommand> = match serde_json::from_value(val) {
            Ok(a) => a,
            Err(_) => {
                self.bus.error("LSP codeAction: could not parse response");
                return;
            }
        };

        if actions.is_empty() {
            self.bus.warn("no code actions");
            return;
        }

        if actions.len() == 1 {
            let action = actions.into_iter().next().unwrap();
            self.apply_code_action_or_command(action, encoding);
            return;
        }

        // Multiple actions — open picker. Store actions in pending_code_actions
        // so the picker can index into them via ApplyCodeAction(i). The
        // encoding is stashed alongside since the picker selection (and thus
        // the eventual `apply_code_action_or_command` call) happens frames
        // later, well after this response handler returns.
        use crate::picker_action::AppAction;
        let entries: Vec<(String, AppAction)> = actions
            .iter()
            .enumerate()
            .map(|(i, action)| {
                let label = match action {
                    lsp_types::CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
                    lsp_types::CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
                };
                (label, AppAction::ApplyCodeAction(i))
            })
            .collect();

        self.pending_code_actions = actions;
        self.pending_code_actions_encoding = encoding;
        let source = Box::new(crate::picker_sources::StaticListSource::new(
            "code actions".to_string(),
            entries,
        ));
        self.picker = Some(crate::picker::Picker::new(source));
    }

    /// Apply a single code action or command.
    pub(crate) fn apply_code_action_or_command(
        &mut self,
        item: lsp_types::CodeActionOrCommand,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        match item {
            lsp_types::CodeActionOrCommand::CodeAction(ca) => {
                // Apply workspace edit first, then execute command if present.
                if let Some(edit) = ca.edit {
                    match self.apply_workspace_edit(edit, encoding) {
                        Ok(count) => {
                            self.bus.info(format!("{count} files changed"));
                        }
                        Err(e) => {
                            self.bus.error(format!("LSP codeAction: {e}"));
                            return;
                        }
                    }
                }
                if let Some(cmd) = ca.command {
                    self.lsp_execute_command(&cmd.command, &cmd.arguments.unwrap_or_default());
                }
            }
            lsp_types::CodeActionOrCommand::Command(cmd) => {
                self.lsp_execute_command(&cmd.command, &cmd.arguments.unwrap_or_default());
            }
        }
    }

    /// Fire-and-forget `workspace/executeCommand`. Phase 5: no response handling.
    fn lsp_execute_command(&mut self, command: &str, args: &[serde_json::Value]) {
        let buffer_id = self.active().buffer_id as hjkl_lsp::BufferId;
        let request_id = self.lsp_alloc_request_id();
        let params = json!({
            "command": command,
            "arguments": args,
        });
        // Fire and forget — don't register a pending entry.
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "workspace/executeCommand", params);
        }
    }

    // ── WorkspaceEdit application ─────────────────────────────────────────

    /// Apply a `WorkspaceEdit` to the open slots (and open new ones as needed).
    /// Returns the count of files changed on success, or an error string.
    ///
    /// `encoding` is the negotiated encoding of the server that produced
    /// `edit` — every `TextEdit.range` in it is relative to that ONE
    /// server's wire units, regardless of which file (and thus which
    /// server's own attached document) each edit targets, so a single
    /// encoding is correct for the whole call (audit R2, UTF-16 fix).
    pub(crate) fn apply_workspace_edit(
        &mut self,
        edit: lsp_types::WorkspaceEdit,
        encoding: hjkl_lsp::PositionEncoding,
    ) -> Result<usize, String> {
        // Collect (url, Vec<TextEdit>) pairs from either .changes or .document_changes.
        let mut file_edits: Vec<(url::Url, Vec<lsp_types::TextEdit>)> = Vec::new();

        if let Some(doc_changes) = edit.document_changes {
            match doc_changes {
                lsp_types::DocumentChanges::Edits(edits) => {
                    for tde in edits {
                        let url: url::Url = url::Url::parse(tde.text_document.uri.as_str())
                            .map_err(|e| format!("bad URI: {e}"))?;
                        let text_edits: Vec<lsp_types::TextEdit> = tde
                            .edits
                            .into_iter()
                            .filter_map(|e| match e {
                                lsp_types::OneOf::Left(te) => Some(te),
                                lsp_types::OneOf::Right(_) => None, // skip annotated edits
                            })
                            .collect();
                        file_edits.push((url, text_edits));
                    }
                }
                lsp_types::DocumentChanges::Operations(ops) => {
                    for op in ops {
                        match op {
                            lsp_types::DocumentChangeOperation::Edit(tde) => {
                                let url: url::Url = url::Url::parse(tde.text_document.uri.as_str())
                                    .map_err(|e| format!("bad URI: {e}"))?;
                                let text_edits: Vec<lsp_types::TextEdit> = tde
                                    .edits
                                    .into_iter()
                                    .filter_map(|e| match e {
                                        lsp_types::OneOf::Left(te) => Some(te),
                                        lsp_types::OneOf::Right(_) => None,
                                    })
                                    .collect();
                                file_edits.push((url, text_edits));
                            }
                            lsp_types::DocumentChangeOperation::Op(_) => {
                                // TODO: file create/rename/delete not supported in Phase 5.
                            }
                        }
                    }
                }
            }
        } else if let Some(changes) = edit.changes {
            for (uri, edits) in changes {
                let url: url::Url =
                    url::Url::parse(uri.as_str()).map_err(|e| format!("bad URI: {e}"))?;
                file_edits.push((url, edits));
            }
        }

        // Merge entries that name the SAME file before applying anything.
        // The end-descending sort below is what stops an applied edit from
        // shifting the ranges of the edits still queued behind it — but that
        // invariant only holds across the edits it actually sorts, i.e.
        // WITHIN one entry. `documentChanges` is a JSON array and the spec
        // lets a server emit several `TextDocumentEdit`s for one document;
        // every one of them computed its ranges against the ORIGINAL
        // document, so applying a second entry to a rope the first already
        // mutated lands its edits at stale offsets — silent corruption of the
        // user's buffer. Concatenating same-file entries puts all of a file's
        // edits under the single sort, which restores the invariant.
        //
        // Group on the RESOLVED PATH, not the raw URL string: two spellings
        // (percent-encoding, `..` segments) can name the same file. A URL
        // that resolves to no path can't be compared this way, so each such
        // entry stays its own group and still hits the `non-file URI` error
        // below with its own URL. First-appearance order is preserved (no
        // sort by path) so application order stays deterministic.
        let mut merged: Vec<(
            url::Url,
            Option<std::path::PathBuf>,
            Vec<lsp_types::TextEdit>,
        )> = Vec::new();
        for (url, edits) in file_edits {
            let target_path = hjkl_lsp::uri::to_path(&url);
            let existing = target_path.as_ref().and_then(|tp| {
                merged
                    .iter_mut()
                    .find(|(_, p, _)| p.as_ref().is_some_and(|q| q == tp))
            });
            match existing {
                Some((_, _, acc)) => acc.extend(edits),
                None => merged.push((url, target_path, edits)),
            }
        }
        let file_edits = merged;

        // Active LSP workspace roots, for the out-of-root warning below.
        // When no server is tracked yet (`lsp_state` empty) fall back to the
        // process cwd so the containment check still means something.
        let workspace_roots: Vec<std::path::PathBuf> = if self.lsp_state.is_empty() {
            std::env::current_dir().ok().into_iter().collect()
        } else {
            self.lsp_state.keys().map(|k| k.root.clone()).collect()
        };
        let mut out_of_root: Vec<std::path::PathBuf> = Vec::new();

        // DISTINCT files, not entries — the caller reports this to the user as
        // "{count} files changed", and a server that split one file across two
        // `TextDocumentEdit`s must not inflate it.
        let count = file_edits.len();
        for (url, target_path, mut edits) in file_edits {
            // A server may direct edits at files OUTSIDE every workspace root
            // (e.g. `~/.bashrc`). Still apply them — buffers are not saved
            // automatically and legitimate multi-root / out-of-tree setups
            // exist — but record the path so the user gets a visible warning
            // before a hurried `:w` / `:wa` persists them.
            if let Some(ref tp) = target_path {
                let canon = std::fs::canonicalize(tp).unwrap_or_else(|_| tp.clone());
                let in_root = workspace_roots.iter().any(|r| {
                    let rc = std::fs::canonicalize(r).unwrap_or_else(|_| r.clone());
                    tp.starts_with(r) || canon.starts_with(&rc)
                });
                if !in_root {
                    out_of_root.push(tp.clone());
                }
            }
            // Find or open the slot for this URI.
            let slot_idx = if let Some(ref tp) = target_path {
                // Try to find an existing slot.
                let existing = self.slots.iter().position(|s| {
                    s.filename.as_ref().is_some_and(|p| {
                        let abs = if p.is_absolute() {
                            p.clone()
                        } else {
                            std::env::current_dir().unwrap_or_default().join(p)
                        };
                        &abs == tp
                    })
                });
                match existing {
                    Some(idx) => idx,
                    None => self.open_new_slot(tp.clone())?,
                }
            } else {
                return Err(format!("non-file URI: {url}"));
            };

            // Sort edits by range END descending so applying later edits
            // doesn't shift the positions of earlier ones. This runs ONCE
            // over every edit aimed at this file (merged above), which is
            // what makes the ordering hold across entry boundaries too.
            edits.sort_by(|a, b| {
                let ea = (a.range.end.line, a.range.end.character);
                let eb = (b.range.end.line, b.range.end.character);
                eb.cmp(&ea)
            });

            use hjkl_engine::BufferEdit;
            for te in edits {
                // Read the CURRENT rope for this slot on every iteration:
                // edits are sorted end-descending (see above), so by the
                // time we reach `te` every previously-applied edit in this
                // batch sat strictly to `te`'s right — its own line's text
                // left of `te.range.start` (and thus the wire->char
                // conversion below) is unaffected and still valid.
                let rope = self.slots[slot_idx].buffer().rope();
                let start = wire_position_to_pos(
                    &rope,
                    te.range.start.line,
                    te.range.start.character,
                    encoding,
                );
                let end = wire_position_to_pos(
                    &rope,
                    te.range.end.line,
                    te.range.end.character,
                    encoding,
                );
                BufferEdit::replace_range(
                    self.slots[slot_idx].buffer_mut(),
                    start..end,
                    &te.new_text,
                );
            }

            // Mark dirty.
            let _ = self.slots[slot_idx].take_dirty();
            self.slots[slot_idx].dirty = true;

            // Drain this slot's engine-emitted edits into syntax + LSP
            // immediately — for every touched slot, not just the focused
            // one. Without this, a slot the workspace edit reached but the
            // user hadn't focused kept a stale server-side document (wrong
            // diagnostics / cross-file positions) until the user happened
            // to focus it (audit R2, fix 3).
            let buffer_id = self.slots[slot_idx].buffer_id;
            if self.slots[slot_idx].take_content_reset() {
                self.syntax.reset(buffer_id);
            }
            let slot_edits = self.slots[slot_idx].take_content_edits();
            if !slot_edits.is_empty() {
                self.syntax.apply_edits(buffer_id, &slot_edits);
            }
            self.lsp_notify_change_for_slot(slot_idx, &slot_edits);
        }
        // The active buffer's syntax spans may now be stale (if it was
        // among the touched slots) — refresh on the next frame flush
        // rather than reparsing inline here (event-loop invariant: only
        // keystroke arms and the top-of-loop flush call recompute_and_install).
        self.pending_recompute = true;

        if !out_of_root.is_empty() {
            let names = out_of_root
                .iter()
                .map(|p| p.display().to_string())
                .collect::<Vec<_>>()
                .join(", ");
            self.bus.warn(format!(
                "LSP: workspace edit modified file(s) OUTSIDE the workspace root: {names} — review before saving"
            ));
        }

        Ok(count)
    }

    // ── Rename ────────────────────────────────────────────────────────────

    /// Send a `textDocument/rename` request.
    pub(crate) fn lsp_rename(&mut self, new_name: String) {
        if self.lsp.is_none() {
            self.bus
                .error("LSP: not enabled (set [lsp] enabled = true in config)");
            return;
        }
        if !self.lsp_active_supports("/renameProvider") {
            self.bus.error("LSP: server has no rename support");
            return;
        }
        let slot = self.active();
        let path = match slot.filename.as_ref() {
            Some(p) => absolutize(p),
            None => {
                self.bus.error(
                    "LSP: no file open in this buffer (use :e <file> or open from the picker)",
                );
                return;
            }
        };
        let Some(uri) = hjkl_lsp::uri::from_path(&path).ok() else {
            self.bus.error("LSP: cannot build URI");
            return;
        };
        let cursor = self.active_editor().buffer().cursor();
        let encoding = self.active_position_encoding();
        let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), cursor.row);
        let wire_col = col_to_wire(&line, cursor.col, encoding);
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;

        let params = json!({
            "textDocument": { "uri": uri.as_str() },
            "position": { "line": cursor.row as u32, "character": wire_col },
            "newName": new_name,
        });

        let request_id = self.lsp_alloc_request_id();
        self.lsp_pending.insert(
            request_id,
            LspPendingRequest::Rename {
                buffer_id,
                anchor_row: cursor.row,
                anchor_col: cursor.col,
                new_name,
                encoding,
            },
        );
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "textDocument/rename", params);
        }
    }

    /// Handle a `textDocument/rename` response.
    pub(crate) fn handle_rename_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        _anchor_row: usize,
        _anchor_col: usize,
        _new_name: String,
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP rename: {}", e.message));
                return;
            }
        };

        if val.is_null() {
            self.bus.error("E: cannot rename here");
            return;
        }

        let workspace_edit: lsp_types::WorkspaceEdit = match serde_json::from_value(val) {
            Ok(we) => we,
            Err(_) => {
                self.bus.error("LSP rename: could not parse response");
                return;
            }
        };

        match self.apply_workspace_edit(workspace_edit, encoding) {
            Ok(count) => {
                self.bus.info(format!("renamed: {count} files changed"));
            }
            Err(e) => {
                self.bus.error(format!("LSP rename: {e}"));
            }
        }
    }

    // ── Format ────────────────────────────────────────────────────────────

    /// `:LspFormat` — send a `textDocument/formatting` request.
    pub(crate) fn lsp_format(&mut self) {
        if self.lsp.is_none() {
            self.bus
                .error("LSP: not enabled (set [lsp] enabled = true in config)");
            return;
        }
        if !self.lsp_active_supports("/documentFormattingProvider") {
            self.bus.error("LSP: server has no formatting support");
            return;
        }
        let slot = self.active();
        let path = match slot.filename.as_ref() {
            Some(p) => absolutize(p),
            None => {
                self.bus.error(
                    "LSP: no file open in this buffer (use :e <file> or open from the picker)",
                );
                return;
            }
        };
        let Some(uri) = hjkl_lsp::uri::from_path(&path).ok() else {
            self.bus.error("LSP: cannot build URI");
            return;
        };
        let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
        let tab_size = self.active_editor().settings().tabstop as u32;
        let insert_spaces = self.active_editor().settings().expandtab;

        let params = json!({
            "textDocument": { "uri": uri.as_str() },
            "options": {
                "tabSize": tab_size,
                "insertSpaces": insert_spaces,
            },
        });

        let encoding = self.active_position_encoding();
        let request_id = self.lsp_alloc_request_id();
        self.lsp_pending.insert(
            request_id,
            LspPendingRequest::Format {
                buffer_id,
                range: None,
                encoding,
            },
        );
        if let Some(mgr) = self.lsp.as_ref() {
            mgr.send_request(request_id, buffer_id, "textDocument/formatting", params);
        }
    }

    /// Handle a `textDocument/formatting` response.
    pub(crate) fn handle_format_response(
        &mut self,
        buffer_id: hjkl_lsp::BufferId,
        _range: Option<(usize, usize, usize, usize)>,
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
        encoding: hjkl_lsp::PositionEncoding,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP format: {}", e.message));
                return;
            }
        };

        if val.is_null() {
            self.bus.warn("no formatting changes");
            return;
        }

        let edits: Vec<lsp_types::TextEdit> = match serde_json::from_value(val) {
            Ok(e) => e,
            Err(_) => {
                self.bus.error("LSP format: could not parse response");
                return;
            }
        };

        if edits.is_empty() {
            self.bus.warn("no formatting changes");
            return;
        }

        // Find the slot matching buffer_id.
        let slot_idx = self
            .slots
            .iter()
            .position(|s| s.buffer_id as hjkl_lsp::BufferId == buffer_id);
        let Some(slot_idx) = slot_idx else {
            self.bus.error("LSP format: buffer no longer open");
            return;
        };

        // Sort edits by range END descending.
        let mut sorted = edits;
        sorted.sort_by(|a, b| {
            let ea = (a.range.end.line, a.range.end.character);
            let eb = (b.range.end.line, b.range.end.character);
            eb.cmp(&ea)
        });

        use hjkl_engine::BufferEdit;
        for te in sorted {
            // Same descending-order + current-rope-read reasoning as
            // `apply_workspace_edit` (audit R2, UTF-16 fix).
            let rope = self.slots[slot_idx].buffer().rope();
            let start = wire_position_to_pos(
                &rope,
                te.range.start.line,
                te.range.start.character,
                encoding,
            );
            let end =
                wire_position_to_pos(&rope, te.range.end.line, te.range.end.character, encoding);
            BufferEdit::replace_range(self.slots[slot_idx].buffer_mut(), start..end, &te.new_text);
        }

        let _ = self.slots[slot_idx].take_dirty();
        self.slots[slot_idx].dirty = true;
        self.bus.info("formatted");
    }

    /// Handle a `textDocument/completion` response.
    pub(crate) fn handle_completion_response(
        &mut self,
        buffer_id: hjkl_lsp::BufferId,
        anchor_row: usize,
        anchor_col: usize,
        auto: bool,
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP completion: {}", e.message));
                return;
            }
        };

        // Guard: discard if user left insert mode or switched buffer.
        use hjkl_engine::VimMode;
        use hjkl_vim::VimEditorExt;
        if self.active_editor().vim_mode() != VimMode::Insert {
            return;
        }
        if (self.active().buffer_id as hjkl_lsp::BufferId) != buffer_id {
            return;
        }

        // Parse CompletionResponse (null | CompletionList | Vec<CompletionItem>).
        let lsp_items: Vec<lsp_types::CompletionItem> = if val.is_null() {
            Vec::new()
        } else if let Ok(list) = serde_json::from_value::<lsp_types::CompletionList>(val.clone()) {
            list.items
        } else {
            serde_json::from_value::<Vec<lsp_types::CompletionItem>>(val).unwrap_or_default()
        };

        // The partial word currently under the cursor — used both to filter the
        // popup and as the `exclude` token for buffer-word harvesting.
        let cursor = self.active_editor().buffer().cursor();
        let prefix = if cursor.row == anchor_row && cursor.col >= anchor_col {
            self.token_between(anchor_row, anchor_col, cursor.col)
        } else {
            String::new()
        };

        let mut items: Vec<crate::completion::CompletionItem> =
            lsp_items.into_iter().map(item_from_lsp).collect();

        // Augment LSP results with unique tokens from the open buffers (vim-like
        // keyword completion). Dedupe by label so a word the server already
        // returned isn't listed twice; buffer words rank below exact LSP hits
        // via the shared fuzzy scorer once the prefix is applied.
        let existing: std::collections::HashSet<String> =
            items.iter().map(|i| i.label.clone()).collect();
        for w in self.buffer_word_items(&prefix) {
            if !existing.contains(&w.label) {
                items.push(w);
            }
        }

        if items.is_empty() {
            // As-you-type requests stay quiet; only an explicit invocation
            // reports an empty result.
            if !auto {
                self.bus.warn("no completions");
            }
            return;
        }

        let mut popup = Completion::new(anchor_row, anchor_col, items);
        // The user may have typed further chars between firing the request and
        // this response landing. Filter the freshly-opened popup by whatever is
        // currently between the anchor and the cursor so it shows the right
        // subset immediately rather than the full list for one frame.
        if !prefix.is_empty() {
            popup.set_prefix(&prefix);
        }
        if popup.is_empty() {
            return;
        }
        self.completion = Some(popup);
    }

    /// Accept the currently selected completion item, inserting its text
    /// into the buffer and dismissing the popup.
    ///
    /// Replace strategy: delete from `anchor_col` to the current cursor col
    /// on the same row, then insert `insert_text` at that position.
    /// TODO: honour `text_edit` with non-prefix ranges (filed for follow-up).
    pub(crate) fn accept_completion(&mut self) {
        let Some(popup) = self.completion.take() else {
            return;
        };
        let item = match popup.selected_item() {
            Some(i) => i.clone(),
            None => return,
        };

        let cursor = self.active_editor().buffer().cursor();
        let row = cursor.row;
        let cur_col = cursor.col;
        let anchor_col = popup.anchor_col.min(cur_col);

        // For Function/Method items: ensure parens exist and place cursor inside.
        // Strip trivial snippet markers ($0, ${0}) before examining the text.
        let raw_text = &item.insert_text;
        let is_fn = matches!(
            item.kind,
            crate::completion::CompletionKind::Function | crate::completion::CompletionKind::Method
        );

        let (actual_text, cursor_offset) = if is_fn {
            // Strip a trailing `$0` or `${0}` snippet placeholder if present.
            let stripped = if raw_text.ends_with("$0") {
                &raw_text[..raw_text.len() - 2]
            } else if raw_text.ends_with("${0}") {
                &raw_text[..raw_text.len() - 4]
            } else {
                raw_text.as_str()
            };

            if let Some(paren_pos) = stripped.find('(') {
                // Already has parens — place cursor after the `(`.
                (stripped.to_string(), paren_pos + 1)
            } else {
                // Bare name — append `()` and place cursor between them.
                let with_parens = format!("{}()", stripped);
                let offset = stripped.len() + 1; // position after `(`
                (with_parens, offset)
            }
        } else {
            // Non-function: cursor at end of inserted text.
            (raw_text.clone(), raw_text.len())
        };

        // Replace [anchor_col, cur_col) with actual_text via the editor's
        // tracked mutation funnel. Going through `mutate_edit` (rather than
        // poking `buffer_mut()` directly) records a `ContentEdit` so the host's
        // `sync_after_engine_mutation` drains it → `syntax.apply_edits` updates
        // the tree-sitter tree and `lsp_notify_change_active` fires a didChange.
        // Without this, accepting a completion left the parse tree and the LSP
        // server with stale text — diagnostics / parse-error gutter signs for
        // the now-fixed line lingered until the next manual edit (#143).
        use hjkl_buffer::{Edit, Position};
        self.active_editor_mut().mutate_edit(Edit::Replace {
            start: Position::new(row, anchor_col),
            end: Position::new(row, cur_col),
            with: actual_text,
        });

        // Move cursor to computed offset within the inserted text.
        let new_col = anchor_col + cursor_offset;
        self.active_editor_mut().jump_cursor(row, new_col);
        // completion was already taken via `take()` above, so it's already None.
    }

    /// Handle a hover response (K-key) — show the compact cursor-anchored hover
    /// popup, the same widget mouse hover uses (content-sized, not an 80%×60%
    /// modal). `origin` is the cursor's doc `(row, col)` from the request.
    pub(crate) fn handle_hover_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        origin: (usize, usize),
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
    ) {
        let val = match result {
            Ok(v) => v,
            Err(e) => {
                self.bus.error(format!("LSP hover: {}", e.message));
                return;
            }
        };
        if val.is_null() {
            self.bus.warn("no hover info");
            return;
        }
        let hover: lsp_types::Hover = match serde_json::from_value(val) {
            Ok(h) => h,
            Err(_) => {
                self.bus.error("LSP hover: could not parse response");
                return;
            }
        };
        let text = extract_hover_markdown(&hover.contents);
        if text.trim().is_empty() {
            self.bus.warn("no hover info");
        } else {
            // Anchor at the cursor cell and reuse the mouse-hover popup so K
            // and mouse hover share the same compact, content-sized rendering.
            let win_id = self.focused_window();
            let (doc_row, doc_col) = origin;
            let cell =
                crate::app::mouse::doc_to_cell(self, win_id, doc_row, doc_col).unwrap_or((0, 0));
            self.hover_popup = Some(hjkl_hover::HoverState::new(
                text,
                hjkl_hover::HoverAnchor::new(cell.0, cell.1),
            ));
        }
    }

    /// Handle a mouse-hover response — set `hover_popup` if the timer is
    /// still armed at the same cell. Drops the result if the user moved.
    pub(crate) fn handle_hover_at_mouse_response(
        &mut self,
        _buffer_id: hjkl_lsp::BufferId,
        _origin: (usize, usize),
        result: Result<serde_json::Value, hjkl_lsp::RpcError>,
    ) {
        // Drop the response if a blocking overlay (context menu, picker,
        // command field) opened while the RPC was in flight — the popup
        // would render through the overlay onto the buffer text behind it.
        if self.overlay_active() {
            return;
        }
        // Only show the popup when the timer is still armed and the RPC was
        // sent from it (guard against stale in-flight requests).
        let timer_cell = match &self.hover_timer {
            Some(t) if t.request_sent => t.cell,
            _ => return, // user moved before the response arrived
        };

        // Silently drop errors for mouse hover.
        let Ok(val) = result else { return };
        if val.is_null() {
            return; // no hover info — don't show an empty popup
        }
        let hover: lsp_types::Hover = match serde_json::from_value(val) {
            Ok(h) => h,
            Err(_) => return,
        };
        let text = extract_hover_markdown(&hover.contents);
        if text.trim().is_empty() {
            return;
        }
        self.hover_popup = Some(hjkl_hover::HoverState::new(
            text,
            hjkl_hover::HoverAnchor::new(timer_cell.0, timer_cell.1),
        ));
    }
}

/// Extract raw markdown from LSP hover contents for rendering by
/// `hjkl-markdown-tui` (K-key popup and mouse hover popup paths).
///
/// `MarkedString::LanguageString` is wrapped in a CommonMark fenced block so
/// the markdown renderer can syntax-highlight it.
fn extract_hover_markdown(contents: &lsp_types::HoverContents) -> String {
    match contents {
        lsp_types::HoverContents::Scalar(ms) => marked_string_to_md(ms),
        lsp_types::HoverContents::Array(items) => items
            .iter()
            .map(marked_string_to_md)
            .collect::<Vec<_>>()
            .join("\n\n"),
        // MarkupContent: already markdown (or plain text for `kind = "plaintext"`).
        lsp_types::HoverContents::Markup(mc) => mc.value.clone(),
    }
}

fn marked_string_to_md(ms: &lsp_types::MarkedString) -> String {
    match ms {
        // Plain string — pass through as-is; valid CommonMark.
        lsp_types::MarkedString::String(s) => s.clone(),
        // Language-annotated snippet — wrap in a fenced code block.
        lsp_types::MarkedString::LanguageString(ls) => {
            format!("```{}\n{}\n```", ls.language, ls.value)
        }
    }
}

impl App {
    /// Dispatch an LSP-related [`crate::keymap_actions::AppAction`].
    ///
    /// Handles variants:
    ///   - ShowDiagAtCursor, LspCodeActions, LspRename
    ///   - LspGotoDef, LspGotoDecl, LspGotoRef, LspGotoImpl, LspGotoTypeDef
    ///   - LspHover
    ///   - DiagNext, DiagPrev, DiagNextError, DiagPrevError
    pub(crate) fn dispatch_lsp_action(&mut self, action: crate::keymap_actions::AppAction) {
        use crate::keymap_actions::AppAction;
        match action {
            AppAction::ShowDiagAtCursor => self.show_diag_at_cursor(),
            AppAction::LspCodeActions => self.lsp_code_actions(),
            AppAction::LspRename => {
                // Phase 5 MVP: prompt user to use :Rename <newname>.
                self.bus.info("use :Rename <newname> to rename");
            }
            AppAction::LspGotoDef => self.lsp_goto_definition(),
            AppAction::LspGotoDecl => self.lsp_goto_declaration(),
            AppAction::LspGotoRef => self.lsp_goto_references(),
            AppAction::LspGotoImpl => self.lsp_goto_implementation(),
            AppAction::LspGotoTypeDef => self.lsp_goto_type_definition(),
            AppAction::LspHover => self.lsp_hover(),
            AppAction::DiagNext => self.dispatch_ex("lnext"),
            AppAction::DiagPrev => self.dispatch_ex("lprev"),
            AppAction::DiagNextError => self.lnext_severity(Some(super::DiagSeverity::Error)),
            AppAction::DiagPrevError => self.lprev_severity(Some(super::DiagSeverity::Error)),
            _ => {}
        }
    }
}

#[cfg(test)]
mod position_encoding_tests {
    use super::{col_to_wire, wire_to_col};
    use hjkl_lsp::PositionEncoding;

    // ── col_to_wire (internal char index -> wire units) ────────────────────

    #[test]
    fn ascii_identity_both_encodings() {
        let line = "let x = 1;";
        for col in [0, 1, 5, line.len()] {
            assert_eq!(col_to_wire(line, col, PositionEncoding::Utf8), col as u32);
            assert_eq!(col_to_wire(line, col, PositionEncoding::Utf16), col as u32);
        }
    }

    #[test]
    fn two_byte_char_diverges_only_under_utf8() {
        // "héllo" — 'é' is 2 UTF-8 bytes, 1 UTF-16 code unit, 1 char.
        let line = "héllo";
        // char index 1 == just past 'h', before 'é'.
        assert_eq!(col_to_wire(line, 1, PositionEncoding::Utf8), 1);
        assert_eq!(col_to_wire(line, 1, PositionEncoding::Utf16), 1);
        // char index 2 == just past 'é'. UTF-8: h(1)+é(2)=3. UTF-16: 1+1=2.
        assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf8), 3);
        assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf16), 2);
        // char index 5 == end of line ("héllo" has 5 chars).
        assert_eq!(col_to_wire(line, 5, PositionEncoding::Utf8), 6);
        assert_eq!(col_to_wire(line, 5, PositionEncoding::Utf16), 5);
    }

    #[test]
    fn astral_emoji_diverges_under_both_encodings() {
        // "a🎉b" — U+1F389 is 4 UTF-8 bytes, 2 UTF-16 code units (surrogate
        // pair), 1 char.
        let line = "a🎉b";
        // char index 2 == just past the emoji, before 'b'.
        assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf8), 5); // a(1)+emoji(4)
        assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf16), 3); // a(1)+emoji(2)
        // char index 3 == end of line.
        assert_eq!(col_to_wire(line, 3, PositionEncoding::Utf8), 6);
        assert_eq!(col_to_wire(line, 3, PositionEncoding::Utf16), 4);
    }

    #[test]
    fn mixed_line_utf8_and_utf16() {
        // "héllo🎉x" — é (2 bytes/1 unit) then emoji (4 bytes/2 units).
        let line = "héllo🎉x";
        // char index 6 == just past the emoji ('h','é','l','l','o','🎉').
        let byte_len_before_emoji_char = "héllo".len(); // 6
        assert_eq!(
            col_to_wire(line, 6, PositionEncoding::Utf8) as usize,
            byte_len_before_emoji_char + 4
        );
        assert_eq!(col_to_wire(line, 6, PositionEncoding::Utf16), 5 + 2); // "héllo" is 5 units + emoji 2
    }

    #[test]
    fn col_to_wire_overshoot_clamps_to_full_line() {
        let line = "hé";
        assert_eq!(
            col_to_wire(line, 9999, PositionEncoding::Utf8),
            line.len() as u32
        );
        assert_eq!(
            col_to_wire(line, 9999, PositionEncoding::Utf16),
            line.chars().map(|c| c.len_utf16()).sum::<usize>() as u32
        );
    }

    // ── wire_to_col (wire units -> internal char index) ─────────────────────

    #[test]
    fn wire_to_col_ascii_identity() {
        let line = "let x = 1;";
        for wire in [0u32, 1, 5, line.len() as u32] {
            assert_eq!(
                wire_to_col(line, wire, PositionEncoding::Utf8),
                wire as usize
            );
            assert_eq!(
                wire_to_col(line, wire, PositionEncoding::Utf16),
                wire as usize
            );
        }
    }

    #[test]
    fn wire_to_col_two_byte_char() {
        let line = "héllo";
        // UTF-8 wire col 3 (past h(1)+é(2)) -> char index 2.
        assert_eq!(wire_to_col(line, 3, PositionEncoding::Utf8), 2);
        // UTF-16 wire col 2 (past h(1)+é(1)) -> char index 2.
        assert_eq!(wire_to_col(line, 2, PositionEncoding::Utf16), 2);
    }

    #[test]
    fn wire_to_col_astral_emoji() {
        let line = "a🎉b";
        // UTF-8 wire col 5 (a=1, emoji=4) -> char index 2 (just past emoji).
        assert_eq!(wire_to_col(line, 5, PositionEncoding::Utf8), 2);
        // UTF-16 wire col 3 (a=1, emoji=2) -> char index 2.
        assert_eq!(wire_to_col(line, 3, PositionEncoding::Utf16), 2);
    }

    #[test]
    fn wire_to_col_overshoot_clamps_to_line_end() {
        let line = "hé";
        assert_eq!(
            wire_to_col(line, 9999, PositionEncoding::Utf8),
            line.chars().count()
        );
        assert_eq!(
            wire_to_col(line, 9999, PositionEncoding::Utf16),
            line.chars().count()
        );
    }

    /// A wire column landing mid-char (e.g. a buggy server reporting the
    /// second UTF-16 unit of a surrogate pair) must resolve to the START of
    /// that char rather than panicking or skipping past it.
    #[test]
    fn wire_to_col_mid_char_snaps_to_char_start() {
        let line = "a🎉b";
        // UTF-16 wire col 2 lands between the emoji's two surrogate units
        // (a=1, first unit=2) — must resolve to the emoji's char index (1),
        // not skip to 'b' (index 2).
        assert_eq!(wire_to_col(line, 2, PositionEncoding::Utf16), 1);
    }

    // ── round-trip ───────────────────────────────────────────────────────────

    /// `wire_to_col(line, col_to_wire(line, c, enc), enc) == c` for every char
    /// boundary on a mixed line, both encodings.
    #[test]
    fn round_trip_every_char_boundary() {
        let line = "héllo🎉world_x";
        let n = line.chars().count();
        for enc in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
            for c in 0..=n {
                let wire = col_to_wire(line, c, enc);
                assert_eq!(
                    wire_to_col(line, wire, enc),
                    c,
                    "round-trip failed at char {c} under {enc:?}"
                );
            }
        }
    }

    /// The motivating scenario from the fix: a diagnostic on line
    /// `s = "héllo"  # x` reported by a UTF-16 server at the wire column of
    /// `#` must convert to hjkl's char column of `#`, and converting back
    /// must reproduce the original wire column.
    #[test]
    fn round_trip_hash_comment_after_multibyte_string() {
        let line = r#"s = "héllo"  # x"#;
        let hash_char_idx = line.chars().position(|c| c == '#').unwrap();
        let wire_utf16 = col_to_wire(line, hash_char_idx, PositionEncoding::Utf16);
        // "héllo" contributes 1 UTF-16 unit for 'é' (same as char count), so
        // for this BMP-only example UTF-16 wire units equal the char index —
        // still exercises the full helper path end-to-end.
        assert_eq!(
            wire_to_col(line, wire_utf16, PositionEncoding::Utf16),
            hash_char_idx
        );
        assert_eq!(&line[..line.find('#').unwrap()], "s = \"héllo\"  ");

        // UTF-8 wire units DO diverge here (é is 2 bytes): converting with
        // UTF-8 must land on a different (larger) wire column than UTF-16,
        // proving the encoding parameter actually changes behavior.
        let wire_utf8 = col_to_wire(line, hash_char_idx, PositionEncoding::Utf8);
        assert!(wire_utf8 > wire_utf16);
        assert_eq!(
            wire_to_col(line, wire_utf8, PositionEncoding::Utf8),
            hash_char_idx
        );
    }
}

#[cfg(test)]
mod lsp_glue_tests {
    use super::{build_text_changes, floor_char_boundary};

    /// Apply a single-row `TextChange` (row 0 only — sufficient for these
    /// single-line fixtures) to `doc`, replacing the byte range
    /// `[start_col, end_col)` with `change.text`. Mirrors what a real LSP
    /// client does when it applies a `contentChanges` array in order.
    fn apply_change(doc: &str, change: &hjkl_lsp::TextChange) -> String {
        assert_eq!(change.start_line, 0);
        assert_eq!(change.end_line, 0);
        let start = change.start_col as usize;
        let end = change.end_col as usize;
        let mut out = String::new();
        out.push_str(&doc[..start]);
        out.push_str(&change.text);
        out.push_str(&doc[end..]);
        out
    }

    /// `floor_char_boundary` must floor a byte index that falls inside a
    /// multi-byte char (here a 4-byte emoji U+1F600) to the char's first byte.
    #[test]
    fn snap_floors_interior_emoji_byte() {
        // "😀" encodes as 4 bytes: F0 9F 98 80
        let rope = ropey::Rope::from_str("hi😀bye");
        // Char starts at byte 2; bytes 3, 4, 5 are interior continuation bytes.
        let emoji_start = 2usize;
        for interior in emoji_start..emoji_start + 4 {
            let snapped = floor_char_boundary(&rope, interior);
            assert_eq!(
                snapped, emoji_start,
                "byte {interior} inside emoji should snap to byte {emoji_start}, got {snapped}"
            );
        }
        // Byte past the emoji should snap to itself (it's already aligned).
        let past = emoji_start + 4;
        assert_eq!(floor_char_boundary(&rope, past), past);
    }

    /// `build_text_changes` must not panic when `new_end_byte` is clamped
    /// into the middle of a multi-byte char.
    #[test]
    fn build_text_changes_no_panic_on_emoji() {
        use hjkl_engine::ContentEdit;
        // Build a rope whose length falls mid-emoji when clamped.
        // "abc😀" = 3 + 4 = 7 bytes; len_bytes = 7.
        // Craft an edit whose new_end_byte = len - 2 lands inside the 4-byte emoji.
        let rope = ropey::Rope::from_str("abc😀\n");
        let len = rope.len_bytes();
        let edit = ContentEdit {
            start_byte: 0,
            old_end_byte: 0,
            new_end_byte: len.saturating_sub(2), // interior emoji byte
            start_position: (0, 0),
            old_end_position: (0, 0),
            new_end_position: (0, 0),
        };
        // Must not panic.
        let changes = build_text_changes(&rope, &[edit]).expect("single edit is ascending-safe");
        assert!(!changes.is_empty());
        // The extracted text must be valid UTF-8 (implicit: it compiled to String).
        assert!(std::str::from_utf8(changes[0].text.as_bytes()).is_ok());
    }

    /// Regression (audit R2, fix 2): a batch recorded in end-DESCENDING
    /// application order — exactly what `apply_workspace_edit` produces
    /// (it deliberately sorts edits by range-end descending so applying a
    /// later edit doesn't shift an earlier edit's positions) — must NOT be
    /// sliced from the final rope. Doing so silently derives the wrong
    /// replacement text.
    ///
    /// Worked example from a `foo` -> `x` rename over `"foo bar foo"`:
    /// the rightmost `foo` (bytes 8..11) is replaced first, then the
    /// leftmost `foo` (bytes 0..3) — final doc `"x bar x"` (7 bytes). The
    /// pre-fix code sliced the first edit's `[8, 9)` from the 7-byte final
    /// rope (clamped to `[7, 7)`) and got `""` instead of `"x"`, which,
    /// replayed against the pre-edit doc, drops the trailing `x` entirely.
    #[test]
    fn build_text_changes_rejects_descending_batch() {
        use hjkl_engine::ContentEdit;

        let pre = "foo bar foo";
        let post = "x bar x";
        let final_rope = ropey::Rope::from_str(post);

        let edits = vec![
            // Rightmost "foo" (bytes 8..11) -> "x", recorded (and applied)
            // first — matches `apply_workspace_edit`'s descending sort.
            ContentEdit {
                start_byte: 8,
                old_end_byte: 11,
                new_end_byte: 9,
                start_position: (0, 8),
                old_end_position: (0, 11),
                new_end_position: (0, 9),
            },
            // Leftmost "foo" (bytes 0..3) -> "x", recorded second.
            ContentEdit {
                start_byte: 0,
                old_end_byte: 3,
                new_end_byte: 1,
                start_position: (0, 0),
                old_end_position: (0, 3),
                new_end_position: (0, 1),
            },
        ];

        assert!(
            build_text_changes(&final_rope, &edits).is_none(),
            "descending batch must be rejected instead of sliced from the \
            final rope — the caller must fall back to a full-document sync"
        );

        // Sanity: this is exactly the scenario that corrupted the server's
        // copy pre-fix — `pre` replayed with the (rejected) old behaviour
        // would have produced "x bar " instead of `post`, dropping the
        // trailing "x".
        let _ = pre;
    }

    /// Companion to the descending-batch rejection above: an
    /// ascending-and-disjoint batch (the common case — edits recorded in
    /// left-to-right application order) must still produce changes that,
    /// replayed in order against the pre-edit document, reconstruct the
    /// post-edit document exactly.
    #[test]
    fn build_text_changes_ascending_batch_replays_correctly() {
        use hjkl_engine::ContentEdit;

        // "foo bar foo" -> replace first "foo" with "x" (ascending step 1)
        // -> "x bar foo" -> replace second "foo" with "y" (ascending step 2)
        // -> "x bar y".
        let pre = "foo bar foo";
        let post = "x bar y";
        let final_rope = ropey::Rope::from_str(post);

        let edits = vec![
            // Leftmost "foo" (bytes 0..3) -> "x", recorded first.
            ContentEdit {
                start_byte: 0,
                old_end_byte: 3,
                new_end_byte: 1,
                start_position: (0, 0),
                old_end_position: (0, 3),
                new_end_position: (0, 1),
            },
            // Second "foo", recorded against the state AFTER the first
            // edit ("x bar foo") where it sits at bytes 6..9 -> "y".
            ContentEdit {
                start_byte: 6,
                old_end_byte: 9,
                new_end_byte: 7,
                start_position: (0, 6),
                old_end_position: (0, 9),
                new_end_position: (0, 7),
            },
        ];

        let changes =
            build_text_changes(&final_rope, &edits).expect("ascending batch must be accepted");
        assert_eq!(changes.len(), 2);

        let mut doc = pre.to_string();
        for change in &changes {
            doc = apply_change(&doc, change);
        }
        assert_eq!(
            doc, post,
            "replaying the ascending batch's changes against the pre-edit \
            doc must reconstruct the post-edit doc"
        );
    }

    /// A `WorkspaceEdit` targeting a file OUTSIDE every active LSP workspace
    /// root must still apply (buffers are never auto-saved, and multi-root /
    /// out-of-tree setups are legitimate) but must push a visible WARN toast
    /// naming the out-of-root file so the user is alerted before `:w`.
    #[test]
    #[allow(clippy::mutable_key_type)] // lsp_types::Uri interns via Cell internally
    fn workspace_edit_outside_root_warns() {
        use std::str::FromStr;

        let root = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        let victim = outside.path().join("victim.txt");
        std::fs::write(&victim, "hello\n").unwrap();

        let mut app = super::App::new(None, false, None, None).unwrap();
        // Pretend a server is running rooted at `root`.
        app.lsp_state.insert(
            hjkl_lsp::ServerKey {
                language: "rust".into(),
                root: root.path().to_path_buf(),
            },
            super::LspServerInfo {
                initialized: true,
                capabilities: serde_json::Value::Null,
            },
        );

        let url = hjkl_lsp::uri::from_path(&victim).unwrap();
        let uri = lsp_types::Uri::from_str(url.as_str()).unwrap();
        let te = lsp_types::TextEdit {
            range: lsp_types::Range::new(
                lsp_types::Position::new(0, 0),
                lsp_types::Position::new(0, 5),
            ),
            new_text: "howdy".into(),
        };
        let mut changes = std::collections::HashMap::new();
        changes.insert(uri, vec![te]);
        let edit = lsp_types::WorkspaceEdit {
            changes: Some(changes),
            ..Default::default()
        };

        let applied = app
            .apply_workspace_edit(edit, hjkl_lsp::PositionEncoding::Utf8)
            .unwrap();
        assert_eq!(applied, 1, "the out-of-root edit must still be applied");

        let warned = app
            .bus
            .history()
            .any(|h| h.severity == hjkl_holler::Severity::Warn && h.body.contains("victim.txt"));
        assert!(warned, "expected a WARN toast naming the out-of-root file");
    }
}
