// editor_core.rs --- Mutable world state shared between Rust and Lua.

//! [`EditorCore`] is the editor's world state: a buffer registry, a
//! window tree, a focused window, file metadata, and the
//! minibuffer. Lives behind a `Rc<RefCell<...>>` so the Lua-bound
//! primitives in [`crate::lua_bindings`] (`pmacs.editor.*`,
//! `pmacs.window.*`) can mutate it from inside command bodies
//! invoked through [`crate::lua::LuaHost::invoke_command`].
//!
//! # Window model (T M2.8)
//!
//! Buffers live in [`BufferRegistry`]. Each [`Window`] points at one
//! by [`BufferId`] and owns its own cursor / view-top / goal-column /
//! [`TextView`]. The [`Layout`] tree maps the cell grid to per-window
//! viewport rectangles. A single [`WindowId`] is "active": every
//! `pmacs.editor.*` primitive operates on it; cursor and edits in
//! the run loop dispatch through it.
//!
//! When the active buffer mutates, [`EditorCore::apply_active_edit`]
//! notifies *every* window whose `buffer_id` matches the active
//! window's --- two windows on the same buffer keep their layout
//! caches synchronized.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};

use crate::buffer::{Buffer, BufferId, EditOp};
use crate::file_io::{FileMeta, save_atomic};
use crate::lua_bindings::SharedRegistry;
use crate::minibuffer::Minibuffer;
use crate::protocol::FrontendId;
use crate::rope::Edit;
use crate::rope::{Position, Range};
use crate::text_view::TextView;
use crate::view::{DisplayCoord, View};
use crate::window::{FrontendView, Layout, Orientation, Window, WindowId};

/// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op.
///
/// Records **whether the originating frontend already applied the
/// op to its local mirror**, which determines whether the broadcast
/// sweep should exclude that frontend.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum CrdtOpOrigin {
    /// A replica frontend's `FrontendEvent::CrdtOp` path applied the
    /// op to its local mirror before sending. Broadcast must exclude
    /// that frontend (it would double-apply otherwise — see
    /// `BufferMirror::apply_local_insert` /
    /// `apply_local_delete` and `optimistic::apply_incoming_crdt_op`'s
    /// echo-skip rule).
    OptimisticReplica(FrontendId),
    /// Daemon-side mutation (a `FrontendEvent::Key` round-trip, a
    /// Lua-driven edit, a fallback path) generated the op. No
    /// frontend has applied it locally; broadcast to every replica
    /// frontend, including the one whose `Key` event drove the
    /// daemon path (its mirror is otherwise stale).
    DaemonKey,
}

/// Live state of an in-progress incremental search (Q#SR5).
///
/// Present only while an isearch is running (`EditorCore::search`);
/// `None` otherwise. Holds the query as typed so far plus the cursor
/// origin to restore on cancel. The *matches* themselves live in
/// [`crate::search::SearchStore`] (shared with the decorations
/// producer and the TUI overlay); this struct is the per-session
/// input state that drives `find_all`.
#[derive(Clone, Debug)]
pub struct SearchSession {
    /// The query as typed so far. Each edit re-runs `find_all`.
    query: String,
    /// Buffer + cursor position when the search began. `C-g` / `Esc`
    /// restores this; `RET` keeps the current (match) cursor. The
    /// buffer id also anchors `find_all` to the buffer the search
    /// started in.
    origin: (BufferId, Position),
    /// Direction of the most recent step/begin. `true` = forward.
    /// Drives the prompt label ("I-search" vs "I-search backward")
    /// and the wrap direction of an empty-query repeat.
    forward: bool,
    /// Whether the query is a regex (Q#RX3). `false` = smart-case
    /// substring (`find_all`); `true` = smart-case regex
    /// (`find_all_regex`). Toggled live by `M-r`.
    regex: bool,
    /// `true` when the last recompute's regex pattern failed to compile
    /// — the prompt shows `[invalid]` instead of a match count. Always
    /// `false` in literal mode (substring never "fails to compile").
    invalid: bool,
}

/// The world state mutated by editor commands.
pub struct EditorCore {
    /// Shared buffer registry. The registry is the canonical owner
    /// of every buffer; windows reference buffers by [`BufferId`].
    pub registry: SharedRegistry,
    /// All windows, keyed by id for stable iteration. `WindowId`s
    /// are globally unique across all frontends; each
    /// [`FrontendView`] in `views` references a subset via its
    /// `Layout`.
    pub windows: BTreeMap<WindowId, Window>,
    /// T M10.8 — per-frontend views. Each attached frontend has its
    /// own `Layout` (split tree) + `active: WindowId`. Buffers are
    /// shared via `registry`; cursors / `view_top`s live in the
    /// per-frontend `Window` instances.
    ///
    /// Invariant: `FrontendId::LOCAL` always has an entry. The
    /// in-process editor uses this view; daemon-attached frontends
    /// register additional entries on attach (M10.8 Day 3 wires
    /// the per-attach registration via the dispatcher; Day 2 ships
    /// a fallback-to-LOCAL accessor so single-frontend tests pass
    /// before per-attach registration lands).
    pub views: HashMap<FrontendId, FrontendView>,
    /// One-line message shown in the status line.
    pub status: String,
    /// True iff the editor should exit at the next iteration.
    pub quit: bool,
    /// Universal minibuffer (T M2.7).
    pub minibuffer: Minibuffer,
    /// The frontend that produced the most recent input event
    /// dispatched to this core (T M5.4). v0.1 has a single frontend
    /// per instance, so this stays at [`FrontendId::LOCAL`] in
    /// practice; the field is load-bearing for v0.3 multi-frontend
    /// (multi-window, multi-user) where each input event must be
    /// attributable to its source frontend.
    pub active_frontend: FrontendId,
    /// T M10.8 Day 4 — pending CRDT ops queue.
    ///
    /// Each [`CrdtOpOrigin`] entry records both **what** to broadcast
    /// and **who already applied it locally** (the sender-exclusion
    /// signal). The dispatcher drains the queue per-tick and
    /// broadcasts each op to multi-frontend sessions with
    /// `crdt_replica` negotiated.
    ///
    /// # M10.10 post-audit-round-3 F16: origin tagging
    ///
    /// Sender exclusion depends on **whether the originating
    /// frontend already applied the op to its local mirror**:
    ///
    /// - [`CrdtOpOrigin::OptimisticReplica`] — a replica frontend's
    ///   `FrontendEvent::CrdtOp` path applied the op to its mirror
    ///   before sending. Broadcast must exclude that frontend so it
    ///   doesn't double-apply.
    /// - [`CrdtOpOrigin::DaemonKey`] — daemon-side mutation (a
    ///   `FrontendEvent::Key` round-trip, a Lua-driven edit, etc.)
    ///   generated the op. No frontend's mirror has applied it
    ///   locally; broadcast must include every replica frontend
    ///   *including* the active one. Without this, the
    ///   active frontend's mirror would silently drift from daemon
    ///   state after every fallback / Key-path edit.
    pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
    /// T M4.5 L1 — bounded jump ring. Cross-file navigation
    /// (`go-to-definition`, references, symbol jumps) pushes the
    /// pre-jump `(BufferId, Position)` here before moving the cursor;
    /// `M-,` (`jump_back`) pops the most recent entry and restores
    /// it. Bounded at [`Self::JUMP_RING_CAP`]: the oldest entry is
    /// evicted when full, so a long navigation session can't grow
    /// this without limit. Entries naming a now-removed buffer are
    /// skipped on pop (stale-handle safe, mirrors the registry's
    /// `Missing` contract).
    pub jump_ring: Vec<(BufferId, Position)>,
    /// In-buffer incremental search store (Q#SR1). Per-buffer query +
    /// matches + active index, written by the search session /
    /// `search.*` commands and read by the decorations producer
    /// ([`crate::semantic_render`]) and the TUI search overlay.
    /// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
    pub search_store: crate::search::SharedSearchStore,
    /// Live incremental-search session (Q#SR5), or `None` when no
    /// search is running. Frontend-agnostic: the TUI run loop and the
    /// daemon's `FrontendEvent::Key` path both drive it through the
    /// same `search_*` methods, so isearch behaves identically in the
    /// terminal and GPU frontends. Only the *prompt surface* differs
    /// (TUI bottom row vs GPU status band).
    pub search: Option<SearchSession>,
    /// In-core clipboard slot (Q#CM6) --- the bytes a paste inserts.
    /// Written by copy/cut and by an inbound OS paste; read by paste.
    /// The frontend-agnostic source of truth, so paste behaves
    /// identically in the terminal and GPU frontends.
    clipboard_slot: Vec<u8>,
    /// One-shot outbound clipboard publish (Q#CM6). A copy/cut queues
    /// `(originating frontend, bytes)`; the dispatcher drains it and
    /// sends [`crate::protocol::InstanceSignal::Clipboard`] to that
    /// frontend, which writes the OS clipboard (OSC 52 in the TUI,
    /// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
    pending_clipboard: Option<(FrontendId, Vec<u8>)>,
    /// Open context menu (Q#CM1), or `None` when closed. Shared
    /// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
    /// from the same state the dispatch path mutates — the menu twin of
    /// `search_store`.
    pub menu: crate::menu::SharedMenu,
}

impl EditorCore {
    /// A fresh core with one window on a `*scratch*` buffer.
    #[must_use]
    pub fn new(registry: SharedRegistry) -> Self {
        let buffer_id = registry.borrow_mut().create("*scratch*");
        let text_view = {
            let r = registry.borrow();
            let buf = r.get(buffer_id).expect("just-created scratch buffer");
            TextView::new(buf)
        };
        let id = WindowId::next();
        let window = Window::new(id, buffer_id, text_view);
        let mut windows = BTreeMap::new();
        windows.insert(id, window);
        let mut views = HashMap::new();
        views.insert(
            FrontendId::LOCAL,
            FrontendView {
                layout: Layout::single(id),
                active: id,
            },
        );
        Self {
            registry,
            windows,
            views,
            status: String::new(),
            quit: false,
            minibuffer: Minibuffer::new(),
            active_frontend: FrontendId::LOCAL,
            pending_crdt_ops: Vec::new(),
            jump_ring: Vec::new(),
            search_store: crate::search::make_shared_store(),
            search: None,
            clipboard_slot: Vec::new(),
            pending_clipboard: None,
            menu: crate::menu::make_shared_menu(),
        }
    }

    /// Build a core from raw bytes under `name`. Used by tests.
    /// Replaces the scratch buffer's content; the active window is
    /// retained.
    #[must_use]
    pub fn from_bytes(registry: SharedRegistry, name: impl Into<String>, bytes: &[u8]) -> Self {
        let mut core = Self::new(registry);
        let id = core.active_window().buffer_id;
        let new_id = {
            let mut reg = core.registry.borrow_mut();
            let new_id = reg.create_from_bytes(name, bytes);
            // Replace the active window's buffer with the new one.
            let _ = reg.remove(id);
            new_id
        };
        let text_view = {
            let reg = core.registry.borrow();
            TextView::new(reg.get(new_id).unwrap())
        };
        let aw = core.active_window_mut();
        aw.buffer_id = new_id;
        aw.text_view = text_view;
        aw.cursor = 0;
        aw.view_top = 0;
        aw.goal_col = None;
        core
    }

    // ---- accessors ---------------------------------------------------------

    /// T M10.8 — the active frontend's view (layout + active window).
    ///
    /// **Day 2 transitional behavior**: if `active_frontend` has no
    /// registered view (the daemon-attached frontend case before Day
    /// 3's dispatcher refactor wires `register_frontend_view`), fall
    /// back to `FrontendId::LOCAL`'s view. The invariant "LOCAL
    /// always has a view" is enforced by the constructor.
    #[must_use]
    pub fn active_view(&self) -> &FrontendView {
        self.views.get(&self.active_frontend).unwrap_or_else(|| {
            self.views.get(&FrontendId::LOCAL).expect(
                "invariant: FrontendId::LOCAL always has a registered FrontendView; \
                 populated by EditorCore::new and never removed",
            )
        })
    }

    /// Mutable view of the active frontend's [`FrontendView`].
    ///
    /// Same fallback semantics as [`active_view`].
    pub fn active_view_mut(&mut self) -> &mut FrontendView {
        // Choose the key first to avoid borrowing `self.views`
        // twice with overlapping lifetimes (the fallback path).
        let key = if self.views.contains_key(&self.active_frontend) {
            self.active_frontend
        } else {
            FrontendId::LOCAL
        };
        self.views.get_mut(&key).expect(
            "invariant: FrontendId::LOCAL always has a registered FrontendView; \
             populated by EditorCore::new and never removed",
        )
    }

    /// The active frontend's window-split tree.
    #[must_use]
    pub fn active_layout(&self) -> &Layout {
        &self.active_view().layout
    }

    /// Mutable access to the active frontend's window-split tree.
    pub fn active_layout_mut(&mut self) -> &mut Layout {
        &mut self.active_view_mut().layout
    }

    /// `WindowId` of the active frontend's focused window.
    #[must_use]
    pub fn active_window_id(&self) -> WindowId {
        self.active_view().active
    }

    /// Set the active frontend's focused window.
    pub fn set_active_window_id(&mut self, id: WindowId) {
        self.active_view_mut().active = id;
    }

    /// Reference the active [`Window`] — the window currently
    /// focused in the active frontend's view.
    #[must_use]
    pub fn active_window(&self) -> &Window {
        let id = self.active_window_id();
        self.windows
            .get(&id)
            .expect("active window present in core.windows")
    }

    /// Mutably reference the active [`Window`].
    pub fn active_window_mut(&mut self) -> &mut Window {
        let id = self.active_window_id();
        self.windows
            .get_mut(&id)
            .expect("active window present in core.windows")
    }

    /// Reference a specific frontend's active [`Window`].
    ///
    /// Returns `None` if `fid` has no registered view (no fallback —
    /// callers explicitly asking about a specific frontend get a
    /// truthful answer about whether that frontend has state).
    #[must_use]
    pub fn active_window_for(&self, fid: FrontendId) -> Option<&Window> {
        let view = self.views.get(&fid)?;
        self.windows.get(&view.active)
    }

    /// Mutably reference a specific frontend's active [`Window`].
    pub fn active_window_mut_for(&mut self, fid: FrontendId) -> Option<&mut Window> {
        let win_id = self.views.get(&fid)?.active;
        self.windows.get_mut(&win_id)
    }

    /// T M10.8 — register a `FrontendView` for `fid`. Called by the
    /// daemon on attach (Day 3 dispatcher work). Day 2's fallback
    /// path makes this optional; Day 3 makes it required.
    pub fn register_frontend_view(&mut self, fid: FrontendId, view: FrontendView) {
        self.views.insert(fid, view);
    }

    /// T M10.8 — drop a frontend's view on detach. The frontend's
    /// windows remain in `self.windows` until explicit cleanup (M10.x
    /// may add per-detach window pruning); for M10.8 they're
    /// orphaned but accessible by id (matches v0.1 behavior where
    /// closing a window left others intact).
    pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
        self.views.remove(&fid);
    }

    /// [`BufferId`] of the active window's buffer.
    #[must_use]
    pub fn active_buffer_id(&self) -> BufferId {
        self.active_window().buffer_id
    }

    /// Path bound to the active window's buffer, if any. T M4.5 L1:
    /// replaces the old `EditorCore.file_path` field — it now lives
    /// per-buffer so cross-file navigation keeps each buffer's
    /// identity straight.
    #[must_use]
    pub fn active_buffer_path(&self) -> Option<PathBuf> {
        let id = self.active_buffer_id();
        self.registry
            .borrow()
            .get(id)
            .ok()
            .and_then(|b| b.file_path().map(Path::to_path_buf))
    }

    /// Filesystem metadata recorded for the active window's buffer.
    #[must_use]
    pub fn active_file_meta(&self) -> Option<FileMeta> {
        let id = self.active_buffer_id();
        self.registry
            .borrow()
            .get(id)
            .ok()
            .and_then(|b| b.file_meta().cloned())
    }

    /// Bind a path (and clear metadata) on a specific buffer. Used by
    /// file open / `pmacs.buffer.from_file`.
    ///
    /// The path is normalized to an absolute, lexically-clean form
    /// first ([`normalize_buffer_path`]). This is the single seam
    /// every buffer identity flows through (CLI open, Lua find-file,
    /// `WorkspaceEdit` rename ops), so doing it here keeps the invariant
    /// "a buffer's `file_path` is always absolute" — which the LSP
    /// layer relies on to build a resolvable `file:///…` URI (a
    /// relative or `~`-prefixed path produced `file://ipc.cpp`, which
    /// clangd rejected with `-32602 unresolvable URI`) and which
    /// cross-file navigation relies on for buffer-identity matching.
    pub fn set_buffer_path(&mut self, id: BufferId, path: Option<PathBuf>) {
        let path = path.map(normalize_buffer_path);
        if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
            b.set_file_path(path);
        }
    }

    /// Record filesystem metadata on a specific buffer.
    pub fn set_buffer_meta(&mut self, id: BufferId, meta: Option<FileMeta>) {
        if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
            b.set_file_meta(meta);
        }
    }

    /// Cursor of the active window (compatibility shim for callers
    /// migrated from pre-M2.8 code).
    #[must_use]
    pub fn cursor(&self) -> Position {
        self.active_window().cursor
    }

    /// `view_top` of the active window.
    #[must_use]
    pub fn view_top(&self) -> usize {
        self.active_window().view_top
    }

    /// Active buffer's byte length.
    #[must_use]
    pub fn active_buffer_len(&self) -> u64 {
        let id = self.active_buffer_id();
        self.registry.borrow().get(id).map_or(0, Buffer::len)
    }

    /// Active buffer's name. Returns an owned String to release the
    /// registry borrow promptly.
    #[must_use]
    pub fn active_buffer_name(&self) -> String {
        let id = self.active_buffer_id();
        self.registry
            .borrow()
            .get(id)
            .map(|b| b.name().to_owned())
            .unwrap_or_default()
    }

    /// Returns true iff the active buffer has unsaved modifications.
    #[must_use]
    pub fn active_buffer_is_modified(&self) -> bool {
        let id = self.active_buffer_id();
        self.registry
            .borrow()
            .get(id)
            .is_ok_and(Buffer::is_modified)
    }

    /// 0-based line index containing the active window's cursor.
    #[must_use]
    pub fn cursor_line(&self) -> usize {
        let aw = self.active_window();
        aw.text_view.line_at_offset(aw.cursor)
    }

    /// Move the active window's cursor to the start of a 0-based line.
    /// Out-of-range line numbers clamp to the last line.
    pub fn move_to_line(&mut self, line: usize) {
        let line_count = self.active_window().text_view.line_count().max(1);
        let target_line = line.min(line_count - 1);
        let target = self
            .active_window()
            .text_view
            .line_offset(target_line)
            .unwrap_or_else(|| self.active_buffer_len());
        let aw = self.active_window_mut();
        aw.cursor = target;
        aw.goal_col = None;
    }

    // ---- jump ring (T M4.5 L1) ---------------------------------------------

    /// Bound on [`Self::jump_ring`]. Large enough for a deep
    /// cross-file dig (definition → definition → references …),
    /// small enough that a stuck loop can't grow memory unbounded.
    pub const JUMP_RING_CAP: usize = 64;

    /// Record the active window's current `(buffer, cursor)` as a
    /// jump origin. Call this *before* moving the cursor on a
    /// navigation action (go-to-definition, references, symbol jump)
    /// so `M-,` can return here.
    ///
    /// When the ring is at [`Self::JUMP_RING_CAP`], the oldest
    /// origin is evicted (front drop) — the user keeps the most
    /// recent trail, which is the one they're likely to unwind.
    pub fn push_jump(&mut self) {
        let entry = (self.active_buffer_id(), self.cursor());
        if self.jump_ring.len() >= Self::JUMP_RING_CAP {
            self.jump_ring.remove(0);
        }
        self.jump_ring.push(entry);
    }

    /// Pop the most recent jump origin and move there. Returns
    /// `true` if a jump was performed.
    ///
    /// Stale entries — a recorded buffer that has since been removed
    /// from the registry — are skipped (the loop keeps popping until
    /// it finds a live target or the ring empties), so a jump-back
    /// never lands on a missing buffer. The restored cursor is
    /// clamped to the (possibly now shorter) buffer length.
    pub fn jump_back(&mut self) -> bool {
        while let Some((bid, pos)) = self.jump_ring.pop() {
            if !self.registry.borrow().contains(bid) {
                continue;
            }
            if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() {
                continue;
            }
            let clamped = pos.min(self.active_buffer_len());
            let aw = self.active_window_mut();
            aw.cursor = clamped;
            aw.goal_col = None;
            return true;
        }
        false
    }

    // ---- incremental search (Q#SR5) ----------------------------------------
    //
    // Frontend-agnostic isearch driven entirely through these methods.
    // The TUI's `dispatch_search_key` and (later) the GPU's round-tripped
    // keystrokes both call into here, so search behaves identically in
    // both frontends. Matches live in `search_store` (shared with the
    // decorations producer and the TUI overlay); `search` holds the
    // live query + origin.

    /// `true` iff an incremental search is in progress.
    #[must_use]
    pub fn search_active(&self) -> bool {
        self.search.is_some()
    }

    /// The current isearch query (empty when no search is running).
    #[must_use]
    pub fn search_query(&self) -> &str {
        self.search.as_ref().map_or("", |s| s.query.as_str())
    }

    /// Direction of the active search (`true` = forward). Defaults to
    /// forward when no search is running — callers should gate on
    /// [`Self::search_active`] first.
    #[must_use]
    pub fn search_forward(&self) -> bool {
        self.search.as_ref().is_none_or(|s| s.forward)
    }

    /// `true` iff the active search is in regex mode (Q#RX3). `false`
    /// for literal substring, or when no search is running.
    #[must_use]
    pub fn search_is_regex(&self) -> bool {
        self.search.as_ref().is_some_and(|s| s.regex)
    }

    /// `true` iff the active regex search's pattern failed to compile —
    /// the prompt shows `[invalid]` rather than a match count. Always
    /// `false` in literal mode / when no search is running.
    #[must_use]
    pub fn search_is_invalid(&self) -> bool {
        self.search.as_ref().is_some_and(|s| s.invalid)
    }

    /// `(active_index, total)` for the active buffer's matches, for the
    /// prompt's "n/m" readout. `active_index` is 0-based and `None`
    /// when there are no matches.
    #[must_use]
    pub fn search_match_summary(&self) -> (Option<usize>, usize) {
        let bid = self.active_buffer_id();
        let guard = self
            .search_store
            .lock()
            .expect("search store mutex poisoned");
        guard
            .for_buffer(bid)
            .map_or((None, 0), |s| (s.active_index(), s.len()))
    }

    /// Begin an incremental search anchored at the active buffer +
    /// cursor. `forward` sets the initial step direction; `regex`
    /// selects regex (`true`) vs literal substring (`false`) matching.
    /// A no-op if a search is already running (the entry chord is
    /// intercepted while active, so this is only reached from an
    /// inactive state — the guard is belt-and-suspenders).
    pub fn search_begin(&mut self, forward: bool, regex: bool) {
        if self.search.is_some() {
            return;
        }
        let origin = (self.active_buffer_id(), self.cursor());
        // Attach the TUI match-wash overlay to the active window (once)
        // so matches highlight live as the query grows. It
        // self-suppresses when the store has no matches / is stale, so
        // leaving it attached across searches is safe. The GPU gets the
        // same matches via SearchMatch decorations and never reads this.
        self.ensure_search_overlay();
        self.search = Some(SearchSession {
            query: String::new(),
            origin,
            forward,
            regex,
            invalid: false,
        });
    }

    /// Toggle the active search between literal and regex matching
    /// (Q#RX3, `M-r`), re-running the current query in the new mode. A
    /// no-op when no search is running.
    pub fn search_toggle_regex(&mut self) {
        let Some(session) = self.search.as_mut() else {
            return;
        };
        session.regex = !session.regex;
        self.search_recompute();
    }

    /// Ensure the active window carries a [`crate::search::SearchView`]
    /// overlay, attaching one if absent (deduped by overlay kind). The
    /// view reads the per-buffer [`Self::search_store`] keyed on the
    /// rendered buffer, so one instance suffices per window.
    fn ensure_search_overlay(&mut self) {
        let store = self.search_store.clone();
        let win = self.active_window_mut();
        if !win.overlay_kinds().contains(&"search") {
            win.push_overlay(Box::new(crate::search::SearchView::new(store)));
        }
    }

    /// Append a character to the query and re-search.
    pub fn search_input_char(&mut self, ch: char) {
        let Some(session) = self.search.as_mut() else {
            return;
        };
        session.query.push(ch);
        self.search_recompute();
    }

    /// Drop the last character of the query and re-search. With an
    /// empty query this is a no-op (the search stays open, empty).
    pub fn search_backspace(&mut self) {
        let Some(session) = self.search.as_mut() else {
            return;
        };
        session.query.pop();
        self.search_recompute();
    }

    /// Re-run `find_all` for the current query against the origin
    /// buffer, refresh the store, and move the cursor to the match
    /// nearest the origin (first match at/after the origin cursor,
    /// wrapping). An empty query or no match anchors the cursor back
    /// at the origin so a failing search never drifts the view.
    fn search_recompute(&mut self) {
        let Some(session) = self.search.as_ref() else {
            return;
        };
        let bid = session.origin.0;
        let origin_byte = session.origin.1;
        let query = session.query.clone();
        let regex = session.regex;
        let bytes = self.buffer_bytes(bid);
        // Regex: `None` ⇒ the pattern won't compile (mark invalid, drop
        // matches). Literal substring never fails. An invalid pattern
        // clears the store (no stale matches paint) and shows
        // `[invalid]` via the prompt.
        let (matches, invalid) = if regex {
            match crate::search::find_all_regex(&bytes, &query) {
                Some(m) => (m, false),
                None => (Vec::new(), true),
            }
        } else {
            (crate::search::find_all(&bytes, &query), false)
        };
        if let Some(session) = self.search.as_mut() {
            session.invalid = invalid;
        }
        let focus = {
            let mut guard = self
                .search_store
                .lock()
                .expect("search store mutex poisoned");
            guard.set(bid, query, matches);
            guard.focus_from(bid, origin_byte)
        };
        let target = focus.map_or(origin_byte, |range| range.start);
        self.search_place_cursor(target);
    }

    /// Step the active buffer's match focus forward/backward (wrapping)
    /// and move the cursor to it. Operates on [`Self::search_store`]
    /// directly, so it works both during a live session (C-s / C-r)
    /// and after accept (a `search.next` navigation command). A no-op
    /// when the active buffer has no matches.
    pub fn search_step(&mut self, forward: bool) {
        if let Some(session) = self.search.as_mut() {
            session.forward = forward;
        }
        let bid = self.active_buffer_id();
        let stepped = {
            let mut guard = self
                .search_store
                .lock()
                .expect("search store mutex poisoned");
            guard.step(bid, forward)
        };
        if let Some(range) = stepped {
            self.search_place_cursor(range.start);
        }
    }

    /// End the active search. `accept` keeps the cursor at the current
    /// match and leaves the matches in the store (so they stay
    /// highlighted, and `search.next` can resume, until the next edit
    /// marks them stale). Cancel restores the origin cursor and clears
    /// the matches. A no-op when no search is running.
    pub fn search_finish(&mut self, accept: bool) {
        let Some(session) = self.search.take() else {
            return;
        };
        if accept {
            return;
        }
        let (bid, origin_byte) = session.origin;
        if self.active_buffer_id() == bid {
            self.search_place_cursor(origin_byte);
        }
        self.search_store
            .lock()
            .expect("search store mutex poisoned")
            .clear(bid);
    }

    /// Move the active window's cursor to a byte offset (clamped to the
    /// buffer extent), resetting the goal column. Shared by the search
    /// motions.
    fn search_place_cursor(&mut self, byte: u64) {
        let clamped = byte.min(self.active_buffer_len());
        let aw = self.active_window_mut();
        aw.cursor = clamped;
        aw.goal_col = None;
    }

    /// Snapshot a buffer's full byte content (empty if the id is
    /// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
    fn buffer_bytes(&self, buffer_id: BufferId) -> Vec<u8> {
        let reg = self.registry.borrow();
        let Ok(buf) = reg.get(buffer_id) else {
            return Vec::new();
        };
        let len = buf.len();
        let mut out = vec![0u8; len as usize];
        buf.snapshot_rope().slice(0, len, &mut out);
        out
    }

    // ---- editing primitives ------------------------------------------------

    /// Apply `op` to the active buffer; notify every window
    /// displaying that buffer. Returns the new buffer length.
    ///
    /// # Errors
    ///
    /// Returns a stringified error on buffer or view failure.
    pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<u64, String> {
        let buffer_id = self.active_buffer_id();
        let mut reg = self.registry.borrow_mut();
        let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
        let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
        for win in self.windows.values_mut() {
            if win.buffer_id == buffer_id {
                let _ = win.text_view.on_edit(buffer, &edit);
                for overlay in &mut win.overlays {
                    let _ = overlay.on_edit(buffer, &edit);
                }
            }
        }
        // T M10.8 Day 4 — capture CRDT op (if the buffer was in
        // CRDT mode and produced one) for the dispatcher to
        // broadcast on the next tick.
        //
        // M10.10 post-audit-round-3 F16: this is the **daemon-side**
        // mutation path (e.g. `FrontendEvent::Key` round-trip,
        // Lua-driven edit, fallback). The source frontend's mirror
        // has NOT applied this op locally; the queued origin is
        // [`CrdtOpOrigin::DaemonKey`] so the broadcast sweep includes
        // every replica (no sender exclusion).
        if let Some(crdt_op) = edit.crdt_op.as_ref() {
            self.pending_crdt_ops
                .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
        }
        // Search matches were computed against the pre-edit text, so
        // their byte positions are now wrong. Mark the buffer's matches
        // stale (M11.8): the producer / TUI overlay suppress them until
        // a fresh search re-runs against the current content. No-op for
        // a buffer with no search state. The headline isearch bet —
        // "stale-after-edit linger" — is closed here.
        self.search_store
            .lock()
            .expect("search store mutex poisoned")
            .mark_stale(buffer_id);
        Ok(edit.new_rope.len())
    }

    /// Notify every window displaying `buffer_id` that the buffer was
    /// just edited externally — used by code paths that mutate a buffer
    /// without going through [`Self::apply_active_edit`] (the most
    /// notable one being [`crate::lua::LuaHost::append_to_errors_buffer`],
    /// which writes to `*errors*` from inside Lua callbacks).
    ///
    /// Without this notification, any window currently displaying the
    /// edited buffer would keep a stale [`crate::text_view::TextView`]
    /// line cache, causing later cursor motions to land at offsets the
    /// view cannot map back to display coordinates.
    pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
        let reg = self.registry.borrow();
        let Ok(buffer) = reg.get(buffer_id) else {
            return;
        };
        for win in self.windows.values_mut() {
            if win.buffer_id == buffer_id {
                let _ = win.text_view.on_edit(buffer, edit);
                for overlay in &mut win.overlays {
                    let _ = overlay.on_edit(buffer, edit);
                }
            }
        }
    }

    /// Force every window currently showing `buffer_id` to rebuild
    /// its [`TextView`] from scratch.
    ///
    /// Used by code paths that rewrite a buffer end-to-end without
    /// emitting a useful [`Edit`] (the help renderer issues a
    /// delete-all + insert pair on `*help*`; `*buffer-list*` is
    /// regenerated from scratch on every C-x C-b). Calling
    /// [`Self::notify_buffer_edit`] for each step works but is more
    /// fiddly; rebuild is simpler and still O(buffer length) which is
    /// what an end-to-end rewrite cost anyway.
    ///
    /// Cursor and `view_top` are clamped to the new buffer extent so
    /// they don't dangle past the end after a shrinking rewrite.
    pub fn rebuild_views_for(&mut self, buffer_id: BufferId) {
        let reg = self.registry.borrow();
        let Ok(buffer) = reg.get(buffer_id) else {
            return;
        };
        let len = buffer.len();
        for win in self.windows.values_mut() {
            if win.buffer_id == buffer_id {
                win.text_view = TextView::new(buffer);
                if win.cursor > len {
                    win.cursor = len;
                }
                let max_top = win.text_view.line_count().saturating_sub(1);
                if win.view_top > max_top {
                    win.view_top = max_top;
                }
            }
        }
    }

    /// Save the active buffer to its backing file. Returns `true` on
    /// successful write; `false` if no path is associated, the buffer
    /// could not be read, or the atomic save failed. Callers (the
    /// `buffer.save` Lua command) use the return value to gate
    /// `buffer.after-save` firing.
    pub fn save(&mut self) -> bool {
        let id = self.active_buffer_id();
        let Some(path) = self.active_buffer_path() else {
            self.status = "no file (M1: open a file from argv)".into();
            return false;
        };
        let len_and_bytes = {
            let reg = self.registry.borrow();
            let buffer = match reg.get(id) {
                Ok(b) => b,
                Err(e) => {
                    self.status = format!("save failed: {e}");
                    return false;
                }
            };
            let len = buffer.len();
            let mut content = vec![0u8; len as usize];
            if len > 0 {
                buffer.snapshot_rope().slice(0, len, &mut content);
            }
            (len, content)
        };
        let (_, content) = len_and_bytes;
        match save_atomic(&path, &content) {
            Ok(meta) => {
                if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
                    buf.set_file_meta(Some(meta));
                    buf.mark_clean();
                }
                self.status = format!("saved {}", path.display());
                true
            }
            Err(e) => {
                self.status = format!("save failed: {e}");
                false
            }
        }
    }

    /// Move the cursor by one codepoint to the left. No-op at start.
    pub fn move_left(&mut self) {
        let cursor = self.active_window().cursor;
        if cursor == 0 {
            self.active_window_mut().goal_col = None;
            return;
        }
        let new = {
            let id = self.active_buffer_id();
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            prev_codepoint(buffer, cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor by one codepoint to the right. No-op at end.
    pub fn move_right(&mut self) {
        let cursor = self.active_window().cursor;
        let new = {
            let id = self.active_buffer_id();
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            if cursor >= buffer.len() {
                cursor
            } else {
                next_codepoint(buffer, cursor)
            }
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor up one line, preserving display column.
    pub fn move_up(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let goal_col = self.active_window().goal_col;
        let result = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let coord = self
                .active_window()
                .text_view
                .pos_to_display(buffer, cursor)
                .unwrap_or_default();
            if coord.row == 0 {
                return;
            }
            let goal = goal_col.unwrap_or(coord.col);
            let target = DisplayCoord::new(coord.row - 1, goal);
            let new_pos = self
                .active_window()
                .text_view
                .display_to_pos(buffer, target);
            (goal, new_pos)
        };
        let (goal, new_pos) = result;
        let aw = self.active_window_mut();
        aw.goal_col = Some(goal);
        if let Some(p) = new_pos {
            aw.cursor = p;
        }
    }

    /// Move the cursor down one line, preserving display column.
    pub fn move_down(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let goal_col = self.active_window().goal_col;
        let result = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let coord = self
                .active_window()
                .text_view
                .pos_to_display(buffer, cursor)
                .unwrap_or_default();
            let next_row = coord.row + 1;
            if (next_row as usize) >= self.active_window().text_view.line_count() {
                return;
            }
            let goal = goal_col.unwrap_or(coord.col);
            let target = DisplayCoord::new(next_row, goal);
            let new_pos = self
                .active_window()
                .text_view
                .display_to_pos(buffer, target);
            (goal, new_pos)
        };
        let (goal, new_pos) = result;
        let aw = self.active_window_mut();
        aw.goal_col = Some(goal);
        if let Some(p) = new_pos {
            aw.cursor = p;
        }
    }

    /// Move to the start of the current line.
    pub fn move_line_start(&mut self) {
        let cursor = self.active_window().cursor;
        let new = {
            let aw = self.active_window();
            let line = aw.text_view.line_at_offset(cursor);
            aw.text_view.line_offset(line).unwrap_or(cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor forward by one word.
    ///
    /// Skips runs of non-word characters, then a run of word
    /// characters. Word characters are alphanumerics plus `_`, the
    /// Emacs default. Multi-byte characters are handled correctly:
    /// `is_word` runs after a full UTF-8 codepoint is decoded.
    pub fn move_word_right(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            forward_word(buffer, cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor backward by one word. Mirror of
    /// [`Self::move_word_right`].
    pub fn move_word_left(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            backward_word(buffer, cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Select the word at the active cursor. Returns `false` when the
    /// cursor is not on a word character.
    pub fn select_word_at_cursor(&mut self) -> bool {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let range = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else {
                return false;
            };
            word_range_at(buffer, cursor)
        };
        let Some((start, end)) = range else {
            return false;
        };
        let aw = self.active_window_mut();
        aw.selection = Some(crate::window::Selection { anchor: start });
        aw.cursor = end;
        aw.goal_col = None;
        true
    }

    /// Select the whole line at the active cursor, trailing newline
    /// included — the convention that makes consecutive triple-click
    /// lines abut (Q#M4). The cursor lands at the selection end (the
    /// start of the next line). No-op when the buffer is gone.
    pub fn select_line_at_cursor(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let (start, end) = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else {
                return;
            };
            let view = &self.active_window().text_view;
            let line = view.line_at_offset(cursor);
            let start = view.line_offset(line).unwrap_or(0);
            let end = view.line_offset(line + 1).unwrap_or_else(|| buffer.len());
            (start, end)
        };
        let aw = self.active_window_mut();
        aw.selection = Some(crate::window::Selection { anchor: start });
        aw.cursor = end;
        aw.goal_col = None;
    }

    /// Move the cursor forward to the next paragraph break.
    ///
    /// A paragraph break is a blank line (empty or whitespace-only).
    /// If the cursor is currently in a paragraph, the cursor lands at
    /// the start of the first blank line after it. If the cursor is
    /// already on a blank line, blanks are skipped first, then the
    /// next blank line is found. Lands at the end of the buffer when
    /// there are no further paragraph breaks. Mirrors GNU Emacs's
    /// (and Doom's) `forward-paragraph` semantics.
    pub fn move_paragraph_down(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let aw = self.active_window();
            forward_paragraph(buffer, &aw.text_view, cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor backward to the previous paragraph break.
    /// Mirror of [`Self::move_paragraph_down`].
    pub fn move_paragraph_up(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let aw = self.active_window();
            backward_paragraph(buffer, &aw.text_view, cursor)
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Move the cursor down by approximately one screenful, scrolling
    /// `view_top` to match. The step is the active window's last
    /// rendered viewport height minus one (so the user keeps a line
    /// of context); falls back to a sane default before the first
    /// frame has rendered.
    pub fn move_page_down(&mut self) {
        let step = self.page_step();
        let cursor = self.active_window().cursor;
        let view_top = self.active_window().view_top;
        let id = self.active_buffer_id();
        let result = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let aw = self.active_window();
            let coord = aw
                .text_view
                .pos_to_display(buffer, cursor)
                .unwrap_or_default();
            let max_line = aw.text_view.line_count().saturating_sub(1) as u32;
            let goal_col = aw.goal_col.unwrap_or(coord.col);
            let target_row = (coord.row + step).min(max_line);
            let target = DisplayCoord::new(target_row, goal_col);
            let new_pos = aw.text_view.display_to_pos(buffer, target);
            (goal_col, new_pos, view_top.saturating_add(step as usize))
        };
        let (goal, new_pos, new_top) = result;
        let aw = self.active_window_mut();
        aw.goal_col = Some(goal);
        if let Some(p) = new_pos {
            aw.cursor = p;
        }
        // Also nudge view_top; render's scroll-into-view will clamp
        // and align further if needed.
        let max_top = aw.text_view.line_count().saturating_sub(1);
        aw.view_top = new_top.min(max_top);
    }

    /// Move the cursor up by approximately one screenful. Mirror of
    /// [`Self::move_page_down`].
    pub fn move_page_up(&mut self) {
        let step = self.page_step();
        let cursor = self.active_window().cursor;
        let view_top = self.active_window().view_top;
        let id = self.active_buffer_id();
        let result = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let aw = self.active_window();
            let coord = aw
                .text_view
                .pos_to_display(buffer, cursor)
                .unwrap_or_default();
            let goal_col = aw.goal_col.unwrap_or(coord.col);
            let target_row = coord.row.saturating_sub(step);
            let target = DisplayCoord::new(target_row, goal_col);
            let new_pos = aw.text_view.display_to_pos(buffer, target);
            (goal_col, new_pos, view_top.saturating_sub(step as usize))
        };
        let (goal, new_pos, new_top) = result;
        let aw = self.active_window_mut();
        aw.goal_col = Some(goal);
        if let Some(p) = new_pos {
            aw.cursor = p;
        }
        aw.view_top = new_top;
    }

    /// Number of lines a "page" advances. Uses the active window's
    /// last rendered viewport height minus one (one line of context
    /// at the seam, like Emacs's `next-screen-context-lines`),
    /// clamped to a sensible default for headless tests where no
    /// frame has rendered.
    fn page_step(&self) -> u32 {
        const DEFAULT_PAGE: u32 = 20;
        let rows = self.active_window().last_visible_rows;
        if rows >= 2 { rows - 1 } else { DEFAULT_PAGE }
    }

    /// Move to the end of the current line (before any trailing newline).
    pub fn move_line_end(&mut self) {
        let id = self.active_buffer_id();
        let cursor = self.active_window().cursor;
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            let aw = self.active_window();
            let line = aw.text_view.line_at_offset(cursor);
            let Some(start) = aw.text_view.line_offset(line) else {
                return;
            };
            let len = aw.text_view.line_len(buffer, line).unwrap_or(0);
            start + len
        };
        let aw = self.active_window_mut();
        aw.cursor = new;
        aw.goal_col = None;
    }

    /// Insert a single character at the cursor.
    pub fn insert_char(&mut self, ch: char) {
        self.active_window_mut().goal_col = None;
        let mut buf = [0u8; 4];
        let s = ch.encode_utf8(&mut buf);
        let bytes = s.as_bytes();
        let pos = self.active_window().cursor;
        if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
            self.status = format!("insert failed: {e}");
            return;
        }
        self.active_window_mut().cursor += bytes.len() as u64;
    }

    /// CUA type-over: insert `ch`, replacing the active region if one
    /// exists. With a region this is a *single* `EditOp::Replace` — one
    /// undo step — rather than the former `delete_region()` +
    /// `insert_char()` pair, which recorded two. With no region it
    /// delegates to [`Self::insert_char`] (a plain insert). The cursor
    /// lands just past the inserted bytes and any selection is cleared.
    pub fn insert_char_over_region(&mut self, ch: char) {
        let Some((lo, hi)) = self.active_region() else {
            self.insert_char(ch);
            return;
        };
        self.active_window_mut().goal_col = None;
        let mut buf = [0u8; 4];
        let bytes = ch.encode_utf8(&mut buf).as_bytes();
        if let Err(e) = self.apply_active_edit(EditOp::Replace {
            range: Range { start: lo, end: hi },
            bytes,
        }) {
            self.status = format!("replace failed: {e}");
            return;
        }
        let aw = self.active_window_mut();
        aw.cursor = lo + bytes.len() as u64;
        aw.selection = None;
    }

    /// Delete the codepoint immediately before the cursor.
    pub fn backspace(&mut self) {
        self.active_window_mut().goal_col = None;
        let cursor = self.active_window().cursor;
        if cursor == 0 {
            return;
        }
        let prev = {
            let id = self.active_buffer_id();
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            prev_codepoint(buffer, cursor)
        };
        let range = Range::new(prev, cursor);
        if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
            self.status = format!("delete failed: {e}");
            return;
        }
        self.active_window_mut().cursor = prev;
    }

    /// Delete the codepoint at the cursor (forward delete).
    pub fn delete_forward(&mut self) {
        self.active_window_mut().goal_col = None;
        let cursor = self.active_window().cursor;
        let id = self.active_buffer_id();
        let next = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            if cursor >= buffer.len() {
                return;
            }
            next_codepoint(buffer, cursor)
        };
        let range = Range::new(cursor, next);
        if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
            self.status = format!("delete failed: {e}");
        }
    }

    /// Delete from the cursor backward to the start of the previous
    /// word. The CUA-style `Ctrl+Backspace`. No-op at start-of-buffer.
    /// Mirrors [`Self::backspace`] but the deleted range is the gap
    /// between the cursor and where [`Self::move_word_left`] would
    /// land.
    pub fn delete_word_backward(&mut self) {
        self.active_window_mut().goal_col = None;
        let cursor = self.active_window().cursor;
        if cursor == 0 {
            return;
        }
        let new = {
            let id = self.active_buffer_id();
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            backward_word(buffer, cursor)
        };
        if new == cursor {
            return;
        }
        let range = Range::new(new, cursor);
        if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
            self.status = format!("delete failed: {e}");
            return;
        }
        self.active_window_mut().cursor = new;
    }

    /// Delete from the cursor forward to the end of the next word. The
    /// CUA-style `Ctrl+Delete`. No-op at end-of-buffer. Mirrors
    /// [`Self::delete_forward`] over the gap from the cursor to where
    /// [`Self::move_word_right`] would land.
    pub fn delete_word_forward(&mut self) {
        self.active_window_mut().goal_col = None;
        let cursor = self.active_window().cursor;
        let id = self.active_buffer_id();
        let new = {
            let reg = self.registry.borrow();
            let Ok(buffer) = reg.get(id) else { return };
            if cursor >= buffer.len() {
                return;
            }
            forward_word(buffer, cursor)
        };
        if new == cursor {
            return;
        }
        let range = Range::new(cursor, new);
        if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
            self.status = format!("delete failed: {e}");
        }
    }

    /// Undo the most recent edit on the active buffer; clamp the
    /// active window's cursor to the new length and notify all
    /// windows on this buffer.
    pub fn undo(&mut self) {
        self.active_window_mut().goal_col = None;
        let buffer_id = self.active_buffer_id();
        let edit = {
            let mut reg = self.registry.borrow_mut();
            let Ok(buffer) = reg.get_mut(buffer_id) else {
                return;
            };
            buffer.undo()
        };
        match edit {
            Ok(edit) => {
                let reg = self.registry.borrow();
                let Ok(buffer) = reg.get(buffer_id) else {
                    return;
                };
                for win in self.windows.values_mut() {
                    if win.buffer_id == buffer_id {
                        let _ = win.text_view.on_edit(buffer, &edit);
                        let max = buffer.len();
                        if win.cursor > max {
                            win.cursor = max;
                        }
                    }
                }
                drop(reg);
                // Post-audit-round-5 F27: undo on a CRDT-backed
                // buffer produces a crdt_op that must broadcast to
                // every replica frontend (including the one whose
                // command triggered the undo — its BufferMirror has
                // no other way to converge with the post-undo state).
                self.queue_daemon_origin_crdt_op(buffer_id, &edit);
            }
            Err(_) => self.status = "nothing to undo".into(),
        }
    }

    /// Redo the most recently undone edit on the active buffer.
    pub fn redo(&mut self) {
        self.active_window_mut().goal_col = None;
        let buffer_id = self.active_buffer_id();
        let edit = {
            let mut reg = self.registry.borrow_mut();
            let Ok(buffer) = reg.get_mut(buffer_id) else {
                return;
            };
            buffer.redo()
        };
        match edit {
            Ok(edit) => {
                let reg = self.registry.borrow();
                let Ok(buffer) = reg.get(buffer_id) else {
                    return;
                };
                for win in self.windows.values_mut() {
                    if win.buffer_id == buffer_id {
                        let _ = win.text_view.on_edit(buffer, &edit);
                        let max = buffer.len();
                        if win.cursor > max {
                            win.cursor = max;
                        }
                    }
                }
                drop(reg);
                // Post-audit-round-5 F27 — same as undo above.
                self.queue_daemon_origin_crdt_op(buffer_id, &edit);
            }
            Err(_) => self.status = "nothing to redo".into(),
        }
    }

    /// T M10.10 post-audit-round-5 F27 + F28 — queue a CRDT op
    /// produced by a daemon-origin edit (undo/redo via core, Lua
    /// bindings, command pipeline) for broadcast.
    ///
    /// Pushes into `pending_crdt_ops` with
    /// [`CrdtOpOrigin::DaemonKey`] semantics: the broadcast sweep
    /// includes every replica frontend (no sender exclusion). The
    /// originating frontend's `BufferMirror` has not applied the op
    /// locally — only the daemon's authoritative buffer has — so
    /// the source's mirror needs the broadcast just like every
    /// other replica.
    ///
    /// No-op when the edit doesn't carry a `crdt_op` (the buffer
    /// wasn't CRDT-backed at the time of the edit). Callers can
    /// invoke this unconditionally after any daemon-origin
    /// `apply_*` that returns an `Edit`; non-CRDT buffers pay no
    /// cost beyond the early return.
    pub fn queue_daemon_origin_crdt_op(&mut self, buffer_id: BufferId, edit: &Edit) {
        if let Some(crdt_op) = edit.crdt_op.as_ref() {
            self.pending_crdt_ops
                .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
        }
    }

    // ---- window operations -------------------------------------------------

    /// Split the active window. Returns the new window's id.
    /// `same_buffer` controls whether the new window opens on the
    /// active buffer (Emacs default) or a fresh `*scratch*` buffer.
    pub fn split_active(&mut self, orientation: Orientation, same_buffer: bool) -> WindowId {
        let active_buf = self.active_buffer_id();
        let (buffer_id, text_view) = if same_buffer {
            let reg = self.registry.borrow();
            let buf = reg.get(active_buf).expect("active buffer present");
            (active_buf, TextView::new(buf))
        } else {
            let mut reg = self.registry.borrow_mut();
            let new_id = reg.create("*scratch*");
            let buf = reg.get(new_id).unwrap();
            (new_id, TextView::new(buf))
        };
        let new_id = WindowId::next();
        let new_window = Window::new(new_id, buffer_id, text_view);
        self.windows.insert(new_id, new_window);
        let active = self.active_window_id();
        self.active_layout_mut()
            .split_window(active, orientation, new_id);
        new_id
    }

    /// Move focus to the next window in iteration order.
    pub fn focus_next(&mut self) {
        let active = self.active_window_id();
        let next = self.active_layout().focus_next(active);
        self.set_active_window_id(next);
    }

    /// Move focus to the previous window in iteration order.
    pub fn focus_prev(&mut self) {
        let active = self.active_window_id();
        let prev = self.active_layout().focus_prev(active);
        self.set_active_window_id(prev);
    }

    /// Close the active window (unless it's the only one). Returns
    /// false if there's only one window left.
    pub fn close_active(&mut self) -> bool {
        if self.windows.len() <= 1 {
            return false;
        }
        let target = self.active_window_id();
        self.active_layout_mut().close_window(target);
        self.windows.remove(&target);
        // Pick an adjacent window as the new focus.
        let next = *self
            .active_layout()
            .iter_ids()
            .first()
            .expect("at least one window remains");
        self.set_active_window_id(next);
        true
    }

    /// Close every window except the active one.
    pub fn close_others(&mut self) {
        let keep = self.active_window_id();
        self.active_layout_mut().keep_only(keep);
        self.windows.retain(|id, _| *id == keep);
    }

    // ---- selection / region (T M2.12) --------------------------------------

    /// Active region of the active window, as `(lo, hi)` byte
    /// positions, or `None` if no region is set or it is empty.
    #[must_use]
    pub fn active_region(&self) -> Option<(Position, Position)> {
        self.active_window().region()
    }

    /// Begin a selection at `anchor` on the active window.
    pub fn begin_selection(&mut self, anchor: Position) {
        self.active_window_mut().selection = Some(crate::window::Selection { anchor });
    }

    /// Drop any active selection on the active window.
    pub fn clear_selection(&mut self) {
        self.active_window_mut().selection = None;
    }

    /// Delete the active region (if any) from the active buffer and
    /// move the cursor to the deletion's start. No-op if there is no
    /// region. Returns the new buffer length.
    ///
    /// # Errors
    ///
    /// Returns the same stringified error shape as
    /// [`Self::apply_active_edit`] if the underlying delete fails.
    pub fn delete_region(&mut self) -> Result<u64, String> {
        let Some((lo, hi)) = self.active_region() else {
            return Ok(self.active_buffer_len());
        };
        let new_len = self.apply_active_edit(EditOp::Delete {
            range: Range { start: lo, end: hi },
        })?;
        let aw = self.active_window_mut();
        aw.cursor = lo;
        aw.selection = None;
        aw.goal_col = None;
        Ok(new_len)
    }

    // ---- clipboard (Q#CM6) -------------------------------------------------

    /// The identifier under (or immediately left of) the cursor, or
    /// `None` when the cursor isn't on a word (Q#CM3, the `symbol`
    /// context). A word is a run of ASCII alphanumerics / `_`; since
    /// those are all single-byte, the slice always lands on UTF-8
    /// boundaries.
    #[must_use]
    pub fn word_at_cursor(&self) -> Option<String> {
        let bytes = self.buffer_bytes(self.active_buffer_id());
        let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
        let cursor = (self.cursor() as usize).min(bytes.len());
        let mut start = cursor;
        while start > 0 && is_word(bytes[start - 1]) {
            start -= 1;
        }
        let mut end = cursor;
        while end < bytes.len() && is_word(bytes[end]) {
            end += 1;
        }
        if start == end {
            return None;
        }
        String::from_utf8(bytes[start..end].to_vec()).ok()
    }

    /// Bytes of the active region, or `None` when nothing is selected.
    #[must_use]
    pub fn region_bytes(&self) -> Option<Vec<u8>> {
        let (lo, hi) = self.active_region()?;
        let reg = self.registry.borrow();
        let buf = reg.get(self.active_buffer_id()).ok()?;
        let mut out = vec![0u8; (hi - lo) as usize];
        buf.snapshot_rope().slice(lo, hi, &mut out);
        Some(out)
    }

    /// Copy the active region into the clipboard slot and queue an
    /// outbound OS-clipboard publish to the originating frontend.
    /// Returns `false` (a no-op) when there is no region.
    pub fn clipboard_copy(&mut self) -> bool {
        let Some(bytes) = self.region_bytes() else {
            return false;
        };
        self.clipboard_slot.clone_from(&bytes);
        self.pending_clipboard = Some((self.active_frontend, bytes));
        true
    }

    /// Cut: copy the region, then delete it. Returns `false` (a no-op)
    /// when there is no region.
    ///
    /// # Errors
    ///
    /// Propagates [`Self::delete_region`]'s error.
    pub fn clipboard_cut(&mut self) -> Result<bool, String> {
        if !self.clipboard_copy() {
            return Ok(false);
        }
        self.delete_region()?;
        Ok(true)
    }

    /// Paste the clipboard slot at the cursor, replacing the active
    /// region if one exists (one undo step, like CUA type-over).
    /// Returns `false` when the slot is empty.
    ///
    /// # Errors
    ///
    /// Propagates the underlying edit error.
    pub fn clipboard_paste(&mut self) -> Result<bool, String> {
        if self.clipboard_slot.is_empty() {
            return Ok(false);
        }
        let bytes = self.clipboard_slot.clone();
        self.insert_bytes_over_region(&bytes)?;
        Ok(true)
    }

    /// Insert externally-pasted bytes at the cursor (inbound OS paste:
    /// terminal bracketed paste, or GPU Ctrl-V via `arboard`),
    /// refreshing the slot so a later in-app paste repeats them.
    /// Replaces the active region if one exists.
    ///
    /// # Errors
    ///
    /// Propagates the underlying edit error.
    pub fn paste_inbound(&mut self, data: &[u8]) -> Result<(), String> {
        self.clipboard_slot = data.to_vec();
        self.insert_bytes_over_region(data)
    }

    /// Shared insert/replace for paste: `Replace` over the active
    /// region, else `Insert` at the cursor. The cursor lands just past
    /// the inserted bytes and any selection is cleared. No-op insert for
    /// empty `bytes`.
    fn insert_bytes_over_region(&mut self, bytes: &[u8]) -> Result<(), String> {
        self.active_window_mut().goal_col = None;
        let start = if let Some((lo, hi)) = self.active_region() {
            self.apply_active_edit(EditOp::Replace {
                range: Range { start: lo, end: hi },
                bytes,
            })?;
            lo
        } else {
            let pos = self.active_window().cursor;
            self.apply_active_edit(EditOp::Insert { pos, bytes })?;
            pos
        };
        let aw = self.active_window_mut();
        aw.cursor = start + bytes.len() as u64;
        aw.selection = None;
        Ok(())
    }

    /// Select the whole active buffer (anchor at 0, cursor at the end).
    pub fn select_all(&mut self) {
        let len = self.active_buffer_len();
        self.begin_selection(0);
        let aw = self.active_window_mut();
        aw.cursor = len;
        aw.goal_col = None;
    }

    /// Drain the one-shot outbound clipboard publish, if any. Called by
    /// the dispatcher each tick (alongside `pending_crdt_ops`).
    pub fn take_pending_clipboard(&mut self) -> Option<(FrontendId, Vec<u8>)> {
        self.pending_clipboard.take()
    }

    /// The current clipboard slot bytes (testing / introspection).
    #[must_use]
    pub fn clipboard_slot(&self) -> &[u8] {
        &self.clipboard_slot
    }

    // ---- context menu (Q#CM1) ----------------------------------------------

    /// True while a context menu is open.
    #[must_use]
    pub fn menu_is_open(&self) -> bool {
        self.menu.lock().expect("menu mutex poisoned").is_some()
    }

    /// Open a menu of resolved `rows` anchored at the absolute `anchor`
    /// cell. A no-op (stays closed) when no row is selectable. Attaches
    /// the TUI overlay on first open (deduped by kind).
    pub fn menu_open(&mut self, rows: Vec<crate::menu::MenuRow>, anchor: (u32, u32)) {
        let state = crate::menu::MenuState::new(rows, anchor);
        let opened = state.is_some();
        *self.menu.lock().expect("menu mutex poisoned") = state;
        if opened {
            self.ensure_menu_overlay();
        }
    }

    /// Close the menu (the overlay then self-suppresses).
    pub fn menu_close(&mut self) {
        *self.menu.lock().expect("menu mutex poisoned") = None;
    }

    /// Move the highlight by `delta` items (wrapping, skipping separators).
    pub fn menu_step(&mut self, delta: isize) {
        if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut() {
            m.step(delta);
        }
    }

    /// Set the highlight to `row` if it names a selectable item (mouse
    /// hover / click).
    pub fn menu_set_active_row(&mut self, row: usize) {
        if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut()
            && matches!(m.rows.get(row), Some(crate::menu::MenuRow::Item { .. }))
        {
            m.active = row;
        }
    }

    /// The active item's command name, if a menu is open.
    #[must_use]
    pub fn menu_active_command(&self) -> Option<String> {
        self.menu
            .lock()
            .expect("menu mutex poisoned")
            .as_ref()
            .and_then(|m| m.active_command().map(str::to_owned))
    }

    /// Hit-test an absolute cell against the open popup, returning the
    /// selectable row it covers (or `None`).
    #[must_use]
    pub fn menu_hit(&self, row: u32, col: u32) -> Option<usize> {
        self.menu
            .lock()
            .expect("menu mutex poisoned")
            .as_ref()
            .and_then(|m| m.hit(row, col))
    }

    /// Ensure the active window carries a [`crate::menu::MenuView`]
    /// overlay (deduped by kind). The view reads the shared `menu`, so
    /// one instance suffices; it renders nothing while the menu is closed.
    fn ensure_menu_overlay(&mut self) {
        let menu = self.menu.clone();
        let win = self.active_window_mut();
        if !win.overlay_kinds().contains(&"context-menu") {
            win.push_overlay(Box::new(crate::menu::MenuView::new(menu)));
        }
    }

    /// Safely remove `buffer_id` from the registry. Any window that
    /// was displaying it is redirected to a fallback buffer (`*scratch*`,
    /// created on demand) so window state never refers to a missing id.
    ///
    /// # Errors
    ///
    /// Returns an error string when `buffer_id` is the only buffer in
    /// the registry (the registry must remain non-empty), or when the
    /// id doesn't resolve.
    pub fn kill_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
        {
            let reg = self.registry.borrow();
            if !reg.contains(buffer_id) {
                return Err(format!("buffer {buffer_id:?} not found"));
            }
            if reg.len() <= 1 {
                return Err("cannot kill the last remaining buffer".into());
            }
        }
        let fallback = {
            let mut reg = self.registry.borrow_mut();
            match reg.find_by_name("*scratch*") {
                Some(id) if id != buffer_id => id,
                _ => {
                    let candidate = reg.ids().iter().copied().find(|id| *id != buffer_id);
                    match candidate {
                        Some(id) => id,
                        None => reg.create("*scratch*"),
                    }
                }
            }
        };
        {
            let reg = self.registry.borrow();
            let buf = reg.get(fallback).map_err(|e| e.to_string())?;
            for win in self.windows.values_mut() {
                if win.buffer_id == buffer_id {
                    win.buffer_id = fallback;
                    win.text_view = TextView::new(buf);
                    win.overlays.clear();
                    win.cursor = 0;
                    win.selection = None;
                    win.view_top = 0;
                    win.goal_col = None;
                }
            }
        }
        self.registry
            .borrow_mut()
            .remove(buffer_id)
            .map(|_| ())
            .map_err(|e| e.to_string())
    }

    /// Switch the active window to a different buffer, allocating a
    /// fresh [`TextView`] for it.
    pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
        let text_view = {
            let reg = self.registry.borrow();
            let buf = reg.get(buffer_id).map_err(|e| e.to_string())?;
            TextView::new(buf)
        };
        let aw = self.active_window_mut();
        aw.buffer_id = buffer_id;
        aw.text_view = text_view;
        // Overlays were keyed to the previous buffer's coordinates;
        // dropping them is safer than carrying through coordinates
        // that no longer mean anything. Callers that want to preserve
        // an overlay across buffer switches re-register after.
        aw.overlays.clear();
        aw.cursor = 0;
        aw.selection = None;
        aw.view_top = 0;
        aw.goal_col = None;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Codepoint navigation
// ---------------------------------------------------------------------------

/// Return the byte position of the codepoint immediately before `pos`.
fn prev_codepoint(buf: &Buffer, pos: Position) -> Position {
    if pos == 0 {
        return 0;
    }
    let rope = buf.snapshot_rope();
    let mut p = pos - 1;
    while p > 0 {
        let b = rope.byte_at(p).unwrap_or(0);
        if (b & 0xC0) != 0x80 {
            return p;
        }
        p -= 1;
    }
    0
}

/// Return the byte position of the codepoint immediately after `pos`.
fn next_codepoint(buf: &Buffer, pos: Position) -> Position {
    let len = buf.len();
    if pos >= len {
        return len;
    }
    let rope = buf.snapshot_rope();
    let lead = rope.byte_at(pos).unwrap_or(0);
    let advance = utf8_codepoint_len(lead);
    (pos + advance as u64).min(len)
}

fn utf8_codepoint_len(lead: u8) -> usize {
    if lead < 0xC0 {
        1
    } else if lead < 0xE0 {
        2
    } else if lead < 0xF0 {
        3
    } else {
        4
    }
}

/// Decode the codepoint starting at `pos`. Returns `(char, advance)`
/// where `advance` is the number of bytes the codepoint consumed.
/// `None` if `pos` is past the buffer end or the bytes there are not
/// valid UTF-8.
fn char_at(buf: &Buffer, pos: Position) -> Option<(char, u64)> {
    let rope = buf.snapshot_rope();
    if pos >= rope.len() {
        return None;
    }
    let lead = rope.byte_at(pos)?;
    let len = utf8_codepoint_len(lead);
    let mut bytes = [0u8; 4];
    for (i, slot) in bytes.iter_mut().take(len).enumerate() {
        *slot = rope.byte_at(pos + i as u64).unwrap_or(0);
    }
    let s = std::str::from_utf8(&bytes[..len]).ok()?;
    let ch = s.chars().next()?;
    Some((ch, len as u64))
}

/// Whether `c` counts as a word character. Matches the Emacs default:
/// alphanumerics plus underscore. Punctuation and whitespace are
/// separators.
fn is_word_char(c: char) -> bool {
    c.is_alphanumeric() || c == '_'
}

/// Forward-word semantics: skip non-word characters, then skip word
/// characters, returning the resulting position.
fn forward_word(buf: &Buffer, mut pos: Position) -> Position {
    let len = buf.len();
    // Skip non-word.
    while pos < len {
        let Some((ch, advance)) = char_at(buf, pos) else {
            break;
        };
        if is_word_char(ch) {
            break;
        }
        pos += advance;
    }
    // Skip word.
    while pos < len {
        let Some((ch, advance)) = char_at(buf, pos) else {
            break;
        };
        if !is_word_char(ch) {
            break;
        }
        pos += advance;
    }
    pos
}

fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> {
    let (ch, ch_len) = char_at(buf, pos)?;
    if !is_word_char(ch) {
        return None;
    }
    // Walk back from just *past* the char under the cursor, not from
    // `pos` itself: `backward_word(pos)` at a word's FIRST character
    // sees the non-word char before it, skips it, and crosses into
    // the previous word — double-clicking the 'w' of "llo world"
    // would select "llo world". From `pos + ch_len` the char behind
    // is this word's own first char, so the walk stops at its start.
    let start = backward_word(buf, pos.saturating_add(ch_len));
    let end = forward_word(buf, pos);
    (start < end).then_some((start, end))
}

/// True iff `line` is empty or contains only ASCII whitespace.
/// Used by paragraph motion: a blank line is a paragraph break.
fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool {
    let Some(start) = view.line_offset(line) else {
        return true;
    };
    let Some(len) = view.line_len(buf, line) else {
        return true;
    };
    if len == 0 {
        return true;
    }
    let rope = buf.snapshot_rope();
    for chunk in rope.chunks(start, start + len) {
        if chunk.iter().any(|b| !b.is_ascii_whitespace()) {
            return false;
        }
    }
    true
}

/// Forward-paragraph: skip blank lines if currently on one, then
/// scan forward until the first blank line; return the position at
/// the start of that line, or the buffer end.
fn forward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
    let total = view.line_count();
    if total == 0 {
        return pos;
    }
    let cur_line = view.line_at_offset(pos);
    let starting_blank = line_is_blank(buf, view, cur_line);
    let mut line = cur_line.saturating_add(1);
    if starting_blank {
        while line < total && line_is_blank(buf, view, line) {
            line += 1;
        }
    }
    while line < total {
        if line_is_blank(buf, view, line) {
            return view.line_offset(line).unwrap_or(pos);
        }
        line += 1;
    }
    buf.len()
}

/// Backward-paragraph: mirror of [`forward_paragraph`].
fn backward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
    if pos == 0 {
        return 0;
    }
    let cur_line = view.line_at_offset(pos);
    if cur_line == 0 {
        return 0;
    }
    let starting_blank = line_is_blank(buf, view, cur_line);
    let mut line = cur_line - 1;
    if starting_blank {
        loop {
            if !line_is_blank(buf, view, line) {
                break;
            }
            if line == 0 {
                return view.line_offset(0).unwrap_or(0);
            }
            line -= 1;
        }
    }
    loop {
        if line_is_blank(buf, view, line) {
            return view.line_offset(line).unwrap_or(0);
        }
        if line == 0 {
            return 0;
        }
        line -= 1;
    }
}

/// Backward-word semantics: step back over non-word characters, then
/// step back over word characters.
fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
    // Step back over non-word characters.
    while pos > 0 {
        let prev = prev_codepoint(buf, pos);
        let Some((ch, _)) = char_at(buf, prev) else {
            break;
        };
        if is_word_char(ch) {
            break;
        }
        pos = prev;
    }
    // Step back over word characters.
    while pos > 0 {
        let prev = prev_codepoint(buf, pos);
        let Some((ch, _)) = char_at(buf, prev) else {
            break;
        };
        if !is_word_char(ch) {
            break;
        }
        pos = prev;
    }
    pos
}

/// Normalize a buffer path to an absolute, lexically-clean form:
///
/// 1. expand a leading `~` / `~/…` against `$HOME`,
/// 2. join onto the process cwd if still relative,
/// 3. fold `.` / `..` purely lexically.
///
/// No filesystem access and no symlink resolution (unlike
/// [`std::fs::canonicalize`]): the result is correct for a
/// not-yet-created "[new file]" buffer and never silently rewrites a
/// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
    let path = expand_tilde(path);
    let abs = if path.is_absolute() {
        path
    } else if let Ok(cwd) = std::env::current_dir() {
        cwd.join(path)
    } else {
        path
    };
    lexical_normalize(&abs)
}

/// Expand a leading `~` (whole component only) using `$HOME`. A bare
/// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset.
fn expand_tilde(path: PathBuf) -> PathBuf {
    let Some(s) = path.to_str() else {
        return path;
    };
    if s == "~" {
        return std::env::var_os("HOME").map_or(path, PathBuf::from);
    }
    if let Some(rest) = s.strip_prefix("~/")
        && let Some(home) = std::env::var_os("HOME")
    {
        return Path::new(&home).join(rest);
    }
    path
}

/// Fold `.` and `..` components without touching the filesystem.
/// `..` pops a preceding normal segment; against the root (or a
/// Windows prefix) it is dropped, since you cannot ascend past it.
fn lexical_normalize(path: &Path) -> PathBuf {
    use std::path::Component;
    let mut stack: Vec<Component> = Vec::new();
    for comp in path.components() {
        match comp {
            Component::CurDir => {}
            Component::ParentDir => match stack.last() {
                Some(Component::Normal(_)) => {
                    stack.pop();
                }
                Some(Component::RootDir | Component::Prefix(_)) => {}
                _ => stack.push(Component::ParentDir),
            },
            c => stack.push(c),
        }
    }
    let mut out = PathBuf::new();
    for c in stack {
        out.push(c.as_os_str());
    }
    if out.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        out
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;
    use std::rc::Rc;

    #[test]
    fn lexical_normalize_folds_dot_and_dotdot() {
        assert_eq!(
            lexical_normalize(Path::new("/a/./b/../c")),
            PathBuf::from("/a/c")
        );
        // `..` cannot ascend past the root.
        assert_eq!(
            lexical_normalize(Path::new("/../../x")),
            PathBuf::from("/x")
        );
        // Already clean ⇒ unchanged (keeps tempdir paths stable so
        // the LSP acceptance tests' exact-path asserts still hold).
        assert_eq!(
            lexical_normalize(Path::new("/tmp/quickshell/ipc.cpp")),
            PathBuf::from("/tmp/quickshell/ipc.cpp")
        );
    }

    #[test]
    fn expand_tilde_only_at_leading_component() {
        // `~user` (no passwd lookup) and a non-leading `~` are left
        // exactly as-is, independent of `$HOME`.
        assert_eq!(
            expand_tilde(PathBuf::from("~bob/x")),
            PathBuf::from("~bob/x")
        );
        assert_eq!(expand_tilde(PathBuf::from("a/~/b")), PathBuf::from("a/~/b"));
        // With `$HOME` set (the case in any normal test environment)
        // a leading `~` / `~/…` expands against its real value.
        if let Some(home) = std::env::var_os("HOME") {
            assert_eq!(expand_tilde(PathBuf::from("~")), PathBuf::from(&home));
            assert_eq!(
                expand_tilde(PathBuf::from("~/src/ipc.cpp")),
                Path::new(&home).join("src/ipc.cpp")
            );
        }
    }

    #[test]
    fn normalize_buffer_path_yields_absolute() {
        // A relative path becomes absolute (joined onto cwd) — this
        // is exactly what made clangd reject `file://ipc.cpp`.
        let p = normalize_buffer_path(PathBuf::from("ipc.cpp"));
        assert!(p.is_absolute(), "expected absolute, got {p:?}");
        assert!(p.ends_with("ipc.cpp"));
    }

    fn fresh() -> EditorCore {
        let reg: SharedRegistry =
            Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
        EditorCore::new(reg)
    }

    fn from_bytes(bytes: &[u8]) -> EditorCore {
        let reg: SharedRegistry =
            Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
        EditorCore::from_bytes(reg, "test", bytes)
    }

    #[test]
    fn insert_advances_cursor() {
        let mut s = from_bytes(b"");
        s.insert_char('h');
        s.insert_char('i');
        assert_eq!(s.cursor(), 2);
        assert_eq!(s.active_buffer_len(), 2);
    }

    #[test]
    fn backspace_undoes_insertion() {
        let mut s = from_bytes(b"abc");
        s.active_window_mut().cursor = 3;
        s.backspace();
        assert_eq!(s.cursor(), 2);
        assert_eq!(s.active_buffer_len(), 2);
    }

    #[test]
    fn copy_captures_region_and_queues_publish() {
        let mut s = from_bytes(b"hello world");
        s.begin_selection(0);
        s.active_window_mut().cursor = 5; // region [0,5) = "hello"
        assert!(s.clipboard_copy());
        assert_eq!(s.clipboard_slot(), b"hello");
        let (fid, bytes) = s.take_pending_clipboard().expect("publish queued");
        assert_eq!(fid, s.active_frontend);
        assert_eq!(bytes, b"hello");
        // Drained: second take is None.
        assert!(s.take_pending_clipboard().is_none());
        // Copy does not mutate the buffer.
        assert_eq!(s.active_buffer_len(), 11);
    }

    #[test]
    fn copy_without_region_is_a_noop() {
        let mut s = from_bytes(b"abc");
        assert!(!s.clipboard_copy());
        assert!(s.clipboard_slot().is_empty());
        assert!(s.take_pending_clipboard().is_none());
    }

    #[test]
    fn cut_copies_then_deletes_region() {
        let mut s = from_bytes(b"hello world");
        s.begin_selection(6);
        s.active_window_mut().cursor = 11; // region [6,11) = "world"
        assert!(s.clipboard_cut().unwrap());
        assert_eq!(s.clipboard_slot(), b"world");
        assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello ");
        assert_eq!(s.cursor(), 6);
    }

    #[test]
    fn paste_inserts_slot_at_cursor() {
        let mut s = from_bytes(b"ac");
        // Seed the slot via a copy.
        s.begin_selection(0);
        s.active_window_mut().cursor = 1; // "a"
        s.clipboard_copy();
        // Paste "a" between a and c.
        s.clear_selection();
        s.active_window_mut().cursor = 1;
        assert!(s.clipboard_paste().unwrap());
        assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aac");
        assert_eq!(s.cursor(), 2);
    }

    #[test]
    fn paste_replaces_active_region_as_one_step() {
        let mut s = from_bytes(b"hello world");
        // Copy "hello".
        s.begin_selection(0);
        s.active_window_mut().cursor = 5;
        s.clipboard_copy();
        // Select "world" and paste over it.
        s.begin_selection(6);
        s.active_window_mut().cursor = 11;
        assert!(s.clipboard_paste().unwrap());
        assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello hello");
        assert!(s.active_region().is_none()); // selection cleared
    }

    #[test]
    fn paste_with_empty_slot_is_a_noop() {
        let mut s = from_bytes(b"abc");
        assert!(!s.clipboard_paste().unwrap());
        assert_eq!(s.active_buffer_len(), 3);
    }

    #[test]
    fn paste_inbound_inserts_and_refreshes_slot() {
        let mut s = from_bytes(b"ab");
        s.active_window_mut().cursor = 1;
        s.paste_inbound(b"XYZ").unwrap();
        assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aXYZb");
        assert_eq!(s.cursor(), 4);
        // Slot refreshed, so an in-app paste repeats the external text.
        assert_eq!(s.clipboard_slot(), b"XYZ");
        // Inbound paste does NOT queue an outbound publish (no echo loop).
        assert!(s.take_pending_clipboard().is_none());
    }

    #[test]
    fn select_all_spans_the_buffer() {
        let mut s = from_bytes(b"hello");
        s.active_window_mut().cursor = 2;
        s.select_all();
        assert_eq!(s.active_region(), Some((0, 5)));
        assert_eq!(s.region_bytes().unwrap(), b"hello");
    }

    #[test]
    fn word_at_cursor_reads_the_identifier() {
        let mut s = from_bytes(b"foo bar_baz qux");
        s.active_window_mut().cursor = 6; // inside "bar_baz"
        assert_eq!(s.word_at_cursor().as_deref(), Some("bar_baz"));
        s.active_window_mut().cursor = 0; // start of "foo"
        assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
        s.active_window_mut().cursor = 3; // just past "foo" → scans left
        assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
    }

    #[test]
    fn word_at_cursor_is_none_in_whitespace() {
        let mut s = from_bytes(b"a   b");
        s.active_window_mut().cursor = 2; // a run of spaces, none adjacent left
        assert_eq!(s.word_at_cursor(), None);
    }

    #[test]
    fn cursor_navigation_left_right() {
        let mut s = from_bytes(b"abc");
        s.active_window_mut().cursor = 0;
        s.move_right();
        assert_eq!(s.cursor(), 1);
        s.move_right();
        s.move_right();
        s.move_right();
        assert_eq!(s.cursor(), 3);
        s.move_left();
        s.move_left();
        s.move_left();
        s.move_left();
        assert_eq!(s.cursor(), 0);
    }

    #[test]
    fn cursor_navigation_up_down_preserves_column() {
        let mut s = from_bytes(b"abcdef\nghi\njklmno");
        s.active_window_mut().cursor = 4;
        s.move_down();
        assert_eq!(s.cursor(), 10);
        s.move_down();
        assert_eq!(s.cursor(), 15);
        s.move_up();
        s.move_up();
        assert_eq!(s.cursor(), 4);
    }

    #[test]
    fn line_start_and_end() {
        let mut s = from_bytes(b"hello\nworld");
        s.active_window_mut().cursor = 8;
        s.move_line_start();
        assert_eq!(s.cursor(), 6);
        s.move_line_end();
        assert_eq!(s.cursor(), 11);
    }

    #[test]
    fn undo_clamps_cursor_to_buffer_len() {
        let mut s = from_bytes(b"");
        s.insert_char('a');
        s.insert_char('b');
        assert_eq!(s.cursor(), 2);
        s.undo();
        assert_eq!(s.cursor(), 1);
        s.undo();
        assert_eq!(s.cursor(), 0);
    }

    #[test]
    fn delete_forward_at_end_is_noop() {
        let mut s = from_bytes(b"abc");
        s.active_window_mut().cursor = 3;
        s.delete_forward();
        assert_eq!(s.active_buffer_len(), 3);
    }

    #[test]
    fn delete_word_backward_removes_previous_word_to_cursor() {
        // Cursor sits at end-of-buffer; deletes back through "world".
        let mut s = from_bytes(b"hello world");
        s.active_window_mut().cursor = 11;
        s.delete_word_backward();
        // `backward_word` lands at the start of the word ("world"
        // begins at byte 6), so we delete bytes 6..11.
        assert_eq!(s.cursor(), 6);
        assert_eq!(s.active_buffer_len(), 6);
    }

    #[test]
    fn delete_word_backward_at_start_of_buffer_is_noop() {
        let mut s = from_bytes(b"hello");
        s.active_window_mut().cursor = 0;
        s.delete_word_backward();
        assert_eq!(s.cursor(), 0);
        assert_eq!(s.active_buffer_len(), 5);
    }

    #[test]
    fn delete_word_forward_removes_next_word_from_cursor() {
        let mut s = from_bytes(b"hello world");
        s.active_window_mut().cursor = 0;
        s.delete_word_forward();
        // `forward_word` lands at the end of the first word (byte 5);
        // delete bytes 0..5. Cursor stays where it was.
        assert_eq!(s.cursor(), 0);
        assert_eq!(s.active_buffer_len(), 6);
    }

    #[test]
    fn delete_word_forward_at_end_of_buffer_is_noop() {
        let mut s = from_bytes(b"hello");
        s.active_window_mut().cursor = 5;
        s.delete_word_forward();
        assert_eq!(s.cursor(), 5);
        assert_eq!(s.active_buffer_len(), 5);
    }

    #[test]
    fn multibyte_navigation() {
        let mut s = from_bytes("héllo".as_bytes());
        s.active_window_mut().cursor = 0;
        s.move_right();
        assert_eq!(s.cursor(), 1);
        s.move_right();
        assert_eq!(s.cursor(), 3);
        s.move_right();
        assert_eq!(s.cursor(), 4);
        s.move_left();
        s.move_left();
        assert_eq!(s.cursor(), 1);
    }

    #[test]
    fn save_with_no_path_announces_via_status() {
        let mut s = fresh();
        s.save();
        assert!(s.status.contains("no file"));
    }

    /// T M10.8 — pins the Day 2 transitional fallback behavior in
    /// [`EditorCore::active_view`].
    ///
    /// **Day 2 → Day 3 transition contract**: while the dispatcher
    /// thread is being wired (Day 3 work), the daemon may set
    /// `active_frontend` to a daemon-attached `FrontendId` whose
    /// `FrontendView` hasn't been registered yet. The fallback to
    /// `FrontendId::LOCAL`'s view keeps single-frontend behavior
    /// observable.
    ///
    /// **Day 3 cleanup**: once
    /// [`EditorCore::register_frontend_view`] is invariantly called
    /// before any event dispatch, this test flips to assert "every
    /// `active_frontend` has its own registered view, no fallback
    /// ever activates." Until then, the fallback is the bridge.
    #[test]
    fn active_view_falls_back_to_local_when_active_frontend_unregistered() {
        let mut s = fresh();
        // Default active_frontend is LOCAL → no fallback yet.
        assert_eq!(s.active_frontend, FrontendId::LOCAL);
        let local_active_window = s.active_view().active;

        // Simulate the Day 2 transitional state: a daemon-attached
        // frontend's id is set as active, but no FrontendView is
        // registered for it (Day 3 work).
        s.active_frontend = FrontendId(42);
        assert!(!s.views.contains_key(&FrontendId(42)));

        // Fallback activates: active_view() returns LOCAL's view.
        let fallback_view = s.active_view();
        assert_eq!(
            fallback_view.active, local_active_window,
            "Day 2 fallback: active_view() returns LOCAL's view when active_frontend has no entry"
        );

        // Same for active_window().
        let win = s.active_window();
        assert_eq!(win.id, local_active_window);
    }

    #[test]
    fn active_view_for_explicit_fid_returns_none_when_unregistered() {
        // T M10.8 — explicit-fid lookups don't fall back. Callers
        // explicitly asking about a specific frontend get a truthful
        // None when that frontend has no state, distinguishing
        // "active by default" from "actually has its own view."
        let s = fresh();
        assert!(s.active_window_for(FrontendId(42)).is_none());
        assert!(s.active_window_for(FrontendId::LOCAL).is_some());
    }

    #[test]
    fn register_and_unregister_frontend_view() {
        // T M10.8 — the lifecycle API the dispatcher uses on attach
        // and detach. Wiring lives in `daemon.rs`; this test pins
        // the EditorCore-side semantics.
        let mut s = fresh();
        let fid = FrontendId(7);
        assert!(s.active_window_for(fid).is_none());

        // Build a view referencing the existing scratch window so
        // we don't need a fresh window allocation in this test.
        let local_view = s.views[&FrontendId::LOCAL].clone();
        s.register_frontend_view(fid, local_view);
        assert!(s.active_window_for(fid).is_some());

        // Unregister drops the entry; explicit lookup returns None.
        s.unregister_frontend_view(fid);
        assert!(s.active_window_for(fid).is_none());

        // LOCAL invariant survives unrelated register/unregister.
        assert!(s.views.contains_key(&FrontendId::LOCAL));
    }

    #[test]
    fn split_active_creates_a_second_window_on_same_buffer() {
        let mut s = fresh();
        let original = s.active_window_id();
        let new_id = s.split_active(Orientation::Vertical, true);
        assert_ne!(new_id, original);
        assert_eq!(s.windows.len(), 2);
        // Same buffer.
        assert_eq!(s.windows[&original].buffer_id, s.windows[&new_id].buffer_id);
    }

    #[test]
    fn edit_in_one_window_propagates_through_buffer_to_the_other() {
        let mut s = from_bytes(b"abc");
        let _new = s.split_active(Orientation::Vertical, true);
        // Insert via the active window.
        s.active_window_mut().cursor = 3;
        s.insert_char('X');
        // Buffer length is now 4; the *other* window shares the
        // same buffer, so its text view sees the same length.
        assert_eq!(s.active_buffer_len(), 4);
        // The other window's text_view has the same line count,
        // confirming on_edit fired.
        let active = s.active_window_id();
        let other = s.windows.keys().find(|id| **id != active).copied().unwrap();
        assert_eq!(s.windows[&other].text_view.line_count(), 1);
    }

    #[test]
    fn close_active_falls_back_to_remaining_window() {
        let mut s = fresh();
        s.split_active(Orientation::Horizontal, true);
        assert_eq!(s.windows.len(), 2);
        assert!(s.close_active());
        assert_eq!(s.windows.len(), 1);
    }

    #[test]
    fn close_active_refuses_when_only_one_window() {
        let mut s = fresh();
        assert!(!s.close_active());
        assert_eq!(s.windows.len(), 1);
    }

    #[test]
    fn focus_next_round_robins() {
        let mut s = fresh();
        let a = s.active_window_id();
        let _b = s.split_active(Orientation::Vertical, true);
        let _c = s.split_active(Orientation::Horizontal, true);
        // Splits don't move focus; `a` is still active.
        assert_eq!(s.active_window_id(), a);
        let order = s.active_layout().iter_ids();
        assert_eq!(order.len(), 3);
        // Walking N times wraps back to the original.
        for _ in 0..3 {
            s.focus_next();
        }
        assert_eq!(s.active_window_id(), a);
    }

    // ------------------------------------------------------------------
    // F27 / F28 (post-audit-round-5) — daemon-origin CRDT ops are
    // queued on `pending_crdt_ops` so they reach all replicas.
    // ------------------------------------------------------------------

    /// Helper: upgrade the active buffer to CRDT-backed under the
    /// LOCAL peer id (mirrors what the daemon does at attach time
    /// for replica sessions).
    #[cfg(feature = "crdt")]
    fn upgrade_active_to_crdt(s: &mut EditorCore) {
        let buffer_id = s.active_buffer_id();
        let mut reg = s.registry.borrow_mut();
        let buf = reg.get_mut(buffer_id).expect("active buffer present");
        buf.upgrade_to_crdt(crate::crdt::peer_id_from_frontend(
            crate::protocol::FrontendId::LOCAL,
        ))
        .expect("upgrade");
    }

    /// F27 — undo on a CRDT-backed buffer queues the resulting
    /// CRDT op for broadcast.
    #[cfg(feature = "crdt")]
    #[test]
    fn undo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
        let mut s = from_bytes(b"abc");
        upgrade_active_to_crdt(&mut s);
        // Apply an edit so there's something to undo. apply_active_edit
        // also pushes a DaemonKey-origin op.
        s.apply_active_edit(crate::buffer::EditOp::Insert {
            pos: 3,
            bytes: b"X",
        })
        .expect("edit");
        let queued_after_edit = s.pending_crdt_ops.len();
        assert!(queued_after_edit >= 1, "edit must queue a CRDT op");

        // Drain to isolate the undo's queueing.
        s.pending_crdt_ops.clear();
        s.undo();

        assert!(
            !s.pending_crdt_ops.is_empty(),
            "F27: undo on a CRDT-backed buffer must queue a CRDT op for broadcast"
        );
        // Origin must be DaemonKey (broadcast-to-all-replicas).
        let (origin, _, _) = &s.pending_crdt_ops[0];
        assert!(
            matches!(origin, CrdtOpOrigin::DaemonKey),
            "F27: undo's CRDT op must be queued with DaemonKey origin (broadcast to all replicas including active frontend)"
        );
    }

    /// F27 — redo on a CRDT-backed buffer queues the resulting
    /// CRDT op for broadcast.
    #[cfg(feature = "crdt")]
    #[test]
    fn redo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
        let mut s = from_bytes(b"abc");
        upgrade_active_to_crdt(&mut s);
        s.apply_active_edit(crate::buffer::EditOp::Insert {
            pos: 3,
            bytes: b"X",
        })
        .expect("edit");
        s.undo();
        s.pending_crdt_ops.clear();
        s.redo();
        assert!(
            !s.pending_crdt_ops.is_empty(),
            "F27: redo on a CRDT-backed buffer must queue a CRDT op for broadcast"
        );
        let (origin, _, _) = &s.pending_crdt_ops[0];
        assert!(matches!(origin, CrdtOpOrigin::DaemonKey));
    }

    /// F27 — undo on a non-CRDT buffer is a no-op for the broadcast
    /// queue (the buffer produced no `crdt_op` on the Edit).
    #[test]
    fn undo_on_non_crdt_buffer_does_not_queue_crdt_op_f27() {
        let mut s = from_bytes(b"abc");
        s.apply_active_edit(crate::buffer::EditOp::Insert {
            pos: 3,
            bytes: b"X",
        })
        .expect("edit");
        // Non-CRDT — apply_active_edit's pending push is a no-op
        // (Edit::crdt_op is None). Confirm precondition then undo.
        assert!(s.pending_crdt_ops.is_empty());
        s.undo();
        assert!(
            s.pending_crdt_ops.is_empty(),
            "F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
        );
    }

    // ---- jump ring (T M4.5 L1) -----------------------------------------

    #[test]
    fn jump_back_returns_false_on_empty_ring() {
        let mut s = from_bytes(b"abc");
        s.active_window_mut().cursor = 2;
        assert!(!s.jump_back(), "empty ring must not move the cursor");
        assert_eq!(s.cursor(), 2);
    }

    #[test]
    fn push_then_jump_back_restores_cursor() {
        let mut s = from_bytes(b"line one\nline two\nline three");
        s.active_window_mut().cursor = 3;
        s.push_jump();
        s.active_window_mut().cursor = 20;
        assert!(s.jump_back());
        assert_eq!(s.cursor(), 3);
        // Ring is now empty; a second pop is a no-op.
        assert!(!s.jump_back());
    }

    #[test]
    fn jump_back_clamps_to_shortened_buffer() {
        let mut s = from_bytes(b"abcdefghij");
        s.active_window_mut().cursor = 9;
        s.push_jump();
        // Truncate the buffer so the recorded position is past EOF.
        s.apply_active_edit(crate::buffer::EditOp::Delete {
            range: Range::new(2, 10),
        })
        .expect("delete");
        assert!(s.jump_back());
        assert_eq!(
            s.cursor(),
            s.active_buffer_len(),
            "stale position must clamp to the current buffer length"
        );
    }

    #[test]
    fn jump_ring_is_bounded_and_evicts_oldest() {
        let mut s = from_bytes(b"0123456789");
        for i in 0..(EditorCore::JUMP_RING_CAP + 10) {
            s.active_window_mut().cursor = (i % 10) as u64;
            s.push_jump();
        }
        assert_eq!(
            s.jump_ring.len(),
            EditorCore::JUMP_RING_CAP,
            "ring must stay bounded at JUMP_RING_CAP"
        );
    }

    #[test]
    fn jump_back_skips_removed_buffer() {
        let mut s = from_bytes(b"original");
        // Record a jump on a second buffer, then remove that buffer.
        let doomed = s.registry.borrow_mut().create_from_bytes("doomed", b"x");
        s.switch_active_buffer(doomed).expect("switch");
        s.active_window_mut().cursor = 1;
        s.push_jump();
        // Switch back and record a live origin too.
        let original = *s.registry.borrow().ids().first().expect("original id");
        s.switch_active_buffer(original).expect("switch back");
        s.active_window_mut().cursor = 4;
        s.push_jump();
        s.active_window_mut().cursor = 0;
        // Drop the doomed buffer: its ring entry is now stale.
        s.registry.borrow_mut().remove(doomed).expect("remove");
        // First pop lands on the live `original` origin.
        assert!(s.jump_back());
        assert_eq!(s.active_buffer_id(), original);
        assert_eq!(s.cursor(), 4);
        // Next pop would be the stale `doomed` entry — skipped, ring empties.
        assert!(!s.jump_back());
    }

    // ---- incremental search (Q#SR5) ------------------------------------

    fn type_query(s: &mut EditorCore, q: &str) {
        for ch in q.chars() {
            s.search_input_char(ch);
        }
    }

    #[test]
    fn search_begin_then_type_highlights_from_origin() {
        let mut s = from_bytes(b"foo bar foo baz foo");
        let bid = s.active_buffer_id();
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        assert!(s.search_active());
        type_query(&mut s, "foo");
        // Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
        // is active and the cursor sits on it.
        assert_eq!(s.search_match_summary(), (Some(0), 3));
        assert_eq!(s.cursor(), 0);
        let guard = s.search_store.lock().expect("store");
        assert!(!guard.is_stale(bid));
        assert_eq!(guard.for_buffer(bid).expect("entry").len(), 3);
    }

    #[test]
    fn search_step_walks_matches_and_wraps() {
        let mut s = from_bytes(b"foo bar foo baz foo");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        type_query(&mut s, "foo");
        assert_eq!(s.cursor(), 0);
        s.search_step(true);
        assert_eq!((s.search_match_summary(), s.cursor()), ((Some(1), 3), 8));
        s.search_step(true);
        assert_eq!(s.cursor(), 16);
        s.search_step(true); // wraps to the first match
        assert_eq!(s.cursor(), 0);
        s.search_step(false); // backward wraps to the last
        assert_eq!(s.cursor(), 16);
    }

    #[test]
    fn search_focuses_first_match_at_or_after_origin() {
        let mut s = from_bytes(b"foo bar foo");
        s.active_window_mut().cursor = 5; // inside "bar"
        s.search_begin(true, false);
        type_query(&mut s, "foo");
        // First match with start >= 5 is the one at byte 8.
        assert_eq!(s.cursor(), 8);
        assert_eq!(s.search_match_summary(), (Some(1), 2));
    }

    #[test]
    fn search_cancel_restores_origin_and_clears_store() {
        let mut s = from_bytes(b"foo bar foo");
        let bid = s.active_buffer_id();
        s.active_window_mut().cursor = 5;
        s.search_begin(true, false);
        type_query(&mut s, "foo");
        assert_eq!(s.cursor(), 8);
        s.search_finish(false); // cancel
        assert!(!s.search_active());
        assert_eq!(s.cursor(), 5, "cancel restores the pre-search cursor");
        assert!(
            s.search_store
                .lock()
                .expect("store")
                .for_buffer(bid)
                .is_none(),
            "cancel clears the matches"
        );
    }

    #[test]
    fn search_accept_keeps_cursor_and_matches() {
        let mut s = from_bytes(b"foo bar foo");
        let bid = s.active_buffer_id();
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        type_query(&mut s, "foo");
        s.search_step(true); // focus the match at byte 8
        assert_eq!(s.cursor(), 8);
        s.search_finish(true); // accept
        assert!(!s.search_active());
        assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match");
        assert!(
            s.search_store
                .lock()
                .expect("store")
                .for_buffer(bid)
                .is_some(),
            "accept keeps matches for highlight + navigation"
        );
    }

    #[test]
    fn search_backspace_widens_the_match_set() {
        let mut s = from_bytes(b"fo foo food");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
        assert_eq!(s.search_match_summary().1, 2);
        s.search_backspace(); // query "fo"
        assert_eq!(s.search_query(), "fo");
        assert_eq!(s.search_match_summary().1, 3);
    }

    #[test]
    fn search_smart_case_is_case_sensitive_with_uppercase() {
        let mut s = from_bytes(b"Foo foo FOO");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        type_query(&mut s, "Foo"); // uppercase => case-sensitive
        assert_eq!(s.search_match_summary().1, 1);
        s.search_backspace();
        s.search_backspace();
        s.search_backspace();
        type_query(&mut s, "foo"); // lowercase => smart-case folds all
        assert_eq!(s.search_match_summary().1, 3);
    }

    #[test]
    fn edit_marks_accepted_matches_stale() {
        let mut s = from_bytes(b"foo foo");
        let bid = s.active_buffer_id();
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false);
        type_query(&mut s, "foo");
        s.search_finish(true); // matches persist after accept
        assert!(!s.search_store.lock().expect("store").is_stale(bid));
        s.insert_char('x'); // any edit invalidates the match offsets
        assert!(
            s.search_store.lock().expect("store").is_stale(bid),
            "an edit marks the buffer's matches stale (linger fix)"
        );
    }

    // ---- regex search (Q#RX3) ------------------------------------------

    #[test]
    fn regex_search_matches_pattern() {
        let mut s = from_bytes(b"a1 b2 c3");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, true);
        assert!(s.search_is_regex());
        type_query(&mut s, r"\d");
        assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3");
        assert!(!s.search_is_invalid());
    }

    #[test]
    fn regex_invalid_pattern_flags_and_recovers() {
        let mut s = from_bytes(b"foo");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, true);
        type_query(&mut s, "fo("); // unbalanced group mid-typing
        assert!(s.search_is_invalid(), "incomplete group is invalid");
        assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches");
        type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo"
        assert!(!s.search_is_invalid(), "valid pattern recovers");
        assert_eq!(s.search_match_summary().1, 1);
    }

    #[test]
    fn toggle_regex_reinterprets_the_query() {
        let mut s = from_bytes(b"a.b axb");
        s.active_window_mut().cursor = 0;
        s.search_begin(true, false); // literal
        type_query(&mut s, "a.b");
        assert!(!s.search_is_regex());
        assert_eq!(
            s.search_match_summary().1,
            1,
            "literal '.' matches only a.b"
        );
        s.search_toggle_regex(); // → regex
        assert!(s.search_is_regex());
        assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb");
        s.search_toggle_regex(); // back to literal
        assert!(!s.search_is_regex());
        assert_eq!(s.search_match_summary().1, 1);
    }
}
