//! Editor component contracts + pure editing core (port of
//! `src/ui/editor-component.ts` plus the line-editor text-buffer model).
//!
//! Two layers live here, both framework-free (no ratatui, no crossterm):
//!
//! 1. **The contract** — a faithful port of indus-rebuild
//!    `src/ui/editor-component.ts`: the read / write / display / callback
//!    surfaces an editor component exposes ([`EditorReadAccess`],
//!    [`EditorWriteAccess`], [`EditorDisplayConfig`], [`EditorEventCallbacks`],
//!    and the composed [`EditorComponent`]). TS-optional members become
//!    overridable default methods; required members stay abstract.
//!
//! 2. **The editing core** — the pure logic of the indus line editor
//!    ([`EditorCore`]): a multi-line buffer + caret, grapheme-aware deletion
//!    and word motion, prompt history, the Emacs-style kill ring with
//!    accumulate / yank / yank-pop, undo with fish-style coalescing, and large
//!    pastes collapsed behind `[paste #N ...]` markers that edit as one atomic
//!    unit. The user-visible strings (paste markers) and the decision tables
//!    are ported faithfully from the TypeScript editor; everything render- or
//!    terminal-specific (ANSI painting, scroll offsets, bracketed-paste
//!    decoding, the Kitty protocol) stays in the render-layer widget that
//!    drives this core.
//!
//! String indices here are Rust `char` (Unicode scalar) counts rather than
//! JS UTF-16 units; the math is self-consistent because every consumer of this
//! module measures the same way. Whitespace / punctuation classification is
//! kept self-contained (rather than reaching into [`crate::utils`]) so the
//! core is independently testable.

use crate::autocomplete::AutocompleteProvider;
use crate::contracts::Component;

/// Read-only capabilities of an editor (≈ TS `EditorReadAccess`).
pub trait EditorReadAccess {
    /// Raw text currently held in the buffer (no virtual/expanded markers).
    fn get_text(&self) -> String;

    /// Text with inline markers expanded (e.g. paste sentinels made visible).
    /// Falls back to [`EditorReadAccess::get_text`] when not implemented.
    fn get_expanded_text(&self) -> String {
        self.get_text()
    }
}

/// Mutation capabilities of an editor (≈ TS `EditorWriteAccess`).
pub trait EditorWriteAccess {
    /// Replace the entire buffer; cursor moves to the end of the new content.
    fn set_text(&mut self, text: &str);

    /// Insert text at the cursor, pushing existing content right. Optional in TS.
    fn insert_text_at_cursor(&mut self, _text: &str) {}

    /// Push text into the history ring for Up/Down recall. Optional in TS.
    fn add_to_history(&mut self, _text: &str) {}
}

/// Optional visual + autocomplete configuration (≈ TS `EditorDisplayConfig`).
pub trait EditorDisplayConfig {
    /// ANSI colour-wrap function applied to the editor border.
    fn set_border_color(&mut self, _color: Box<dyn Fn(&str) -> String + Send + Sync>) {}

    /// Set the number of blank columns padding the content area left and right.
    fn set_padding_x(&mut self, _padding: usize) {}

    /// Attach an autocomplete provider for completion candidates.
    fn set_autocomplete_provider(&mut self, _provider: Box<dyn AutocompleteProvider>) {}

    /// Opaque keybinding-scope id used to resolve per-component overrides.
    fn get_keybinding_scope_id(&self) -> Option<String> {
        None
    }
}

/// Lifecycle event callbacks (≈ TS `EditorEventCallbacks`).
pub trait EditorEventCallbacks {
    /// Fired when the user commits the input (e.g. Enter); receives final text.
    fn set_on_submit(&mut self, _cb: Box<dyn FnMut(&str) + Send>) {}

    /// Fired on every change; receives the updated text after the change.
    fn set_on_change(&mut self, _cb: Box<dyn FnMut(&str) + Send>) {}
}

/// Full pluggable editor (≈ TS `EditorComponent`): composes the `Component`
/// lifecycle with the read / write / display / callback surfaces.
pub trait EditorComponent:
    Component + EditorReadAccess + EditorWriteAccess + EditorDisplayConfig + EditorEventCallbacks
{
}

// ---------------------------------------------------------------------------
// JS-parity helpers (whitespace / punctuation / trim)
// ---------------------------------------------------------------------------

/// The characters JavaScript's `\s` / `trim()` treat as whitespace, spelled out
/// so trims and word motion behave like the TypeScript editor (Rust's
/// `char::is_whitespace` differs slightly, e.g. it omits U+FEFF and includes a
/// few JS excludes).
const JS_WS_CHARS: &[char] = &[
    ' ', '\t', '\n', '\r', '\u{000b}', '\u{000c}', '\u{00a0}', '\u{1680}', '\u{2000}', '\u{2001}',
    '\u{2002}', '\u{2003}', '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}',
    '\u{200a}', '\u{2028}', '\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}', '\u{feff}',
];

/// `is_whitespace_char` parity: JS `\s` classification for a single char.
fn is_ws(ch: char) -> bool {
    JS_WS_CHARS.contains(&ch)
}

/// `is_whitespace_char` parity for a (possibly multi-codepoint) segment: the
/// segment is whitespace iff it is a single whitespace scalar.
fn segment_is_ws(seg: &str) -> bool {
    let mut it = seg.chars();
    match (it.next(), it.next()) {
        (Some(c), None) => is_ws(c),
        _ => false,
    }
}

/// `is_punctuation_char` parity: a non-empty, non-whitespace, non-alphanumeric,
/// non-combining single scalar. Mirrors the TS `isPunctuationChar` predicate
/// (anything that is neither word-char nor whitespace).
fn segment_is_punct(seg: &str) -> bool {
    let mut it = seg.chars();
    match (it.next(), it.next()) {
        (Some(c), None) => !is_ws(c) && !c.is_alphanumeric() && c != '_' && !is_combining(c),
        _ => false,
    }
}

/// Unicode combining marks (general categories Mn / Mc / Me) are treated as
/// part of a word, never as punctuation.
fn is_combining(c: char) -> bool {
    matches!(c as u32,
        0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
}

/// `String.prototype.trim` parity (JS whitespace set, both ends).
fn js_trim(text: &str) -> String {
    text.trim_matches(is_ws).to_string()
}

/// Normalize text for editor storage: CRLF / CR become LF, tabs expand to four
/// spaces. (≈ TS `normalizeText`.)
fn normalize_text(text: &str) -> String {
    text.replace("\r\n", "\n")
        .replace('\r', "\n")
        .replace('\t', "    ")
}

// ---------------------------------------------------------------------------
// Char-vector helpers
//
// The TS/Python editor indexes the buffer by code points and slices freely.
// Rust `str` is byte-indexed, so the core keeps each logical line ready to be
// treated as a `Vec<char>` for O(1) splicing at a column. These helpers convert
// between `String` and char-columns without ever splitting a scalar.
// ---------------------------------------------------------------------------

fn char_len(s: &str) -> usize {
    s.chars().count()
}

/// `s[..col]` measured in chars.
fn take_chars(s: &str, col: usize) -> String {
    s.chars().take(col).collect()
}

/// `s[col..]` measured in chars.
fn drop_chars(s: &str, col: usize) -> String {
    s.chars().skip(col).collect()
}

/// `s[start..end]` measured in chars.
fn slice_chars(s: &str, start: usize, end: usize) -> String {
    s.chars()
        .skip(start)
        .take(end.saturating_sub(start))
        .collect()
}

// ---------------------------------------------------------------------------
// Paste markers
// ---------------------------------------------------------------------------

/// Is `segment` a standalone paste marker like `[paste #1 +12 lines]` or
/// `[paste #2 345 chars]`? (≈ TS `isPasteMarker`.)
fn is_paste_marker(segment: &str) -> bool {
    if segment.len() < 10 || !segment.starts_with("[paste #") || !segment.ends_with(']') {
        return false;
    }
    // Strip "[paste #" prefix and trailing "]".
    let inner = &segment[8..segment.len() - 1];
    // <digits> ( <space> (+<digits> lines | <digits> chars) )?
    let (id_part, rest) = match inner.find(' ') {
        Some(i) => (&inner[..i], Some(&inner[i + 1..])),
        None => (inner, None),
    };
    if id_part.is_empty() || !id_part.bytes().all(|b| b.is_ascii_digit()) {
        return false;
    }
    match rest {
        None => true,
        Some(r) => {
            if let Some(n) = r.strip_prefix('+').and_then(|x| x.strip_suffix(" lines")) {
                !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
            } else if let Some(n) = r.strip_suffix(" chars") {
                !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
            } else {
                false
            }
        }
    }
}

/// A grapheme (or merged atomic paste marker) and its char-index start.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Segment {
    text: String,
    /// Start index in chars.
    index: usize,
}

// ---------------------------------------------------------------------------
// Buffer state, kill ring, undo
// ---------------------------------------------------------------------------

/// The whole undoable buffer state: logical lines plus the caret.
/// (≈ TS `EditorState`.)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorState {
    /// Logical lines (always at least one).
    pub lines: Vec<String>,
    /// Caret logical-line index.
    pub cursor_line: usize,
    /// Caret column, in chars.
    pub cursor_col: usize,
}

impl Default for EditorState {
    fn default() -> Self {
        Self {
            lines: vec![String::new()],
            cursor_line: 0,
            cursor_col: 0,
        }
    }
}

/// Ring buffer for Emacs-style kill / yank operations (≈ TS `KillRing`).
///
/// Consecutive kills can accumulate into a single entry. Supports yank (paste
/// most recent) and yank-pop (cycle through older entries via [`KillRing::rotate`]).
#[derive(Debug, Default)]
struct KillRing {
    ring: Vec<String>,
}

impl KillRing {
    /// Add `text` to the ring. When `accumulate` is set and the ring is
    /// non-empty, merge with the most recent entry — `prepend` for backward
    /// deletion, append for forward deletion. Empty text is ignored.
    fn push(&mut self, text: &str, prepend: bool, accumulate: bool) {
        if text.is_empty() {
            return;
        }
        if accumulate && !self.ring.is_empty() {
            let last = self.ring.pop().unwrap();
            let merged = if prepend {
                format!("{text}{last}")
            } else {
                format!("{last}{text}")
            };
            self.ring.push(merged);
        } else {
            self.ring.push(text.to_string());
        }
    }

    /// Most recent entry without modifying the ring.
    fn peek(&self) -> Option<&str> {
        self.ring.last().map(|s| s.as_str())
    }

    /// Move the last entry to the front (yank-pop cycling).
    fn rotate(&mut self) {
        if self.ring.len() > 1 {
            let last = self.ring.pop().unwrap();
            self.ring.insert(0, last);
        }
    }

    fn len(&self) -> usize {
        self.ring.len()
    }
}

/// Undo stack with clone-on-push semantics (≈ TS `UndoStack`).
#[derive(Debug, Default)]
struct UndoStack {
    stack: Vec<EditorState>,
}

impl UndoStack {
    fn push(&mut self, state: &EditorState) {
        self.stack.push(state.clone());
    }
    fn pop(&mut self) -> Option<EditorState> {
        self.stack.pop()
    }
    fn clear(&mut self) {
        self.stack.clear();
    }
}

/// The last mutating action, used for kill-accumulation, undo coalescing, and
/// yank-pop gating (≈ TS internal `lastAction`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LastAction {
    Kill,
    Yank,
    TypeWord,
}

// ---------------------------------------------------------------------------
// The pure editing core
// ---------------------------------------------------------------------------

/// The framework-free engine behind the prompt editor (≈ TS line editor /
/// Python `EditorCore`).
///
/// Owns the buffer, caret, prompt history, kill ring, undo stack, and paste
/// vault. A render-layer widget translates keystrokes into calls on this object
/// and re-renders from [`EditorCore::get_text`] / [`EditorCore::state`].
pub struct EditorCore {
    /// Current buffer + caret.
    pub state: EditorState,

    // Paste tracking for large pastes.
    pastes: Vec<(u64, String)>,
    paste_counter: u64,

    // Prompt history for up/down navigation. -1 (here: None) = not browsing,
    // 0 = most recent, 1 = older, etc.
    history: Vec<String>,
    history_index: i64,

    kill_ring: KillRing,
    last_action: Option<LastAction>,

    undo_stack: UndoStack,

    /// When set, [`EditorCore::submit_value`] returns the empty string and
    /// never resets the buffer — mirrors the TS `disableSubmit` flag.
    pub disable_submit: bool,

    // Lifecycle callbacks.
    on_submit: Option<EditorCallback>,
    on_change: Option<EditorCallback>,
}

/// A lifecycle callback fired with the editor text (≈ TS `onSubmit` / `onChange`).
type EditorCallback = Box<dyn FnMut(&str) + Send>;

impl Default for EditorCore {
    fn default() -> Self {
        Self::new()
    }
}

impl EditorCore {
    /// A fresh, empty editor.
    pub fn new() -> Self {
        Self {
            state: EditorState::default(),
            pastes: Vec::new(),
            paste_counter: 0,
            history: Vec::new(),
            history_index: -1,
            kill_ring: KillRing::default(),
            last_action: None,
            undo_stack: UndoStack::default(),
            disable_submit: false,
            on_submit: None,
            on_change: None,
        }
    }

    // -- callbacks ----------------------------------------------------------

    /// Install the submit callback (fires on [`EditorCore::submit_value`]).
    pub fn on_submit(&mut self, cb: Box<dyn FnMut(&str) + Send>) {
        self.on_submit = Some(cb);
    }

    /// Install the change callback (fires after every buffer mutation).
    pub fn on_change(&mut self, cb: Box<dyn FnMut(&str) + Send>) {
        self.on_change = Some(cb);
    }

    fn emit_change(&mut self) {
        if self.on_change.is_some() {
            let text = self.get_text();
            if let Some(cb) = self.on_change.as_mut() {
                cb(&text);
            }
        }
    }

    // -- small accessors ----------------------------------------------------

    fn current_line(&self) -> &str {
        self.state
            .lines
            .get(self.state.cursor_line)
            .map(|s| s.as_str())
            .unwrap_or("")
    }

    /// The current logical line's text up to the caret.
    pub fn text_before_cursor(&self) -> String {
        take_chars(self.current_line(), self.state.cursor_col)
    }

    /// The whole buffer joined with `\n` (raw, markers not expanded).
    pub fn get_text(&self) -> String {
        self.state.lines.join("\n")
    }

    /// The buffer with paste markers expanded to their stored content.
    pub fn get_expanded_text(&self) -> String {
        self.expand_paste_markers(&self.get_text())
    }

    /// A copy of the logical lines.
    pub fn get_lines(&self) -> Vec<String> {
        self.state.lines.clone()
    }

    /// The `(line, col)` caret position.
    pub fn get_cursor(&self) -> (usize, usize) {
        (self.state.cursor_line, self.state.cursor_col)
    }

    /// Whether the buffer is a single empty line.
    pub fn is_editor_empty(&self) -> bool {
        self.state.lines.len() == 1 && self.state.lines[0].is_empty()
    }

    /// The current history-browse index (`-1` = not browsing).
    pub fn history_index(&self) -> i64 {
        self.history_index
    }

    /// Expand every `[paste #N ...]` marker into its stored content.
    pub fn expand_paste_markers(&self, text: &str) -> String {
        let mut result = text.to_string();
        for (id, content) in &self.pastes {
            // Replace the bare marker and both decorated forms.
            for marker in self.marker_forms(*id, text) {
                result = result.replace(&marker, content);
            }
        }
        result
    }

    /// All marker spellings of `id` that currently appear in `text`. Because
    /// the decoration (`+N lines` / `N chars`) is baked into the inserted
    /// marker, we scan for the actual occurrences.
    fn marker_forms(&self, id: u64, text: &str) -> Vec<String> {
        let prefix = format!("[paste #{id}");
        let mut out = Vec::new();
        let mut search_from = 0;
        while let Some(rel) = text[search_from..].find(&prefix) {
            let start = search_from + rel;
            if let Some(close_rel) = text[start..].find(']') {
                let end = start + close_rel + 1;
                let candidate = &text[start..end];
                // Confirm the next char after the id is a space or ']'.
                let after_id = &candidate[prefix.len()..];
                if (after_id.starts_with(' ') || after_id == "]") && is_paste_marker(candidate) {
                    out.push(candidate.to_string());
                }
                search_from = end;
            } else {
                break;
            }
        }
        out
    }

    // -- segmentation -------------------------------------------------------

    /// Segment `text` into chars, merging valid paste markers into single
    /// atomic segments (≈ TS `segmentWithMarkers`). Each base segment is a
    /// single Unicode scalar; this keeps the core grapheme-agnostic while still
    /// treating paste markers as one unit for motion / deletion.
    fn segment(&self, text: &str) -> Vec<Segment> {
        let base: Vec<Segment> = text
            .chars()
            .scan(0usize, |idx, c| {
                let seg = Segment {
                    text: c.to_string(),
                    index: *idx,
                };
                *idx += 1;
                Some(seg)
            })
            .collect();

        if self.pastes.is_empty() || !text.contains("[paste #") {
            return base;
        }

        // Find marker spans (char-index ranges) whose id is valid.
        let chars: Vec<char> = text.chars().collect();
        let mut markers: Vec<(usize, usize)> = Vec::new();
        for (id, _) in &self.pastes {
            for m in self.marker_forms(*id, text) {
                // Locate each occurrence's char range.
                let m_chars = char_len(&m);
                let mut from = 0usize;
                while from + m_chars <= chars.len() {
                    let window: String = chars[from..from + m_chars].iter().collect();
                    if window == m {
                        markers.push((from, from + m_chars));
                        from += m_chars;
                    } else {
                        from += 1;
                    }
                }
            }
        }
        if markers.is_empty() {
            return base;
        }
        markers.sort_unstable();

        let mut result: Vec<Segment> = Vec::new();
        let mut marker_idx = 0;
        for seg in base {
            while marker_idx < markers.len() && markers[marker_idx].1 <= seg.index {
                marker_idx += 1;
            }
            let marker = markers.get(marker_idx).copied();
            match marker {
                Some((start, end)) if start <= seg.index && seg.index < end => {
                    if seg.index == start {
                        let merged: String = chars[start..end].iter().collect();
                        result.push(Segment {
                            text: merged,
                            index: start,
                        });
                    }
                    // else: inside a marker but not its head — skip.
                }
                _ => result.push(seg),
            }
        }
        result
    }

    // -- text normalization / programmatic mutation -------------------------

    /// `\r\n` and `\r` become `\n`; tabs become four spaces. (≈ TS `normalizeText`.)
    pub fn normalize_text(text: &str) -> String {
        normalize_text(text)
    }

    /// Internal set_text that does not reset history state — used by
    /// [`EditorCore::navigate_history`].
    fn set_text_internal(&mut self, text: &str) {
        let lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
        self.state.lines = if lines.is_empty() {
            vec![String::new()]
        } else {
            lines
        };
        self.state.cursor_line = self.state.lines.len() - 1;
        let col = char_len(&self.state.lines[self.state.cursor_line]);
        self.state.cursor_col = col;
        self.emit_change();
    }

    /// Replace the entire buffer; cursor moves to the end. Resets history
    /// browsing and pushes an undo snapshot when the content actually changes.
    pub fn set_text(&mut self, text: &str) {
        self.last_action = None;
        self.history_index = -1;
        let normalized = normalize_text(text);
        if self.get_text() != normalized {
            self.push_undo_snapshot();
        }
        self.set_text_internal(&normalized);
    }

    /// Programmatic insertion at the caret (e.g. clipboard image markers).
    /// Atomic for undo — a single undo restores the entire pre-insert state.
    pub fn insert_text_at_cursor(&mut self, text: &str) {
        if text.is_empty() {
            return;
        }
        self.push_undo_snapshot();
        self.last_action = None;
        self.history_index = -1;
        self.insert_text_at_cursor_internal(text);
    }

    /// Splice text at the caret, single- or multi-line. Does NOT push an undo
    /// snapshot (caller's responsibility). Emits one change at the end.
    fn insert_text_at_cursor_internal(&mut self, text: &str) {
        if text.is_empty() {
            return;
        }
        let normalized = normalize_text(text);
        let inserted: Vec<String> = normalized.split('\n').map(|s| s.to_string()).collect();

        let line = self.current_line().to_string();
        let before = take_chars(&line, self.state.cursor_col);
        let after = drop_chars(&line, self.state.cursor_col);

        if inserted.len() == 1 {
            self.state.lines[self.state.cursor_line] = format!("{before}{normalized}{after}");
            self.state.cursor_col += char_len(&normalized);
        } else {
            let mut new_lines: Vec<String> = Vec::new();
            new_lines.extend_from_slice(&self.state.lines[..self.state.cursor_line]);
            new_lines.push(format!("{before}{}", inserted[0]));
            new_lines.extend_from_slice(&inserted[1..inserted.len() - 1]);
            new_lines.push(format!("{}{after}", inserted[inserted.len() - 1]));
            new_lines.extend_from_slice(&self.state.lines[self.state.cursor_line + 1..]);
            self.state.lines = new_lines;
            self.state.cursor_line += inserted.len() - 1;
            self.state.cursor_col = char_len(&inserted[inserted.len() - 1]);
        }
        self.emit_change();
    }

    // -- typing -------------------------------------------------------------

    /// Splice a typed string at the caret.
    ///
    /// Undo coalescing (fish-style): consecutive word characters coalesce into
    /// one undo unit; whitespace captures state before itself (so undo removes
    /// the space plus following word together). Coalescing is skipped for
    /// atomic ops (e.g. paste handling).
    pub fn insert_character(&mut self, ch: &str, skip_undo_coalescing: bool) {
        self.history_index = -1;
        if !skip_undo_coalescing {
            let ch_is_ws = ch.chars().count() == 1 && ch.chars().all(is_ws);
            if ch_is_ws || self.last_action != Some(LastAction::TypeWord) {
                self.push_undo_snapshot();
            }
            self.last_action = Some(LastAction::TypeWord);
        }
        let line = self.current_line().to_string();
        let before = take_chars(&line, self.state.cursor_col);
        let after = drop_chars(&line, self.state.cursor_col);
        self.state.lines[self.state.cursor_line] = format!("{before}{ch}{after}");
        self.state.cursor_col += char_len(ch);
        self.emit_change();
    }

    /// Insert pasted text; large pastes (> 10 lines or > 1000 chars) are stored
    /// in the paste vault and replaced by a `[paste #N ...]` marker that edits
    /// as one atomic unit. (≈ TS `handlePaste`.)
    pub fn handle_paste(&mut self, pasted_text: &str) {
        self.history_index = -1;
        self.last_action = None;
        self.push_undo_snapshot();

        let clean = normalize_text(pasted_text);
        let mut filtered: String = clean
            .chars()
            .filter(|&c| c == '\n' || (c as u32) >= 32)
            .collect();

        // Prepend a space when pasting a path right after a word character.
        if filtered.starts_with(['/', '~', '.']) {
            let line = self.current_line().to_string();
            let char_before = if self.state.cursor_col > 0 {
                line.chars().nth(self.state.cursor_col - 1)
            } else {
                None
            };
            if let Some(c) = char_before
                && (c.is_alphanumeric() || c == '_')
            {
                filtered = format!(" {filtered}");
            }
        }

        let line_count = filtered.split('\n').count();
        let total_chars = char_len(&filtered);
        if line_count > 10 || total_chars > 1000 {
            self.paste_counter += 1;
            let id = self.paste_counter;
            self.pastes.push((id, filtered.clone()));
            let marker = if line_count > 10 {
                format!("[paste #{id} +{line_count} lines]")
            } else {
                format!("[paste #{id} {total_chars} chars]")
            };
            self.insert_text_at_cursor_internal(&marker);
            return;
        }
        self.insert_text_at_cursor_internal(&filtered);
    }

    // -- newline / submit ---------------------------------------------------

    /// Split the current line at the caret, moving the caret to column 0 of the
    /// freshly-inserted line. (≈ TS `addNewLine`.)
    pub fn add_new_line(&mut self) {
        self.history_index = -1;
        self.last_action = None;
        self.push_undo_snapshot();

        let line = self.current_line().to_string();
        let before = take_chars(&line, self.state.cursor_col);
        let after = drop_chars(&line, self.state.cursor_col);

        self.state.lines[self.state.cursor_line] = before;
        self.state.lines.insert(self.state.cursor_line + 1, after);
        self.state.cursor_line += 1;
        self.state.cursor_col = 0;
        self.emit_change();
    }

    /// Whether the char immediately before the caret is `\` — the multi-line
    /// continuation sentinel for terminals without Shift+Enter.
    pub fn has_backslash_before_cursor(&self) -> bool {
        self.state.cursor_col > 0
            && self.current_line().chars().nth(self.state.cursor_col - 1) == Some('\\')
    }

    /// Commit the buffer: expand paste markers, trim, reset all state, and fire
    /// `on_change("")` then `on_submit(result)`. Returns the committed text.
    /// (≈ TS `submitValue`.)
    pub fn submit_value(&mut self) -> String {
        let result = js_trim(&self.expand_paste_markers(&self.state.lines.join("\n")));

        self.state = EditorState::default();
        self.pastes.clear();
        self.paste_counter = 0;
        self.history_index = -1;
        self.undo_stack.clear();
        self.last_action = None;

        if let Some(cb) = self.on_change.as_mut() {
            cb("");
        }
        if let Some(cb) = self.on_submit.as_mut() {
            cb(&result);
        }
        result
    }

    // -- deletion -----------------------------------------------------------

    /// Delete the char before the caret (or merge with the previous line at
    /// column 0). (≈ TS `handleBackspace`.)
    pub fn handle_backspace(&mut self) {
        self.history_index = -1;
        self.last_action = None;

        if self.state.cursor_col > 0 {
            self.push_undo_snapshot();
            let line = self.current_line().to_string();
            let before_cursor = take_chars(&line, self.state.cursor_col);
            let segs = self.segment(&before_cursor);
            let g_len = segs.last().map(|s| char_len(&s.text)).unwrap_or(1);
            let before = take_chars(&line, self.state.cursor_col - g_len);
            let after = drop_chars(&line, self.state.cursor_col);
            self.state.lines[self.state.cursor_line] = format!("{before}{after}");
            self.state.cursor_col -= g_len;
        } else if self.state.cursor_line > 0 {
            self.push_undo_snapshot();
            let current = self.current_line().to_string();
            let previous = self.state.lines[self.state.cursor_line - 1].clone();
            let prev_len = char_len(&previous);
            self.state.lines[self.state.cursor_line - 1] = format!("{previous}{current}");
            self.state.lines.remove(self.state.cursor_line);
            self.state.cursor_line -= 1;
            self.state.cursor_col = prev_len;
        }
        self.emit_change();
    }

    /// Delete the char at the caret (or merge with the next line at end of
    /// line). (≈ TS `handleForwardDelete`.)
    pub fn handle_forward_delete(&mut self) {
        self.history_index = -1;
        self.last_action = None;

        let line = self.current_line().to_string();
        let line_len = char_len(&line);
        if self.state.cursor_col < line_len {
            self.push_undo_snapshot();
            let after_cursor = drop_chars(&line, self.state.cursor_col);
            let segs = self.segment(&after_cursor);
            let g_len = segs.first().map(|s| char_len(&s.text)).unwrap_or(1);
            let before = take_chars(&line, self.state.cursor_col);
            let after = drop_chars(&line, self.state.cursor_col + g_len);
            self.state.lines[self.state.cursor_line] = format!("{before}{after}");
        } else if self.state.cursor_line < self.state.lines.len() - 1 {
            self.push_undo_snapshot();
            let next = self.state.lines[self.state.cursor_line + 1].clone();
            self.state.lines[self.state.cursor_line] = format!("{line}{next}");
            self.state.lines.remove(self.state.cursor_line + 1);
        }
        self.emit_change();
    }

    /// Kill from line start to caret (readline unix-line-discard); at column 0,
    /// kill the line break. (≈ TS `deleteToStartOfLine`.)
    pub fn delete_to_start_of_line(&mut self) {
        self.history_index = -1;
        let line = self.current_line().to_string();
        let was_kill = self.last_action == Some(LastAction::Kill);

        if self.state.cursor_col > 0 {
            self.push_undo_snapshot();
            let deleted = take_chars(&line, self.state.cursor_col);
            self.kill_ring.push(&deleted, true, was_kill);
            self.last_action = Some(LastAction::Kill);
            self.state.lines[self.state.cursor_line] = drop_chars(&line, self.state.cursor_col);
            self.state.cursor_col = 0;
        } else if self.state.cursor_line > 0 {
            self.push_undo_snapshot();
            self.kill_ring.push("\n", true, was_kill);
            self.last_action = Some(LastAction::Kill);
            let previous = self.state.lines[self.state.cursor_line - 1].clone();
            let prev_len = char_len(&previous);
            self.state.lines[self.state.cursor_line - 1] = format!("{previous}{line}");
            self.state.lines.remove(self.state.cursor_line);
            self.state.cursor_line -= 1;
            self.state.cursor_col = prev_len;
        }
        self.emit_change();
    }

    /// Kill from caret to line end (readline kill-line); at end of line, kill
    /// the line break. (≈ TS `deleteToEndOfLine`.)
    pub fn delete_to_end_of_line(&mut self) {
        self.history_index = -1;
        let line = self.current_line().to_string();
        let line_len = char_len(&line);
        let was_kill = self.last_action == Some(LastAction::Kill);

        if self.state.cursor_col < line_len {
            self.push_undo_snapshot();
            let deleted = drop_chars(&line, self.state.cursor_col);
            self.kill_ring.push(&deleted, false, was_kill);
            self.last_action = Some(LastAction::Kill);
            self.state.lines[self.state.cursor_line] = take_chars(&line, self.state.cursor_col);
        } else if self.state.cursor_line < self.state.lines.len() - 1 {
            self.push_undo_snapshot();
            self.kill_ring.push("\n", false, was_kill);
            self.last_action = Some(LastAction::Kill);
            let next = self.state.lines[self.state.cursor_line + 1].clone();
            self.state.lines[self.state.cursor_line] = format!("{line}{next}");
            self.state.lines.remove(self.state.cursor_line + 1);
        }
        self.emit_change();
    }

    /// Kill the word before the caret (readline unix-word-rubout).
    /// (≈ TS `deleteWordBackwards`.)
    pub fn delete_word_backwards(&mut self) {
        self.history_index = -1;
        let line = self.current_line().to_string();

        if self.state.cursor_col == 0 {
            if self.state.cursor_line > 0 {
                let was_kill = self.last_action == Some(LastAction::Kill);
                self.push_undo_snapshot();
                self.kill_ring.push("\n", true, was_kill);
                self.last_action = Some(LastAction::Kill);
                let previous = self.state.lines[self.state.cursor_line - 1].clone();
                let prev_len = char_len(&previous);
                self.state.lines[self.state.cursor_line - 1] = format!("{previous}{line}");
                self.state.lines.remove(self.state.cursor_line);
                self.state.cursor_line -= 1;
                self.state.cursor_col = prev_len;
            }
        } else {
            self.push_undo_snapshot();
            let was_kill = self.last_action == Some(LastAction::Kill);
            let old_col = self.state.cursor_col;
            self.move_word_backwards();
            let delete_from = self.state.cursor_col;
            self.state.cursor_col = old_col;

            let deleted = slice_chars(&line, delete_from, self.state.cursor_col);
            self.kill_ring.push(&deleted, true, was_kill);
            self.last_action = Some(LastAction::Kill);

            let before = take_chars(&line, delete_from);
            let after = drop_chars(&line, self.state.cursor_col);
            self.state.lines[self.state.cursor_line] = format!("{before}{after}");
            self.state.cursor_col = delete_from;
        }
        self.emit_change();
    }

    /// Kill the word after the caret (readline kill-word).
    /// (≈ TS `deleteWordForward`.)
    pub fn delete_word_forward(&mut self) {
        self.history_index = -1;
        let line = self.current_line().to_string();
        let line_len = char_len(&line);

        if self.state.cursor_col >= line_len {
            if self.state.cursor_line < self.state.lines.len() - 1 {
                let was_kill = self.last_action == Some(LastAction::Kill);
                self.push_undo_snapshot();
                self.kill_ring.push("\n", false, was_kill);
                self.last_action = Some(LastAction::Kill);
                let next = self.state.lines[self.state.cursor_line + 1].clone();
                self.state.lines[self.state.cursor_line] = format!("{line}{next}");
                self.state.lines.remove(self.state.cursor_line + 1);
            }
        } else {
            self.push_undo_snapshot();
            let was_kill = self.last_action == Some(LastAction::Kill);
            let old_col = self.state.cursor_col;
            self.move_word_forwards();
            let delete_to = self.state.cursor_col;
            self.state.cursor_col = old_col;

            let deleted = slice_chars(&line, self.state.cursor_col, delete_to);
            self.kill_ring.push(&deleted, false, was_kill);
            self.last_action = Some(LastAction::Kill);

            let before = take_chars(&line, self.state.cursor_col);
            let after = drop_chars(&line, delete_to);
            self.state.lines[self.state.cursor_line] = format!("{before}{after}");
        }
        self.emit_change();
    }

    // -- kill ring ----------------------------------------------------------

    /// Yank (paste) the most recent kill-ring entry at the caret. (≈ TS `yank`.)
    pub fn yank(&mut self) {
        if self.kill_ring.len() == 0 {
            return;
        }
        self.push_undo_snapshot();
        let text = self.kill_ring.peek().unwrap().to_string();
        self.insert_yanked_text(&text);
        self.last_action = Some(LastAction::Yank);
    }

    /// Cycle through the kill ring (only immediately after yank / yank-pop):
    /// replaces the last yanked text with the previous entry. (≈ TS `yankPop`.)
    pub fn yank_pop(&mut self) {
        if self.last_action != Some(LastAction::Yank) || self.kill_ring.len() <= 1 {
            return;
        }
        self.push_undo_snapshot();
        self.delete_yanked_text();
        self.kill_ring.rotate();
        let text = self.kill_ring.peek().unwrap().to_string();
        self.insert_yanked_text(&text);
        self.last_action = Some(LastAction::Yank);
    }

    fn insert_yanked_text(&mut self, text: &str) {
        self.history_index = -1;
        let lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();

        let current = self.current_line().to_string();
        let before = take_chars(&current, self.state.cursor_col);
        let after = drop_chars(&current, self.state.cursor_col);

        if lines.len() == 1 {
            self.state.lines[self.state.cursor_line] = format!("{before}{text}{after}");
            self.state.cursor_col += char_len(text);
        } else {
            self.state.lines[self.state.cursor_line] = format!("{before}{}", lines[0]);
            for (i, mid) in lines[1..lines.len() - 1].iter().enumerate() {
                self.state
                    .lines
                    .insert(self.state.cursor_line + 1 + i, mid.clone());
            }
            let last_idx = self.state.cursor_line + lines.len() - 1;
            self.state
                .lines
                .insert(last_idx, format!("{}{after}", lines[lines.len() - 1]));
            self.state.cursor_line = last_idx;
            self.state.cursor_col = char_len(&lines[lines.len() - 1]);
        }
        self.emit_change();
    }

    fn delete_yanked_text(&mut self) {
        let yanked = match self.kill_ring.peek() {
            Some(t) if !t.is_empty() => t.to_string(),
            _ => return,
        };
        let yank_lines: Vec<&str> = yanked.split('\n').collect();

        if yank_lines.len() == 1 {
            let current = self.current_line().to_string();
            let delete_len = char_len(&yanked);
            let before = take_chars(&current, self.state.cursor_col - delete_len);
            let after = drop_chars(&current, self.state.cursor_col);
            self.state.lines[self.state.cursor_line] = format!("{before}{after}");
            self.state.cursor_col -= delete_len;
        } else {
            let start_line = self.state.cursor_line - (yank_lines.len() - 1);
            let start_col = char_len(&self.state.lines[start_line]) - char_len(yank_lines[0]);
            let after_cursor = drop_chars(
                &self.state.lines[self.state.cursor_line],
                self.state.cursor_col,
            );
            let before_yank = take_chars(&self.state.lines[start_line], start_col);
            let replacement = format!("{before_yank}{after_cursor}");
            self.state
                .lines
                .splice(start_line..start_line + yank_lines.len(), [replacement]);
            self.state.cursor_line = start_line;
            self.state.cursor_col = start_col;
        }
        self.emit_change();
    }

    // -- undo ---------------------------------------------------------------

    fn push_undo_snapshot(&mut self) {
        self.undo_stack.push(&self.state);
    }

    /// Restore the most recent undo snapshot. (≈ TS `undo`.)
    pub fn undo(&mut self) {
        self.history_index = -1;
        let Some(snapshot) = self.undo_stack.pop() else {
            return;
        };
        self.state = snapshot;
        self.last_action = None;
        self.emit_change();
    }

    // -- history ------------------------------------------------------------

    /// Add a prompt to history for up/down recall. Skips blank and consecutive
    /// duplicate entries; caps at 100. (≈ TS `addToHistory`.)
    pub fn add_to_history(&mut self, text: &str) {
        let trimmed = js_trim(text);
        if trimmed.is_empty() {
            return;
        }
        if self.history.first().map(|s| s.as_str()) == Some(trimmed.as_str()) {
            return;
        }
        self.history.insert(0, trimmed);
        if self.history.len() > 100 {
            self.history.pop();
        }
    }

    /// Recall older (`-1`) / newer (`1`) history entries into the buffer.
    /// (≈ TS `navigateHistory`.)
    pub fn navigate_history(&mut self, direction: i64) {
        self.last_action = None;
        if self.history.is_empty() {
            return;
        }
        let new_index = self.history_index - direction;
        if new_index < -1 || new_index >= self.history.len() as i64 {
            return;
        }
        if self.history_index == -1 && new_index >= 0 {
            self.push_undo_snapshot();
        }
        self.history_index = new_index;
        if self.history_index == -1 {
            self.set_text_internal("");
        } else {
            let entry = self.history[self.history_index as usize].clone();
            self.set_text_internal(&entry);
        }
    }

    // -- cursor movement ----------------------------------------------------

    /// Move the caret to the start of the current line. (≈ TS `moveToLineStart`.)
    pub fn move_to_line_start(&mut self) {
        self.last_action = None;
        self.state.cursor_col = 0;
    }

    /// Move the caret to the end of the current line. (≈ TS `moveToLineEnd`.)
    pub fn move_to_line_end(&mut self) {
        self.last_action = None;
        self.state.cursor_col = char_len(self.current_line());
    }

    /// Move the caret to the start of the previous word (whitespace,
    /// punctuation-run, and paste-marker aware). (≈ TS `moveWordBackwards`.)
    pub fn move_word_backwards(&mut self) {
        self.last_action = None;
        let line = self.current_line().to_string();

        if self.state.cursor_col == 0 {
            if self.state.cursor_line > 0 {
                self.state.cursor_line -= 1;
                self.state.cursor_col = char_len(&self.state.lines[self.state.cursor_line]);
            }
            return;
        }

        let before = take_chars(&line, self.state.cursor_col);
        let mut graphemes = self.segment(&before);
        let mut new_col = self.state.cursor_col;

        // Skip trailing whitespace.
        while let Some(seg) = graphemes.last() {
            if !is_paste_marker(&seg.text) && segment_is_ws(&seg.text) {
                new_col -= char_len(&seg.text);
                graphemes.pop();
            } else {
                break;
            }
        }

        if let Some(last) = graphemes.last().cloned() {
            if is_paste_marker(&last.text) {
                new_col -= char_len(&last.text);
                graphemes.pop();
            } else if segment_is_punct(&last.text) {
                while let Some(seg) = graphemes.last() {
                    if segment_is_punct(&seg.text) && !is_paste_marker(&seg.text) {
                        new_col -= char_len(&seg.text);
                        graphemes.pop();
                    } else {
                        break;
                    }
                }
            } else {
                while let Some(seg) = graphemes.last() {
                    if !segment_is_ws(&seg.text)
                        && !segment_is_punct(&seg.text)
                        && !is_paste_marker(&seg.text)
                    {
                        new_col -= char_len(&seg.text);
                        graphemes.pop();
                    } else {
                        break;
                    }
                }
            }
        }
        self.state.cursor_col = new_col;
    }

    /// Move the caret to the end of the next word (mirror of
    /// [`EditorCore::move_word_backwards`]). (≈ TS `moveWordForwards`.)
    pub fn move_word_forwards(&mut self) {
        self.last_action = None;
        let line = self.current_line().to_string();
        let line_len = char_len(&line);

        if self.state.cursor_col >= line_len {
            if self.state.cursor_line < self.state.lines.len() - 1 {
                self.state.cursor_line += 1;
                self.state.cursor_col = 0;
            }
            return;
        }

        let after = drop_chars(&line, self.state.cursor_col);
        let segments = self.segment(&after);
        let mut new_col = self.state.cursor_col;
        let mut pos = 0usize;

        // Skip leading whitespace.
        while pos < segments.len()
            && !is_paste_marker(&segments[pos].text)
            && segment_is_ws(&segments[pos].text)
        {
            new_col += char_len(&segments[pos].text);
            pos += 1;
        }

        if pos < segments.len() {
            let first = &segments[pos].text;
            if is_paste_marker(first) {
                new_col += char_len(first);
            } else if segment_is_punct(first) {
                while pos < segments.len()
                    && segment_is_punct(&segments[pos].text)
                    && !is_paste_marker(&segments[pos].text)
                {
                    new_col += char_len(&segments[pos].text);
                    pos += 1;
                }
            } else {
                while pos < segments.len()
                    && !segment_is_ws(&segments[pos].text)
                    && !segment_is_punct(&segments[pos].text)
                    && !is_paste_marker(&segments[pos].text)
                {
                    new_col += char_len(&segments[pos].text);
                    pos += 1;
                }
            }
        }
        self.state.cursor_col = new_col;
    }
}

// ---------------------------------------------------------------------------
// Trait impls — EditorCore realizes the read / write surfaces of the contract.
// ---------------------------------------------------------------------------

impl EditorReadAccess for EditorCore {
    fn get_text(&self) -> String {
        EditorCore::get_text(self)
    }
    fn get_expanded_text(&self) -> String {
        EditorCore::get_expanded_text(self)
    }
}

impl EditorWriteAccess for EditorCore {
    fn set_text(&mut self, text: &str) {
        EditorCore::set_text(self, text);
    }
    fn insert_text_at_cursor(&mut self, text: &str) {
        EditorCore::insert_text_at_cursor(self, text);
    }
    fn add_to_history(&mut self, text: &str) {
        EditorCore::add_to_history(self, text);
    }
}

// ---------------------------------------------------------------------------
// Tests — mirror the documented TS editor behaviors.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn typed(text: &str) -> EditorCore {
        let mut e = EditorCore::new();
        for ch in text.chars() {
            e.insert_character(&ch.to_string(), false);
        }
        e
    }

    #[test]
    fn new_editor_is_empty() {
        let e = EditorCore::new();
        assert!(e.is_editor_empty());
        assert_eq!(e.get_text(), "");
        assert_eq!(e.get_cursor(), (0, 0));
    }

    #[test]
    fn insert_character_advances_cursor() {
        let e = typed("hi");
        assert_eq!(e.get_text(), "hi");
        assert_eq!(e.get_cursor(), (0, 2));
        assert!(!e.is_editor_empty());
    }

    #[test]
    fn insert_character_at_cursor_middle() {
        let mut e = typed("ac");
        e.state.cursor_col = 1;
        e.insert_character("b", false);
        assert_eq!(e.get_text(), "abc");
        assert_eq!(e.get_cursor(), (0, 2));
    }

    #[test]
    fn set_text_moves_cursor_to_end() {
        let mut e = EditorCore::new();
        e.set_text("hello\nworld");
        assert_eq!(e.get_text(), "hello\nworld");
        assert_eq!(e.get_cursor(), (1, 5));
    }

    #[test]
    fn set_text_normalizes_crlf_and_tabs() {
        let mut e = EditorCore::new();
        e.set_text("a\r\nb\tc");
        assert_eq!(e.get_text(), "a\nb    c");
    }

    #[test]
    fn insert_text_at_cursor_single_line() {
        let mut e = typed("ad");
        e.state.cursor_col = 1;
        e.insert_text_at_cursor("bc");
        assert_eq!(e.get_text(), "abcd");
        assert_eq!(e.get_cursor(), (0, 3));
    }

    #[test]
    fn insert_text_at_cursor_multiline() {
        let mut e = typed("XY");
        e.state.cursor_col = 1;
        e.insert_text_at_cursor("1\n2\n3");
        assert_eq!(e.get_text(), "X1\n2\n3Y");
        assert_eq!(e.get_cursor(), (2, 1));
    }

    #[test]
    fn insert_text_at_cursor_empty_is_noop() {
        let mut e = typed("ab");
        e.insert_text_at_cursor("");
        assert_eq!(e.get_text(), "ab");
    }

    #[test]
    fn add_new_line_splits_at_cursor() {
        let mut e = typed("abcd");
        e.state.cursor_col = 2;
        e.add_new_line();
        assert_eq!(e.get_lines(), vec!["ab".to_string(), "cd".to_string()]);
        assert_eq!(e.get_cursor(), (1, 0));
    }

    #[test]
    fn backspace_deletes_previous_char() {
        let mut e = typed("abc");
        e.handle_backspace();
        assert_eq!(e.get_text(), "ab");
        assert_eq!(e.get_cursor(), (0, 2));
    }

    #[test]
    fn backspace_at_col0_merges_lines() {
        let mut e = EditorCore::new();
        e.set_text("ab\ncd");
        e.state.cursor_line = 1;
        e.state.cursor_col = 0;
        e.handle_backspace();
        assert_eq!(e.get_text(), "abcd");
        assert_eq!(e.get_cursor(), (0, 2));
    }

    #[test]
    fn forward_delete_removes_char_at_cursor() {
        let mut e = typed("abc");
        e.state.cursor_col = 1;
        e.handle_forward_delete();
        assert_eq!(e.get_text(), "ac");
        assert_eq!(e.get_cursor(), (0, 1));
    }

    #[test]
    fn forward_delete_at_eol_merges_next_line() {
        let mut e = EditorCore::new();
        e.set_text("ab\ncd");
        e.state.cursor_line = 0;
        e.state.cursor_col = 2;
        e.handle_forward_delete();
        assert_eq!(e.get_text(), "abcd");
    }

    #[test]
    fn delete_to_start_of_line_kills_prefix() {
        let mut e = typed("hello world");
        e.state.cursor_col = 6;
        e.delete_to_start_of_line();
        assert_eq!(e.get_text(), "world");
        assert_eq!(e.get_cursor(), (0, 0));
    }

    #[test]
    fn delete_to_end_of_line_kills_suffix() {
        let mut e = typed("hello world");
        e.state.cursor_col = 5;
        e.delete_to_end_of_line();
        assert_eq!(e.get_text(), "hello");
    }

    #[test]
    fn move_word_backwards_skips_word_and_space() {
        let mut e = typed("foo bar baz");
        // cursor at end (col 11) → start of "baz" (col 8)
        e.move_word_backwards();
        assert_eq!(e.get_cursor(), (0, 8));
        e.move_word_backwards();
        assert_eq!(e.get_cursor(), (0, 4));
    }

    #[test]
    fn move_word_backwards_at_col0_goes_to_prev_line_end() {
        let mut e = EditorCore::new();
        e.set_text("foo\nbar");
        e.state.cursor_line = 1;
        e.state.cursor_col = 0;
        e.move_word_backwards();
        assert_eq!(e.get_cursor(), (0, 3));
    }

    #[test]
    fn move_word_forwards_skips_to_word_end() {
        let mut e = typed("foo bar baz");
        e.state.cursor_col = 0;
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 3));
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 7));
    }

    #[test]
    fn move_word_forwards_at_eol_goes_to_next_line_start() {
        let mut e = EditorCore::new();
        e.set_text("foo\nbar");
        e.state.cursor_line = 0;
        e.state.cursor_col = 3;
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (1, 0));
    }

    #[test]
    fn move_word_handles_punctuation_runs() {
        let mut e = typed("a..b");
        e.state.cursor_col = 0;
        // word "a"
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 1));
        // punctuation run ".."
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 3));
        // word "b"
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 4));
    }

    #[test]
    fn delete_word_backwards_kills_word() {
        let mut e = typed("foo bar");
        e.delete_word_backwards();
        assert_eq!(e.get_text(), "foo ");
        assert_eq!(e.get_cursor(), (0, 4));
    }

    #[test]
    fn delete_word_forward_kills_word() {
        let mut e = typed("foo bar");
        e.state.cursor_col = 0;
        e.delete_word_forward();
        assert_eq!(e.get_text(), " bar");
        assert_eq!(e.get_cursor(), (0, 0));
    }

    #[test]
    fn move_to_line_start_and_end() {
        let mut e = typed("hello");
        e.move_to_line_start();
        assert_eq!(e.get_cursor(), (0, 0));
        e.move_to_line_end();
        assert_eq!(e.get_cursor(), (0, 5));
    }

    #[test]
    fn kill_then_yank_round_trips() {
        let mut e = typed("hello world");
        e.state.cursor_col = 5;
        e.delete_to_end_of_line(); // kills " world"
        assert_eq!(e.get_text(), "hello");
        e.yank();
        assert_eq!(e.get_text(), "hello world");
        assert_eq!(e.get_cursor(), (0, 11));
    }

    #[test]
    fn yank_with_empty_ring_is_noop() {
        let mut e = typed("abc");
        e.yank();
        assert_eq!(e.get_text(), "abc");
    }

    #[test]
    fn yank_pop_cycles_kill_ring() {
        let mut e = typed("one");
        // kill "one"
        e.delete_to_start_of_line();
        // type and kill "two" as a separate entry (reset accumulation via a
        // non-kill action in between).
        for ch in "two".chars() {
            e.insert_character(&ch.to_string(), false);
        }
        e.delete_to_start_of_line();
        // ring is now ["one", "two"] (two most recent). yank inserts "two".
        e.yank();
        assert_eq!(e.get_text(), "two");
        // yank-pop replaces it with the previous entry "one".
        e.yank_pop();
        assert_eq!(e.get_text(), "one");
    }

    #[test]
    fn consecutive_kills_accumulate() {
        let mut e = typed("hello world");
        e.move_to_line_end();
        e.delete_word_backwards(); // kills "world"
        e.delete_word_backwards(); // kills "hello " — accumulates (prepend)
        // The accumulated entry is "hello world"; yank restores it.
        e.yank();
        assert_eq!(e.get_text(), "hello world");
    }

    #[test]
    fn undo_restores_previous_state() {
        // Fish-style coalescing: whitespace captures the snapshot BEFORE itself,
        // then the following word coalesces with the space into one undo unit (the
        // space sets last_action = type-word, so the next word char does not snap).
        // Undo therefore removes "the space plus the following word together",
        // restoring "a" — matching the TS/Python ground-truth semantics.
        let mut e = EditorCore::new();
        e.insert_character("a", false); // snapshot of ""
        e.insert_character(" ", false); // whitespace: snapshot of "a"
        assert_eq!(e.get_text(), "a ");
        e.insert_character("b", false); // coalesces, no new snapshot
        assert_eq!(e.get_text(), "a b");
        e.undo(); // pops the "a" snapshot -> drops the space + following word
        assert_eq!(e.get_text(), "a");
    }

    #[test]
    fn undo_on_empty_stack_is_noop() {
        let mut e = EditorCore::new();
        e.undo();
        assert_eq!(e.get_text(), "");
    }

    #[test]
    fn submit_value_trims_and_resets() {
        let mut e = typed("  hello  ");
        let result = e.submit_value();
        assert_eq!(result, "hello");
        assert!(e.is_editor_empty());
    }

    #[test]
    fn add_to_history_then_navigate() {
        let mut e = EditorCore::new();
        e.add_to_history("first");
        e.add_to_history("second");
        // Up recalls most recent first.
        e.navigate_history(-1);
        assert_eq!(e.get_text(), "second");
        e.navigate_history(-1);
        assert_eq!(e.get_text(), "first");
        // Down returns toward current (empty).
        e.navigate_history(1);
        assert_eq!(e.get_text(), "second");
        e.navigate_history(1);
        assert_eq!(e.get_text(), "");
    }

    #[test]
    fn add_to_history_skips_blank_and_dupes() {
        let mut e = EditorCore::new();
        e.add_to_history("   ");
        e.add_to_history("x");
        e.add_to_history("x");
        e.navigate_history(-1);
        assert_eq!(e.get_text(), "x");
        // Only one entry: a second up does nothing.
        e.navigate_history(-1);
        assert_eq!(e.get_text(), "x");
    }

    #[test]
    fn has_backslash_before_cursor() {
        let mut e = typed("foo\\");
        assert!(e.has_backslash_before_cursor());
        e.handle_backspace();
        assert!(!e.has_backslash_before_cursor());
    }

    #[test]
    fn large_paste_collapses_to_marker() {
        let mut e = EditorCore::new();
        let big = "x".repeat(1500);
        e.handle_paste(&big);
        let text = e.get_text();
        assert_eq!(text, "[paste #1 1500 chars]");
        assert!(is_paste_marker(&text));
        // Expanded text restores the full content.
        assert_eq!(e.get_expanded_text(), big);
    }

    #[test]
    fn many_line_paste_collapses_to_lines_marker() {
        let mut e = EditorCore::new();
        let big: String = (0..15)
            .map(|i| format!("line{i}"))
            .collect::<Vec<_>>()
            .join("\n");
        e.handle_paste(&big);
        assert_eq!(e.get_text(), "[paste #1 +15 lines]");
        assert_eq!(e.get_expanded_text(), big);
    }

    #[test]
    fn small_paste_inserts_literally() {
        let mut e = EditorCore::new();
        e.handle_paste("short");
        assert_eq!(e.get_text(), "short");
    }

    #[test]
    fn paste_marker_is_atomic_for_word_motion() {
        let mut e = EditorCore::new();
        let big = "y".repeat(1200);
        e.handle_paste(&big); // inserts "[paste #1 1200 chars]"
        let marker_len = char_len(&e.get_text());
        // cursor sits at end of the marker; one word-back step hops the whole marker.
        e.move_word_backwards();
        assert_eq!(e.get_cursor(), (0, 0));
        // forward again returns to the marker end.
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, marker_len));
    }

    #[test]
    fn backspace_deletes_whole_paste_marker() {
        let mut e = EditorCore::new();
        let big = "z".repeat(1100);
        e.handle_paste(&big);
        assert!(!e.is_editor_empty());
        e.handle_backspace();
        assert!(e.is_editor_empty());
    }

    #[test]
    fn is_paste_marker_recognizes_forms() {
        assert!(is_paste_marker("[paste #1]"));
        assert!(is_paste_marker("[paste #2 +12 lines]"));
        assert!(is_paste_marker("[paste #3 345 chars]"));
        assert!(!is_paste_marker("[paste #]"));
        assert!(!is_paste_marker("not a marker"));
        assert!(!is_paste_marker("[paste #1 foo]"));
    }

    #[test]
    fn on_change_fires_with_updated_text() {
        use std::sync::{Arc, Mutex};
        let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
        let mut e = EditorCore::new();
        let l = log.clone();
        e.on_change(Box::new(move |t| l.lock().unwrap().push(t.to_string())));
        e.insert_character("a", false);
        e.insert_character("b", false);
        let captured = log.lock().unwrap().clone();
        assert_eq!(captured, vec!["a".to_string(), "ab".to_string()]);
    }

    #[test]
    fn on_submit_receives_committed_text() {
        use std::sync::{Arc, Mutex};
        let got: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
        let mut e = typed("hi there");
        let g = got.clone();
        e.on_submit(Box::new(move |t| *g.lock().unwrap() = Some(t.to_string())));
        e.submit_value();
        assert_eq!(got.lock().unwrap().clone(), Some("hi there".to_string()));
    }

    #[test]
    fn trait_surfaces_delegate_to_core() {
        let mut e = EditorCore::new();
        EditorWriteAccess::set_text(&mut e, "hello");
        assert_eq!(EditorReadAccess::get_text(&e), "hello");
        EditorWriteAccess::insert_text_at_cursor(&mut e, "!");
        assert_eq!(EditorReadAccess::get_text(&e), "hello!");
    }

    #[test]
    fn unicode_chars_counted_as_scalars() {
        let mut e = typed("héllo");
        assert_eq!(e.get_cursor(), (0, 5));
        e.handle_backspace();
        assert_eq!(e.get_text(), "héll");
        e.move_to_line_start();
        e.move_word_forwards();
        assert_eq!(e.get_cursor(), (0, 4));
    }
}
