//! Core text buffer backed by ropey.
//!
//! This buffer provides:
//! - Efficient text storage via ropey
//! - Position-based cursor and selection
//! - Basic undo/redo via snapshots
//! - Async-safe snapshots

use std::{
    fs,
    ops::Range,
    path::{Path, PathBuf},
};

use std::sync::Arc;

use ropey::{LineType, Rope};
use serde::{Deserialize, Serialize};

use crate::editor::history::{apply_changeset, BufferOp, ChangeKind, ChangeSet, History};
use crate::editor::position::Position;
use crate::editor::selection::Selection;
use crate::prelude::*;
use crate::settings::adapters;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BufferId(u64);

impl BufferId {
    pub fn new() -> Self {
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(1);
        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
    }
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
pub struct Version(u64);

impl Version {
    pub fn new() -> Self {
        Self(0)
    }

    pub fn value(&self) -> u64 {
        self.0
    }

    /// Create a version with a specific raw counter value. Used in tests.
    #[cfg(test)]
    pub(crate) fn from_raw(v: u64) -> Self {
        Self(v)
    }

    fn increment(&mut self) {
        self.0 += 1;
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableSnapshot {
    lines: Vec<String>,
    cursor: Position,
    #[serde(default)]
    selection: Option<Selection>,
    dirty: bool,
    version: Version,
}

impl From<&Buffer> for SerializableSnapshot {
    fn from(buf: &Buffer) -> Self {
        let lines = buf.lines();
        SerializableSnapshot {
            lines,
            cursor: buf.cursor,
            selection: buf.selection,
            dirty: buf.dirty,
            version: buf.version,
        }
    }
}

impl SerializableSnapshot {
    pub fn lines(&self) -> &[String] {
        &self.lines
    }

    pub fn cursor(&self) -> Position {
        self.cursor
    }

    pub fn selection(&self) -> Option<Selection> {
        self.selection
    }

    pub fn dirty(&self) -> bool {
        self.dirty
    }

    pub fn version(&self) -> Version {
        self.version
    }
}

#[derive(Debug, Clone)]
pub struct BufferSnapshot {
    pub text: Rope,
    pub version: Version,
}

impl BufferSnapshot {
    pub fn lines(&self) -> Vec<String> {
        let line_type = LineType::LF;
        (0..self.text.len_lines(line_type))
            .map(|i| {
                let mut line_text = self.text.line(i, line_type).to_string();
                if line_text.ends_with('\n') {
                    line_text.pop();
                }
                line_text
            })
            .collect()
    }

    pub fn line_count(&self) -> usize {
        self.text.len_lines(LineType::LF)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Marker {
    pub line: usize,
    pub label: String,
}

/// Accumulates `BufferOp`s during an open transaction so they can be
/// committed to history as a single undo step via [`Buffer::end_transaction`].
#[derive(Debug)]
struct Transaction {
    ops: Vec<BufferOp>,
    kind: ChangeKind,
    before_cursor: Position,
    before_selection: Option<Selection>,
}

#[derive(Debug)]
pub struct Buffer {
    id: BufferId,
    version: Version,
    text: Rope,
    pub path: Option<PathBuf>,
    cursor: Position,
    selection: Option<Selection>,
    pub scroll: usize,
    pub scroll_x: usize,
    dirty: bool,
    history: History,
    pub markers: Vec<Marker>,
    /// When `Some`, all calls to [`apply_change`] buffer their ops here
    /// instead of touching history directly.
    active_tx: Option<Transaction>,
    /// Hash of the file content at last save (for detecting external modifications).
    pub file_hash: Option<u64>,
    /// Modification time of the file at last save (for quick pre-check).
    pub file_mtime: Option<u64>,
    /// Size of the file at last save (for quick pre-check).
    pub file_size: Option<u64>,
    /// Set to true when an external modification is detected.
    pub external_modification_detected: bool,
    /// Set to true during save to ignore file watcher events.
    pub is_saving: bool,
    /// Cached shared lines for cheap cloning across highlight requests.
    cached_lines: Option<Arc<Vec<String>>>,
}

impl Buffer {
    pub fn new() -> Self {
        Self {
            id: BufferId::new(),
            version: Version::new(),
            text: Rope::new(),
            path: None,
            cursor: Position::default(),
            selection: None,
            scroll: 0,
            scroll_x: 0,
            dirty: false,
            history: History::new(),
            markers: Vec::new(),
            active_tx: None,
            file_hash: None,
            file_mtime: None,
            file_size: None,
            external_modification_detected: false,
            is_saving: false,
            cached_lines: None,
        }
    }

    pub fn from_text(text: &str) -> Self {
        let mut buf = Self::new();
        buf.text = Rope::from_str(text);
        let line_count = buf.text.len_lines(LineType::LF);
        if line_count > 0 {
            let last_line_idx = line_count - 1;
            let last_line_len = buf.text.line(last_line_idx, LineType::LF).len_chars();
            buf.cursor = Position::new(last_line_idx, last_line_len);
        }
        buf
    }

    /// Test-only constructor that disables history merging so every edit
    /// becomes its own undo step, regardless of how quickly it happens.
    #[cfg(test)]
    pub(crate) fn from_text_no_merge(text: &str) -> Self {
        let mut buf = Self::from_text(text);
        buf.history = History::with_threshold(0);
        buf
    }

    pub fn from_lines(lines: Vec<String>, path: Option<PathBuf>) -> Self {
        let text = if lines.is_empty() {
            String::new()
        } else {
            lines.join("\n")
        };
        let mut buf = Self::new();
        buf.text = Rope::from_str(&text);
        buf.path = path;
        buf.cursor = Position::new(0, 0);
        buf
    }

    pub fn open(path: &Path) -> Result<Self> {
        let content = fs::read_to_string(path)?;
        let mut buf = Self::new();
        buf.text = Rope::from_str(&content);
        buf.path = Some(path.to_path_buf());
        buf.normalize_cursor();
        Ok(buf)
    }

    #[allow(clippy::too_many_arguments)]
    pub fn restore(
        path: PathBuf,
        lines: Vec<String>,
        selection: Option<Selection>,
        scroll: usize,
        dirty: bool,
        markers: Vec<Marker>,
        undo_stack: Vec<SerializableSnapshot>,
        redo_stack: Vec<SerializableSnapshot>,
    ) -> Self {
        let text = if lines.is_empty() {
            Rope::new()
        } else {
            Rope::from_str(&lines.join("\n"))
        };
        let cursor = selection.as_ref().map(|s| s.active).unwrap_or_default();
        let version = Version::new();
        let mut history = History::new();
        history.restore_from_snapshots(undo_stack, redo_stack, version, lines.clone());
        let file_hash = Self::compute_hash(&text);
        let file_metadata = Self::get_file_metadata(&path);
        let file_mtime = file_metadata.as_ref().map(|(m, _)| *m);
        let file_size = file_metadata.map(|(_, s)| s);
        let mut buf = Self {
            id: BufferId::new(),
            version,
            text,
            path: Some(path),
            cursor,
            selection,
            scroll,
            scroll_x: 0,
            dirty,
            history,
            markers,
            active_tx: None,
            file_hash,
            file_mtime,
            file_size,
            external_modification_detected: false,
            is_saving: false,
            cached_lines: None,
        };
        // Clamp cursor and selection to the actual content. Necessary when a
        // non-dirty file is restored from saved state but the on-disk content
        // has changed (fewer lines) since the state was last written.
        let sel = buf.selection.take();
        if let Some(s) = sel {
            buf.set_selection(Some(Selection {
                anchor: buf.normalize_position(s.anchor),
                active: buf.normalize_position(s.active),
            }));
        } else {
            buf.normalize_cursor();
        }
        buf
    }

    pub fn id(&self) -> BufferId {
        self.id
    }

    pub fn version(&self) -> Version {
        self.version
    }

    pub fn snapshot(&self) -> BufferSnapshot {
        BufferSnapshot {
            text: self.text.clone(),
            version: self.version,
        }
    }

    fn line_type(&self) -> LineType {
        LineType::LF
    }

    fn pos_to_byte(&self, pos: Position) -> usize {
        let line_count = self.text.len_lines(self.line_type());
        if pos.line >= line_count {
            if pos.line == line_count && pos.column == 0 && line_count > 0 {
                let last_line_idx = line_count - 1;
                let last_line = self.text.line(last_line_idx, self.line_type());
                if last_line.chars().last() == Some('\n') {
                    let last_line_start =
                        self.text.line_to_byte_idx(last_line_idx, self.line_type());
                    return last_line_start + last_line.len() - 1;
                }
            }
            return self.text.len();
        }
        let line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
        let line = self.text.line(pos.line, self.line_type());

        let mut char_count = 0;
        let mut byte_offset = 0;
        for ch in line.chars() {
            if char_count >= pos.column {
                break;
            }
            byte_offset += ch.len_utf8();
            char_count += 1;
        }
        if pos.column > char_count {
            byte_offset = line.len();
        }
        line_start_byte + byte_offset
    }

    fn byte_to_pos(&self, byte_idx: usize) -> Position {
        let clamped_byte_idx = byte_idx.min(self.text.len());
        let line_count = self.text.len_lines(self.line_type());

        if clamped_byte_idx == self.text.len() {
            if line_count == 0 {
                return Position::new(0, 0);
            }
            let last_line_idx = line_count - 1;
            let last_line = self.text.line(last_line_idx, self.line_type());
            let last_line_len = last_line.len_chars();
            let has_trailing_nl = last_line.chars().last() == Some('\n');
            if has_trailing_nl {
                return Position::new(last_line_idx + 1, 0);
            }
            return Position::new(last_line_idx, last_line_len);
        }

        let byte_idx = clamped_byte_idx;
        let line_idx = self.text.byte_to_line_idx(byte_idx, self.line_type());
        let line_start_byte = self.text.line_to_byte_idx(line_idx, self.line_type());
        let char_idx = self.text.byte_to_char_idx(byte_idx);
        let line_start_char = self.text.byte_to_char_idx(line_start_byte);
        let line = self.text.line(line_idx, self.line_type());
        let line_len_chars = line.len_chars();
        let line_has_trailing_nl = line.chars().last() == Some('\n');

        let column = char_idx - line_start_char;
        if column >= line_len_chars && line_has_trailing_nl {
            return Position::new(line_idx + 1, 0);
        }
        Position::new(line_idx, column)
    }

    fn cursor_to_byte(&self) -> usize {
        self.pos_to_byte(self.cursor)
    }

    /// Convert a `Position` to a global char index using ropey's char metric.
    pub fn pos_to_char(&self, pos: Position) -> usize {
        let line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
        let line_start_char = self.text.byte_to_char_idx(line_start_byte);
        line_start_char + pos.column
    }

    /// Convert a global char index to a `Position`.
    pub fn char_to_pos(&self, char_idx: usize) -> Position {
        let byte_idx = self.text.char_to_byte_idx(char_idx).min(self.text.len());
        self.byte_to_pos(byte_idx)
    }

    pub fn cursor(&self) -> Position {
        self.cursor
    }

    pub fn selection(&self) -> Option<Selection> {
        self.selection
    }

    pub fn set_cursor(&mut self, pos: Position) {
        self.selection = None;
        self.cursor = self.normalize_position(pos);
    }

    pub fn set_selection(&mut self, selection: Option<Selection>) {
        self.selection = selection.map(|sel| Selection {
            anchor: self.normalize_position(sel.anchor),
            active: self.normalize_position(sel.active),
        });
        if let Some(ref sel) = self.selection {
            self.cursor = sel.active;
        }
    }

    /// Clamp `pos` to a valid (line, column) within the current text.
    pub fn normalize_position(&self, pos: Position) -> Position {
        let line_count = self.text.len_lines(self.line_type());
        if line_count == 0 {
            return Position::new(0, 0);
        }
        let line = pos.line.min(line_count - 1);
        let line_len = self.line_display_len(line);
        Position::new(line, pos.column.min(line_len))
    }

    pub fn normalize_cursor(&mut self) {
        self.cursor = self.normalize_position(self.cursor);
    }

    /// Return the usable character length of a line, excluding the trailing newline.
    fn line_display_len(&self, line_idx: usize) -> usize {
        let line = self.text.line(line_idx, self.line_type());
        let has_trailing_nl = line.chars().last() == Some('\n');
        line.len_chars().saturating_sub(has_trailing_nl as usize)
    }

    pub fn line_count(&self) -> usize {
        self.text.len_lines(self.line_type()).max(1)
    }

    pub fn current_line(&self) -> usize {
        self.cursor.line
    }

    pub fn char_count(&self) -> usize {
        self.text.len_chars()
    }

    pub fn line(&self, line_idx: usize) -> Option<String> {
        let actual_line_count = self.text.len_lines(self.line_type());
        if line_idx >= actual_line_count {
            return None;
        }
        let mut line_text = self.text.line(line_idx, self.line_type()).to_string();
        if line_text.ends_with('\n') {
            line_text.pop();
        }
        if line_text.ends_with('\r') {
            line_text.pop();
        }
        Some(line_text)
    }

    pub fn lines(&self) -> Vec<String> {
        (0..self.text.len_lines(self.line_type()))
            .map(|i| {
                let mut s: String = self.text.line(i, self.line_type()).into();
                if s.ends_with('\n') {
                    s.pop();
                }
                if s.ends_with('\r') {
                    s.pop();
                }
                s
            })
            .collect()
    }

    /// Return a shared `Arc<Vec<String>>` for the current buffer contents.
    /// The result is cached and invalidated on mutations to avoid rebuilding
    /// the Vec for every highlight request.
    pub fn lines_arc(&mut self) -> Arc<Vec<String>> {
        if let Some(ref cached) = self.cached_lines {
            return Arc::clone(cached);
        }
        let v = self.lines();
        let arc = Arc::new(v);
        self.cached_lines = Some(arc.clone());
        arc
    }

    /// Consume the buffer and return its lines, preferring a cached Arc<Vec<String>>
    /// to avoid cloning when possible. This is useful when stashing buffers during
    /// screen switches where the buffer can be moved without extra allocations.
    pub fn into_lines(self) -> Vec<String> {
        if let Some(arc) = self.cached_lines {
            match Arc::try_unwrap(arc) {
                Ok(v) => v,
                Err(arc) => (*arc).clone(),
            }
        } else {
            self.lines()
        }
    }

    pub fn is_empty(&self) -> bool {
        self.text.len_chars() == 0
    }

    pub fn is_dirty(&self) -> bool {
        self.dirty
    }

    fn increment_version(&mut self) {
        self.version.increment();
        self.dirty = true;
        self.file_hash = None;
        self.file_mtime = None;
        self.file_size = None;
        self.cached_lines = None;
    }

    pub fn can_undo(&self) -> bool {
        self.history.can_undo()
    }

    pub fn can_redo(&self) -> bool {
        self.history.can_redo()
    }

    pub fn history_len(&self) -> usize {
        self.history.len()
    }

    pub fn history_position(&self) -> usize {
        self.history.current_position()
    }

    pub fn apply_change(&mut self, change: ChangeSet, kind: ChangeKind) {
        if change.is_empty() {
            return;
        }

        // Inside a transaction: buffer the raw ops and return; history is
        // not touched until end_transaction() commits everything at once.
        if let Some(tx) = &mut self.active_tx {
            tx.ops.extend(change.ops);
            return;
        }

        // Coalesce adjacent typing/deletion into a single undo step when
        // the edits are contiguous, within the merge window, and don't
        // cross line boundaries.  All other kinds (Replace, Structural, …)
        // always get their own entry so they can be undone atomically.
        let merged = matches!(kind, ChangeKind::InsertText | ChangeKind::DeleteText)
            && self.history.try_merge(change.clone(), kind, self.version);

        if !merged {
            self.history.push(change, kind, self.version);
        }
    }

    /// Begin an explicit transaction.  All subsequent calls to
    /// [`Buffer::apply_change`] will buffer their ops instead of writing to history.
    /// Call [`Buffer::end_transaction`] to commit everything as one undo step.
    ///
    /// If a transaction is already active this is a no-op (the outermost
    /// transaction wins).  In debug builds a panic is emitted to surface
    /// accidental nesting early.
    pub fn begin_transaction(&mut self, kind: ChangeKind) {
        debug_assert!(
            self.active_tx.is_none(),
            "begin_transaction called while a transaction is already active; \
             nested transactions are not supported"
        );
        if self.active_tx.is_some() {
            return;
        }
        self.active_tx = Some(Transaction {
            ops: Vec::new(),
            kind,
            before_cursor: self.cursor,
            before_selection: self.selection,
        });
    }

    /// Commit the current transaction to history as a **single undo step**.
    ///
    /// * The committed entry always gets its own history slot — `try_merge`
    ///   is bypassed, so the transaction is never accidentally coalesced with
    ///   surrounding typing.
    /// * `history.push` truncates any future (redo) entries, so a
    ///   transaction after undo correctly discards the forward branch.
    /// * `increment_version` is called on commit so the buffer version
    ///   advances once for the whole group (consistent with LSP sync).
    ///
    /// If no transaction is active, or if no ops were recorded, this is a
    /// no-op.
    pub fn end_transaction(&mut self) {
        let tx = match self.active_tx.take() {
            Some(tx) => tx,
            None => return,
        };
        if tx.ops.is_empty() {
            return;
        }
        let change = ChangeSet::new(
            tx.ops,
            tx.before_cursor,
            self.cursor,
            tx.before_selection,
            self.selection,
        );
        // Always push; the transaction *is* the merge unit.
        // push() also truncates the redo stack, so branching history is safe.
        self.history.push(change, tx.kind, self.version);
        // Advance the version once for the committed group so that external
        // consumers (LSP, syntax highlighter) see exactly one change event.
        self.increment_version();
    }

    /// Discard the current transaction **without** recording a history entry.
    ///
    /// ⚠️  Text mutations that occurred within the transaction are **not**
    /// rolled back — only the pending history entry is dropped.  The buffer
    /// will be in a modified-but-untracked state after this call.  Use this
    /// only when you intend to handle the rollback yourself (e.g. by
    /// reloading from disk), or when you are certain no edits were made.
    pub fn abort_transaction(&mut self) {
        self.active_tx = None;
    }

    /// Returns `true` if a transaction is currently open.
    pub fn in_transaction(&self) -> bool {
        self.active_tx.is_some()
    }

    pub fn insert(&mut self, text: &str) {
        // If there's a selection, replace it atomically as a single undo step.
        if let Some(sel) = self.selection {
            let start = sel.min();
            let end = sel.max();
            let before_cursor = self.cursor;
            let before_selection = self.selection;

            let start_byte = self.pos_to_byte(start);
            let end_byte = self.pos_to_byte(end).min(self.text.len());
            let old_text: String = self.text.slice(start_byte..end_byte).into();

            self.text.remove(start_byte..end_byte);
            self.text.insert(start_byte, text);

            let new_char_idx = self.text.byte_to_char_idx(start_byte) + text.chars().count();
            let after_cursor = self.byte_to_pos(
                self.text
                    .char_to_byte_idx(new_char_idx)
                    .min(self.text.len()),
            );
            self.cursor = after_cursor;
            self.selection = None;

            let op = BufferOp::Replace {
                range: start..end,
                text: text.to_string(),
                old_text,
                end_position: after_cursor,
            };
            let change = ChangeSet::new(
                vec![op],
                before_cursor,
                after_cursor,
                before_selection,
                None,
            );
            self.apply_change(change, ChangeKind::Replace);
            self.increment_version();
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let byte_idx = self.cursor_to_byte();
        let old_char_idx = self.text.byte_to_char_idx(byte_idx);
        self.text.insert(byte_idx, text);

        let new_char_idx = old_char_idx + text.chars().count();
        let new_char_byte = self
            .text
            .char_to_byte_idx(new_char_idx)
            .min(self.text.len());
        let new_cursor = self.byte_to_pos(new_char_byte);
        self.cursor = new_cursor;

        let after_cursor = new_cursor;
        let after_selection = None;

        let range = before_cursor..after_cursor;
        let op = BufferOp::Replace {
            range,
            text: text.to_string(),
            old_text: String::new(),
            end_position: after_cursor,
        };

        let change = ChangeSet::new(
            vec![op],
            before_cursor,
            after_cursor,
            before_selection,
            after_selection,
        );

        self.apply_change(change, ChangeKind::InsertText);
        self.increment_version();
    }

    /// Delete the current selection if one exists. Returns true if a selection was deleted.
    fn delete_selection_if_active(&mut self) -> bool {
        if let Some(sel) = self.selection {
            self.delete_range(sel.min(), sel.max());
            true
        } else {
            false
        }
    }

    pub fn delete_backward(&mut self) {
        if self.delete_selection_if_active() {
            return;
        }

        let byte_idx = self.cursor_to_byte();
        if byte_idx == 0 {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let char_idx = self.text.byte_to_char_idx(byte_idx);
        if char_idx == 0 {
            return;
        }
        let prev_char_idx = char_idx - 1;
        let del_start_byte = self.text.char_to_byte_idx(prev_char_idx);
        let del_end_byte = byte_idx;
        let prev_char_byte_idx = del_start_byte;
        let prev_char = self.text.char(prev_char_byte_idx);

        let new_cursor_pos: Position;
        if prev_char == '\n' {
            let line_idx = self.text.byte_to_line_idx(byte_idx, self.line_type());
            if line_idx > 0 {
                let new_cursor_col = self.line_display_len(line_idx - 1);
                new_cursor_pos = Position::new(line_idx - 1, new_cursor_col);
            } else {
                new_cursor_pos = before_cursor;
            }
        } else {
            let new_col = before_cursor.column.saturating_sub(1);
            new_cursor_pos = Position::new(before_cursor.line, new_col);
        }

        let deleted_text: String = self.text.slice(del_start_byte..del_end_byte).into();

        self.text.remove(del_start_byte..del_end_byte);

        self.cursor = new_cursor_pos;
        self.selection = None;

        let range = new_cursor_pos..before_cursor;
        let op = BufferOp::Replace {
            range,
            text: String::new(),
            old_text: deleted_text,
            end_position: new_cursor_pos,
        };

        let change = ChangeSet::new(
            vec![op],
            before_cursor,
            new_cursor_pos,
            before_selection,
            None,
        );

        self.apply_change(change, ChangeKind::DeleteText);
        self.increment_version();
    }

    pub fn delete_forward(&mut self) {
        if self.delete_selection_if_active() {
            return;
        }

        let byte_idx = self.cursor_to_byte();
        if byte_idx >= self.text.len() {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        // Advance by exactly one char using char indices (correct for multi-byte UTF-8).
        let char_idx = self.text.byte_to_char_idx(byte_idx);
        let next_byte = self
            .text
            .char_to_byte_idx(char_idx + 1)
            .min(self.text.len());

        let deleted_text: String = self.text.slice(byte_idx..next_byte).into();
        self.text.remove(byte_idx..next_byte);

        let after_cursor = before_cursor;
        let after_selection = None;

        let range = before_cursor..after_cursor;
        let op = BufferOp::Replace {
            range,
            text: String::new(),
            old_text: deleted_text,
            end_position: before_cursor,
        };

        let change = ChangeSet::new(
            vec![op],
            before_cursor,
            after_cursor,
            before_selection,
            after_selection,
        );

        self.apply_change(change, ChangeKind::DeleteText);
        self.increment_version();
    }

    pub fn delete_range(&mut self, start_pos: Position, end_pos: Position) {
        if start_pos == end_pos {
            return;
        }

        let start_byte = self.pos_to_byte(start_pos);
        let end_byte = self.pos_to_byte(end_pos).min(self.text.len());

        if start_byte >= self.text.len() || start_byte >= end_byte {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let deleted_text: String = self.text.slice(start_byte..end_byte).into();
        self.text.remove(start_byte..end_byte);

        self.cursor = start_pos;
        self.selection = None;

        let range = start_pos..end_pos;
        let op = BufferOp::Replace {
            range,
            text: String::new(),
            old_text: deleted_text,
            end_position: start_pos,
        };

        let change = ChangeSet::new(vec![op], before_cursor, start_pos, before_selection, None);

        self.apply_change(change, ChangeKind::DeleteText);
        self.increment_version();
    }

    pub fn replace_range(&mut self, start: Position, end: Position, text: &str) {
        let start_byte = self.pos_to_byte(start);
        let end_byte = self.pos_to_byte(end).min(self.text.len());

        if start_byte >= self.text.len() && start != end {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let actual_end_byte = end_byte.min(self.text.len());
        let old_text: String = self.text.slice(start_byte..actual_end_byte).into();

        if old_text == text && before_cursor == start {
            return;
        }

        self.text.remove(start_byte..actual_end_byte);
        self.text.insert(start_byte, text);

        let new_char_idx = self.text.byte_to_char_idx(start_byte) + text.chars().count();
        let after_cursor = self.byte_to_pos(self.text.char_to_byte_idx(new_char_idx));
        self.cursor = after_cursor;
        self.selection = None;

        let range = start..end;
        let op = BufferOp::Replace {
            range,
            text: text.to_string(),
            old_text,
            end_position: after_cursor,
        };

        let change = ChangeSet::new(
            vec![op],
            before_cursor,
            after_cursor,
            before_selection,
            None,
        );

        self.apply_change(change, ChangeKind::Replace);
        self.increment_version();
    }

    pub fn insert_newline(&mut self) {
        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let byte_idx = self.cursor_to_byte();
        self.text.insert(byte_idx, "\n");

        let new_char_idx = self.text.byte_to_char_idx(byte_idx) + 1;
        let new_cursor_byte = self
            .text
            .char_to_byte_idx(new_char_idx)
            .min(self.text.len());
        let new_cursor = self.byte_to_pos(new_cursor_byte);
        self.cursor = new_cursor;

        let range = before_cursor..new_cursor;
        let op = BufferOp::Replace {
            range,
            text: "\n".to_string(),
            old_text: String::new(),
            end_position: new_cursor,
        };

        let change = ChangeSet::new(vec![op], before_cursor, new_cursor, before_selection, None);

        self.apply_change(change, ChangeKind::Structural);
        self.increment_version();
    }

    pub fn insert_newline_with_indent(&mut self, _use_spaces: bool, _indent_width: usize) {
        // Extract leading whitespace from the current line
        // This ensures we carry forward the same indentation for the new line
        let leading_indent = if self.cursor.line < self.text.len_lines(self.line_type()) {
            let line = self.text.line(self.cursor.line, self.line_type());
            line.chars()
                .take_while(|c| *c == ' ' || *c == '\t')
                .collect::<String>()
        } else {
            String::new()
        };

        // Always use the leading indentation from the current line (even if empty)
        // This matches the user's existing indentation pattern
        let text = format!("\n{}", leading_indent);

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let byte_idx = self.cursor_to_byte();
        self.text.insert(byte_idx, &text);

        let new_char_idx = self.text.byte_to_char_idx(byte_idx) + text.chars().count();
        let new_cursor = self.byte_to_pos(self.text.char_to_byte_idx(new_char_idx));
        self.cursor = new_cursor;

        let range = before_cursor..new_cursor;
        let op = BufferOp::Replace {
            range,
            text,
            old_text: String::new(),
            end_position: new_cursor,
        };

        let change = ChangeSet::new(vec![op], before_cursor, new_cursor, before_selection, None);

        self.apply_change(change, ChangeKind::Structural);
        self.increment_version();
    }

    pub fn delete_word_backward(&mut self) {
        let end_byte = self.cursor_to_byte();
        if end_byte == 0 {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        // Use the same token logic as `word_boundary_prev` so deletion mirrors movement.
        let start_pos = self.word_boundary_prev(self.cursor);
        let start_byte = self.pos_to_byte(start_pos);

        if start_byte < end_byte {
            let deleted_text: String = self.text.slice(start_byte..end_byte).into();
            self.text.remove(start_byte..end_byte);
            let new_pos = self.byte_to_pos(start_byte);
            self.cursor = new_pos;

            let range = new_pos..before_cursor;
            let op = BufferOp::Replace {
                range,
                text: String::new(),
                old_text: deleted_text,
                end_position: new_pos,
            };

            let change = ChangeSet::new(vec![op], before_cursor, new_pos, before_selection, None);

            self.apply_change(change, ChangeKind::DeleteText);
            self.increment_version();
        }
    }

    pub fn delete_word_forward(&mut self) {
        let start = self.cursor_to_byte();
        if start >= self.text.len() {
            return;
        }

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let mut char_idx = self.text.byte_to_char_idx(start);
        let end_char = self.text.len_chars();

        let first_char = self.text.char(self.text.char_to_byte_idx(char_idx));
        let at_whitespace = first_char.is_whitespace();

        while char_idx < end_char
            && self
                .text
                .char(self.text.char_to_byte_idx(char_idx))
                .is_whitespace()
        {
            char_idx += 1;
        }

        if !at_whitespace {
            while char_idx < end_char {
                let curr_byte = self.text.char_to_byte_idx(char_idx);
                if self.text.char(curr_byte).is_whitespace() {
                    break;
                }
                char_idx += 1;
            }

            while char_idx < end_char
                && self
                    .text
                    .char(self.text.char_to_byte_idx(char_idx))
                    .is_whitespace()
            {
                char_idx += 1;
            }
        }

        let end_byte = if char_idx < end_char {
            self.text.char_to_byte_idx(char_idx)
        } else {
            self.text.len()
        };
        let start_byte = self
            .text
            .char_to_byte_idx(self.text.byte_to_char_idx(start));

        if start_byte < end_byte {
            let deleted_text: String = self.text.slice(start_byte..end_byte).into();
            self.text.remove(start_byte..end_byte);

            let range = before_cursor..before_cursor;
            let op = BufferOp::Replace {
                range,
                text: String::new(),
                old_text: deleted_text,
                end_position: before_cursor,
            };

            let change = ChangeSet::new(
                vec![op],
                before_cursor,
                before_cursor,
                before_selection,
                None,
            );

            self.apply_change(change, ChangeKind::DeleteText);
            self.increment_version();
        }
    }

    pub fn word_range_at(&self, pos: Position) -> (usize, usize) {
        let byte_idx = self.pos_to_byte(pos);
        let line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
        let line = self.text.line(pos.line, self.line_type());

        let mut word_start = 0;
        let mut word_end = line.len_chars();
        let mut in_word = false;

        for (i, (byte_offset, ch)) in line.char_indices().enumerate() {
            let abs_byte = line_start_byte + byte_offset;
            if abs_byte >= byte_idx && ch.is_alphanumeric() && !in_word {
                word_start = i;
                in_word = true;
            }
            if in_word && !ch.is_alphanumeric() {
                word_end = i;
                break;
            }
        }

        (word_start, word_end)
    }

    pub fn insert_at(&mut self, pos: Position, text: &str) {
        let before_cursor = self.cursor;
        let before_selection = self.selection;

        let byte_idx = self.pos_to_byte(pos);
        self.text.insert(byte_idx, text);

        let new_char_idx = self.text.byte_to_char_idx(byte_idx) + text.chars().count();
        let after_cursor = self.byte_to_pos(self.text.char_to_byte_idx(new_char_idx));
        if pos == before_cursor {
            self.cursor = after_cursor;
        }

        let range = pos..after_cursor;
        let op = BufferOp::Replace {
            range,
            text: text.to_string(),
            old_text: String::new(),
            end_position: after_cursor,
        };

        let change = ChangeSet::new(
            vec![op],
            before_cursor,
            after_cursor,
            before_selection,
            None,
        );

        self.apply_change(change, ChangeKind::InsertText);
        self.increment_version();
    }

    pub fn insert_text_raw(&mut self, text: &str) {
        let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
        self.insert(&normalized);
    }

    pub fn delete_line(&mut self) -> String {
        let line_idx = self.cursor.line;
        if line_idx >= self.text.len_lines(self.line_type()) {
            return String::new();
        }

        let line_start = self.text.line_to_byte_idx(line_idx, self.line_type());
        let line_end = if line_idx + 1 < self.text.len_lines(self.line_type()) {
            self.text.line_to_byte_idx(line_idx + 1, self.line_type())
        } else {
            self.text.len()
        };

        let deleted: String = self.text.slice(line_start..line_end).into();

        let before_cursor = self.cursor;
        let before_selection = self.selection;

        self.text.remove(line_start..line_end);

        if self.text.len_lines(self.line_type()) == 0 {
            self.text.insert(0, "");
        }

        let new_line_idx = line_idx.min(self.text.len_lines(self.line_type()).saturating_sub(1));
        let new_pos = Position::new(new_line_idx, 0);
        self.cursor = new_pos;
        self.normalize_cursor();

        let range = new_pos..before_cursor;
        let op = BufferOp::Replace {
            range,
            text: String::new(),
            old_text: deleted.clone(),
            end_position: new_pos,
        };

        let change = ChangeSet::new(vec![op], before_cursor, new_pos, before_selection, None);

        self.apply_change(change, ChangeKind::DeleteText);
        self.increment_version();

        deleted
    }

    pub fn selected_text_all(&self) -> String {
        self.selected_text()
    }

    pub fn cursor_left(&mut self) -> Position {
        let pos = self.offset_left(self.cursor);
        self.cursor = pos;
        self.cursor
    }

    pub fn offset_left(&self, pos: Position) -> Position {
        if pos.column > 0 {
            Position::new(pos.line, pos.column - 1)
        } else if pos.line > 0 {
            let prev_line = pos.line - 1;
            let prev_line_len = self.line_display_len(prev_line);
            Position::new(prev_line, prev_line_len)
        } else {
            pos
        }
    }

    pub fn cursor_right(&mut self) -> Position {
        let pos = self.offset_right(self.cursor);
        self.cursor = pos;
        self.cursor
    }

    pub fn offset_right(&self, pos: Position) -> Position {
        let line_len = self.line_display_len(pos.line);
        if pos.column < line_len {
            Position::new(pos.line, pos.column + 1)
        } else if pos.line < self.text.len_lines(self.line_type()) - 1 {
            Position::new(pos.line + 1, 0)
        } else {
            pos
        }
    }

    /// Apply a cursor movement, updating self.cursor to the new position.
    fn apply_movement(&mut self, new_pos: Position) -> Position {
        self.cursor = new_pos;
        self.cursor
    }

    pub fn cursor_up(&mut self) -> Position {
        self.apply_movement(self.offset_up(self.cursor))
    }

    pub fn offset_up(&self, pos: Position) -> Position {
        if pos.line == 0 { pos } else { self.offset_up_n(pos, 1) }
    }

    pub fn cursor_down(&mut self) -> Position {
        self.apply_movement(self.offset_down(self.cursor))
    }

    pub fn offset_down(&self, pos: Position) -> Position {
        if pos.line >= self.text.len_lines(self.line_type()).saturating_sub(1) {
            pos
        } else {
            self.offset_down_n(pos, 1)
        }
    }

    pub fn cursor_up_n(&mut self, n: usize) -> Position {
        self.apply_movement(self.offset_up_n(self.cursor, n))
    }

    pub fn offset_up_n(&self, pos: Position, n: usize) -> Position {
        let new_line = pos.line.saturating_sub(n);
        let line_len = self.line_display_len(new_line);
        Position::new(new_line, pos.column.min(line_len))
    }

    pub fn cursor_down_n(&mut self, n: usize) -> Position {
        self.apply_movement(self.offset_down_n(self.cursor, n))
    }

    pub fn offset_down_n(&self, pos: Position, n: usize) -> Position {
        let new_line = (pos.line + n).min(self.text.len_lines(self.line_type()).saturating_sub(1));
        let line_len = self.line_display_len(new_line);
        Position::new(new_line, pos.column.min(line_len))
    }

    pub fn cursor_page_up(&mut self, height: usize) -> Position {
        self.apply_movement(self.offset_up_n(self.cursor, height))
    }

    pub fn cursor_page_down(&mut self, height: usize) -> Position {
        self.apply_movement(self.offset_down_n(self.cursor, height))
    }

    pub fn cursor_line_start(&mut self) -> Position {
        self.cursor.column = 0;
        self.cursor
    }

    pub fn offset_line_start(&self, pos: Position) -> Position {
        Position::new(pos.line, 0)
    }

    pub fn cursor_line_end(&mut self) -> Position {
        self.cursor.column = self.line_display_len(self.cursor.line);
        self.cursor
    }

    pub fn offset_line_end(&self, pos: Position) -> Position {
        Position::new(pos.line, self.line_display_len(pos.line))
    }

    pub fn cursor_word_prev(&mut self) -> Position {
        self.apply_movement(self.word_boundary_prev(self.cursor))
    }

    pub fn cursor_word_next(&mut self) -> Position {
        self.apply_movement(self.word_boundary_next_for_selection(self.cursor))
    }

    pub fn word_boundary_prev(&self, pos: Position) -> Position {
        // If at logical start of line (column 0 or first non-whitespace), move to end of previous line (do not attempt token logic).
        if pos.line > 0 {
            let line_slice = self.text.line(pos.line, self.line_type());
            let mut first_non_ws_col: Option<usize> = None;
            for (i, c) in line_slice.chars().enumerate() {
                if !c.is_whitespace() || c == '\n' {
                    first_non_ws_col = Some(i);
                    break;
                }
            }
            if pos.column == 0 || first_non_ws_col == Some(pos.column) {
                let prev_line = pos.line - 1;
                return Position::new(prev_line, self.line_display_len(prev_line));
            }
        }

        let line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
        let byte_idx = line_start_byte + self.text.char_to_byte_idx(pos.column);

        if byte_idx == 0 {
            return pos;
        }

        // Start from the character immediately before the cursor.
        let mut char_idx = self.text.byte_to_char_idx(byte_idx);
        if char_idx == 0 {
            return pos;
        }
        char_idx = char_idx.saturating_sub(1);

        // Phase 1: skip whitespace (but not newlines) immediately before the cursor.
        while char_idx > 0 {
            let c_byte = self.text.char_to_byte_idx(char_idx);
            let c = self.text.char(c_byte);
            if !c.is_whitespace() || c == '\n' {
                break;
            }
            char_idx = char_idx.saturating_sub(1);
        }

        // Determine token type: punctuation vs other (word) characters.
        let c_byte = self.text.char_to_byte_idx(char_idx);
        let first_char = self.text.char(c_byte);
        let is_punct = first_char.is_ascii_punctuation();

        // Phase 2: move left over the token of the same type.
        while char_idx > 0 {
            let prev_byte = self.text.char_to_byte_idx(char_idx.saturating_sub(1));
            let prev_char = self.text.char(prev_byte);
            if is_punct {
                if !prev_char.is_ascii_punctuation() {
                    break;
                }
            } else if prev_char.is_whitespace() || prev_char.is_ascii_punctuation() {
                break;
            }
            char_idx = char_idx.saturating_sub(1);
        }

        self.byte_to_pos(self.text.char_to_byte_idx(char_idx))
    }

    pub fn word_boundary_next(&self, pos: Position) -> Position {
        self.word_boundary_next_impl(pos, true)
    }

    pub fn word_boundary_next_for_selection(&self, pos: Position) -> Position {
        self.word_boundary_next_impl(pos, false)
    }

    fn word_boundary_next_impl(&self, mut pos: Position, skip_whitespace_after: bool) -> Position {
        let end_char = self.text.len_chars();

        // Precompute line-local char bounds so we can clamp to end-of-line and
        // avoid crossing to the next line.
        let mut line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
        let mut line_start_char = self.text.byte_to_char_idx(line_start_byte);
        let mut line_len_chars = self.text.line(pos.line, self.line_type()).len_chars();
        let line_slice = self.text.line(pos.line, self.line_type());
        // Printable length excludes the trailing newline if present (rope lines include the newline
        // on all but the last line). Use printable_line_len for 'visual' end-of-line semantics.
        let mut printable_line_len = if line_slice.chars().last() == Some('\n') {
            line_len_chars.saturating_sub(1)
        } else {
            line_len_chars
        };
        let mut printable_line_end_char_idx = line_start_char + printable_line_len;

        let mut char_idx = self.pos_to_char(pos);

        // If at or beyond EOF, stay put.
        if char_idx >= end_char {
            return pos;
        }


        // If already at or beyond end-of-line (visual EOL), attempt to move to the first
        // token on the next line instead of clamping here.
        let total_lines = self.text.len_lines(self.line_type());
        if char_idx >= printable_line_end_char_idx {
            if pos.line + 1 >= total_lines {
                // No next line, clamp to current line printable end.
                return Position::new(pos.line, printable_line_len);
            }

            // Move to the start of the next line and recompute bounds.
            pos = Position::new(pos.line + 1, 0);
            line_start_byte = self.text.line_to_byte_idx(pos.line, self.line_type());
            line_start_char = self.text.byte_to_char_idx(line_start_byte);
            line_len_chars = self.text.line(pos.line, self.line_type()).len_chars();
            let line_slice = self.text.line(pos.line, self.line_type());
            printable_line_len = if line_slice.chars().last() == Some('\n') {
                line_len_chars.saturating_sub(1)
            } else {
                line_len_chars
            };
            printable_line_end_char_idx = line_start_char + printable_line_len;
            // Start at the beginning of the new line and move to the first non-whitespace
            // char (the start of the first word). This is the desired behavior when
            // ctrl+right is pressed at the visual end-of-line.
            let mut next_char_idx = self.pos_to_char(pos);
            while next_char_idx < end_char
                && self
                    .text
                    .char(self.text.char_to_byte_idx(next_char_idx))
                    .is_whitespace()
            {
                next_char_idx += 1;
                if next_char_idx >= printable_line_end_char_idx {
                    return Position::new(pos.line, printable_line_len);
                }
            }
            if next_char_idx >= end_char {
                return pos;
            }
            return self.byte_to_pos(self.text.char_to_byte_idx(next_char_idx));
        }

        // Phase 1: skip whitespace starting at the current char. If skipping would
        // cross the line boundary, clamp to end-of-line instead of crossing.
        while char_idx < end_char
            && self
                .text
                .char(self.text.char_to_byte_idx(char_idx))
                .is_whitespace()
        {
            char_idx += 1;
            if char_idx >= printable_line_end_char_idx {
                return Position::new(pos.line, printable_line_len);
            }
        }

        if char_idx >= end_char {
            return pos;
        }

        // Identify token type at the current position.
        let first_char = self.text.char(self.text.char_to_byte_idx(char_idx));
        let is_punct = first_char.is_ascii_punctuation();

        // Phase 2: advance over the token. If advancing reaches or crosses the
        // line end, clamp to end-of-line.
        while char_idx < end_char {
            let c = self.text.char(self.text.char_to_byte_idx(char_idx));
            if is_punct {
                if !c.is_ascii_punctuation() {
                    break;
                }
            } else if c.is_whitespace() || c.is_ascii_punctuation() {
                break;
            }
            char_idx += 1;
            if char_idx >= printable_line_end_char_idx {
                return Position::new(pos.line, printable_line_len);
            }
        }

        if skip_whitespace_after {
            while char_idx < end_char
                && self
                    .text
                    .char(self.text.char_to_byte_idx(char_idx))
                    .is_whitespace()
            {
                char_idx += 1;
                if char_idx >= printable_line_end_char_idx {
                    return Position::new(pos.line, printable_line_len);
                }
            }
        }

        // If we've advanced past the line end due to some edge-case, clamp.
        if char_idx >= printable_line_end_char_idx {
            return Position::new(pos.line, printable_line_len);
        }

        self.byte_to_pos(self.text.char_to_byte_idx(char_idx))
    }

    pub fn select_all(&mut self) {
        let last_line = self.text.len_lines(self.line_type()).saturating_sub(1);
        let pos = Position::new(
            last_line,
            self.text.line(last_line, self.line_type()).len_chars(),
        );
        self.selection = Some(Selection::new(Position::new(0, 0), pos));
        self.cursor = pos;
    }

    pub fn selected_text(&self) -> String {
        if let Some(sel) = self.selection {
            let start = self.pos_to_byte(sel.min());
            let end = self.pos_to_byte(sel.max());
            self.text.slice(start..end).into()
        } else {
            String::new()
        }
    }

    pub fn delete_selection(&mut self) -> String {
        if let Some(sel) = self.selection {
            let start = sel.min();
            let end = sel.max();
            let text = self.slice(start..end);
            self.delete_range(start, end);
            text
        } else {
            String::new()
        }
    }

    pub fn undo(&mut self) -> bool {
        let inverse = {
            match self.history.undo() {
                Some(node) => node.inverse.clone(),
                None => return false,
            }
        };
        apply_changeset(self, &inverse);
        true
    }

    pub fn redo(&mut self) -> bool {
        let change = {
            match self.history.redo() {
                Some(node) => node.change.clone(),
                None => return false,
            }
        };
        apply_changeset(self, &change);
        true
    }

    fn export_history_snapshots(&self) -> (Vec<SerializableSnapshot>, Vec<SerializableSnapshot>) {
        // Get a copy of the timeline (change, inverse, version) triples.
        let entries = self.history.timeline_entries();
        let cursor = self.history.current_position();

        // Start from the current buffer state.
        let mut temp = Buffer::from_lines(self.lines(), self.path.clone());
        temp.cursor = self.cursor;
        temp.selection = self.selection;
        temp.dirty = self.dirty;
        temp.version = self.version;

        // Build undo snapshots by walking backwards, applying inverses.
        let mut undo_rev: Vec<SerializableSnapshot> = Vec::new();
        for i in (0..cursor).rev() {
            let (change, inverse, version) = &entries[i];
            temp.set_cursor(change.after_cursor);
            temp.set_selection(change.after_selection);
            let snap = SerializableSnapshot {
                lines: temp.lines(),
                cursor: change.after_cursor,
                selection: change.after_selection,
                dirty: temp.dirty,
                version: *version,
            };
            undo_rev.push(snap);
            // Revert one step.
            apply_changeset(&mut temp, inverse);
        }

        // Temp now represents the base state before any undo/redo entries. Include it.
        let base_snap = SerializableSnapshot {
            lines: temp.lines(),
            cursor: temp.cursor,
            selection: temp.selection,
            dirty: temp.dirty,
            version: temp.version,
        };
        undo_rev.push(base_snap);
        undo_rev.reverse();

        // Build redo snapshots by replaying forward from the base.
        let mut redo: Vec<SerializableSnapshot> = Vec::new();
        for (change, _inv, version) in entries.iter().skip(cursor) {
            apply_changeset(&mut temp, change);
            let snap = SerializableSnapshot {
                lines: temp.lines(),
                cursor: change.after_cursor,
                selection: change.after_selection,
                dirty: temp.dirty,
                version: *version,
            };
            redo.push(snap);
        }

        (undo_rev, redo)
    }

    pub fn undo_stack(&self) -> Vec<SerializableSnapshot> {
        let (undo, _redo) = self.export_history_snapshots();
        undo
    }

    pub fn redo_stack(&self) -> Vec<SerializableSnapshot> {
        let (_undo, redo) = self.export_history_snapshots();
        redo
    }

    pub fn save(&mut self, settings: &crate::settings::Settings) -> Result<()> {
        let path = self
            .path
            .clone()
            .ok_or_else(|| anyhow!("No path associated with buffer"))?;

        self.is_saving = true;
        let result = self.do_save(&path, settings);
        self.is_saving = false;
        result
    }

    fn do_save(&mut self, path: &PathBuf, settings: &crate::settings::Settings) -> Result<()> {
        let should_trim = *adapters::editor::trim_on_save(settings);

        let mut text = self.text.clone();

        if should_trim {
            let content = text.to_string();
            let had_trailing_newline = content.ends_with('\n');
            let trimmed: String = content
                .lines()
                .map(|l| l.trim_end().to_string())
                .collect::<Vec<_>>()
                .join("\n");

            let trailing_empty = trimmed.lines().rev().take_while(|l| l.is_empty()).count();
            let final_lines: String = if trailing_empty > 1 {
                trimmed
                    .lines()
                    .take(trimmed.lines().count() - (trailing_empty - 1))
                    .collect::<Vec<_>>()
                    .join("\n")
            } else {
                trimmed.clone()
            };

            let final_with_newline = if had_trailing_newline && !final_lines.ends_with('\n') {
                format!("{}\n", final_lines)
            } else {
                final_lines
            };

            text = Rope::from_str(&final_with_newline);
            self.text = text.clone();
            self.normalize_cursor();
        }

        // Normalize trailing newlines: if the content has at least one trailing
        // newline, keep exactly one; if it has none, don't add one.
        let content = {
            let s = text.to_string();
            let without_trailing = s.trim_end_matches('\n');
            if s.ends_with('\n') {
                format!("{}\n", without_trailing)
            } else {
                without_trailing.to_string()
            }
        };

        let tmp = path.with_extension("tmp");
        fs::write(&tmp, &content)?;
        fs::rename(&tmp, path).or_else(|_| {
            fs::remove_file(path)?;
            fs::rename(&tmp, path)
        })?;

        self.file_hash = Self::compute_hash(&text);
        self.dirty = false;
        if let Some((mtime, size)) = Self::get_file_metadata(path) {
            self.file_mtime = Some(mtime);
            self.file_size = Some(size);
        }
        Ok(())
    }

    pub fn compute_hash(text: &Rope) -> Option<u64> {
        use std::collections::hash_map::DefaultHasher;
        use std::hash::{Hash, Hasher};
        let mut hasher = DefaultHasher::new();
        text.hash(&mut hasher);
        Some(hasher.finish())
    }

    pub fn compute_file_hash(path: &Path) -> Option<u64> {
        use std::collections::hash_map::DefaultHasher;
        use std::hash::{Hash, Hasher};
        use std::io::Read;
        let mut file = fs::File::open(path).ok()?;
        let mut contents = Vec::new();
        file.read_to_end(&mut contents).ok()?;
        let mut hasher = DefaultHasher::new();
        contents.hash(&mut hasher);
        Some(hasher.finish())
    }

    pub fn get_file_metadata(path: &Path) -> Option<(u64, u64)> {
        let metadata = fs::metadata(path).ok()?;
        let mtime = metadata
            .modified()
            .ok()?
            .duration_since(std::time::UNIX_EPOCH)
            .ok()?
            .as_secs();
        let size = metadata.len();
        Some((mtime, size))
    }

    pub fn compute_and_store_file_hash(&mut self) {
        if let Some(ref path) = self.path {
            self.file_hash = Self::compute_file_hash(path);
            if let Some((mtime, size)) = Self::get_file_metadata(path) {
                self.file_mtime = Some(mtime);
                self.file_size = Some(size);
            }
        }
    }

    pub fn check_external_modification(&mut self) -> bool {
        if self.is_saving {
            return false;
        }
        if self.is_dirty() {
            return false;
        }
        if let Some(ref path) = self.path
            && let Some((current_mtime, current_size)) = Self::get_file_metadata(path) {
                let mtime_changed = self.file_mtime.map(|m| m != current_mtime).unwrap_or(false);
                let size_changed = self.file_size.map(|s| s != current_size).unwrap_or(false);
                if !mtime_changed && !size_changed {
                    return false;
                }
                if let Some(current_hash) = Self::compute_file_hash(path)
                    && let Some(saved_hash) = self.file_hash
                        && current_hash != saved_hash {
                            self.external_modification_detected = true;
                            return true;
                        }
            }
        false
    }

    pub fn reload_from_disk(&mut self) -> Result<()> {
        let path = self
            .path
            .clone()
            .ok_or_else(|| anyhow!("No path associated with buffer"))?;
        let content = fs::read_to_string(&path)?;
        self.text = Rope::from_str(&content);
        self.file_hash = Self::compute_hash(&self.text);
        if let Some((mtime, size)) = Self::get_file_metadata(&path) {
            self.file_mtime = Some(mtime);
            self.file_size = Some(size);
        }
        self.external_modification_detected = false;
        self.normalize_cursor();
        Ok(())
    }

    pub fn clear_external_modification(&mut self) {
        self.external_modification_detected = false;
    }

    pub fn scroll_to_cursor(&mut self, height: usize) {
        if height == 0 {
            return;
        }
        if self.cursor.line < self.scroll {
            self.scroll = self.cursor.line;
        } else if self.cursor.line >= self.scroll + height {
            self.scroll = self.cursor.line + 1 - height;
        }
    }

    pub fn scroll_to_cursor_visual(
        &mut self,
        height: usize,
        _content_width: usize,
        _indent_width: usize,
    ) {
        self.scroll_to_cursor(height);
    }

    pub fn scroll_x_to_cursor(&mut self, _content_width: usize, _indent_width: usize) {}

    pub fn slice(&self, range: Range<Position>) -> String {
        let start_byte = self.pos_to_byte(range.start);
        let end_byte = self.pos_to_byte(range.end);
        self.text.slice(start_byte..end_byte).into()
    }

    pub fn text(&self) -> String {
        self.text.to_string()
    }

    pub fn apply_op_without_history(&mut self, op: &BufferOp) {
        match op {
            BufferOp::Replace { range, text, .. } => {
                self.replace_range_direct(range.start, range.end, text);
            }
            BufferOp::MoveCursor { position } => {
                self.set_cursor(*position);
            }
            BufferOp::SetSelection { selection } => {
                self.set_selection(*selection);
            }
        }
    }

    fn replace_range_direct(&mut self, start: Position, end: Position, text: &str) {
        let start_byte = self.pos_to_byte(start);
        let end_byte = self.pos_to_byte(end).min(self.text.len());

        if start_byte < end_byte {
            self.text.remove(start_byte..end_byte);
        }
        self.text.insert(start_byte, text);
    }
}

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

impl From<&Rope> for Buffer {
    fn from(rope: &Rope) -> Self {
        let mut buf = Self::new();
        buf.text = rope.clone();
        buf
    }
}

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

    #[test]
    fn test_position_mapping() {
        let buf = Buffer::from_text("hello\nworld\n");

        assert_eq!(buf.byte_to_pos(0), Position::new(0, 0));
        assert_eq!(buf.byte_to_pos(5), Position::new(0, 5));
        assert_eq!(buf.byte_to_pos(6), Position::new(1, 0));
        assert_eq!(buf.byte_to_pos(11), Position::new(1, 5));

        assert_eq!(buf.pos_to_byte(Position::new(0, 0)), 0);
        assert_eq!(buf.pos_to_byte(Position::new(0, 5)), 5);
        assert_eq!(buf.pos_to_byte(Position::new(1, 0)), 6);
        assert_eq!(buf.pos_to_byte(Position::new(1, 5)), 11);
    }

    #[test]
    fn test_insert() {
        let mut buf = Buffer::from_text("hello");
        buf.cursor = Position::new(0, 5);
        buf.insert(" world");
        assert_eq!(buf.text.to_string(), "hello world");
    }

    #[test]
    fn test_insert_newline() {
        let mut buf = Buffer::from_text("hello");
        buf.cursor = Position::new(0, 5);
        buf.insert_newline();
        assert_eq!(buf.text.to_string(), "hello\n");
        assert_eq!(buf.cursor, Position::new(1, 0));
    }

    #[test]
    fn test_delete_backward() {
        let mut buf = Buffer::from_text("hello world");
        buf.cursor = Position::new(0, 5);
        buf.delete_backward();
        assert_eq!(buf.text.to_string(), "hell world");
        assert_eq!(buf.cursor, Position::new(0, 4));
    }

    #[test]
    fn test_delete_selection() {
        let mut buf = Buffer::from_text("hello world");
        buf.selection = Some(Selection::new(Position::new(0, 0), Position::new(0, 6)));
        buf.delete_selection();
        assert_eq!(buf.text.to_string(), "world");
    }

    #[test]
    fn test_cursor_movement() {
        let mut buf = Buffer::from_text("hello\nworld");

        buf.cursor = Position::new(0, 3);
        assert_eq!(buf.cursor_right(), Position::new(0, 4));
        assert_eq!(buf.cursor_left(), Position::new(0, 3));

        buf.cursor = Position::new(0, 0);
        assert_eq!(buf.cursor_up(), Position::new(0, 0));

        buf.cursor = Position::new(0, 3);
        buf.cursor_up();
        assert_eq!(buf.cursor.line, 0);

        buf.cursor = Position::new(1, 3);
        buf.cursor_up();
        assert_eq!(buf.cursor.line, 0);
        assert_eq!(buf.cursor.column, 3);
    }

    #[test]
    fn test_undo_multiple_steps() {
        let mut buf = Buffer::from_text("");

        buf.insert("a");
        buf.insert("b");
        buf.insert("c");

        assert_eq!(buf.text(), "abc");
        // fast consecutive inserts of same kind coalesce into one entry
        assert_eq!(buf.history_position(), 1);

        assert!(buf.undo());
        assert_eq!(buf.text(), "");

        assert!(!buf.undo());
    }

    #[test]
    fn test_undo_stack_persist_and_restore() {
        use std::path::PathBuf;
        // Use the no-merge constructor so each insert becomes its own undo step.
        let mut buf = Buffer::from_text_no_merge("");

        buf.begin_transaction(ChangeKind::InsertText);
        buf.insert("a");
        buf.end_transaction();

        buf.begin_transaction(ChangeKind::InsertText);
        buf.insert("b");
        buf.end_transaction();

        assert_eq!(buf.text(), "ab");

        let undo_stack = buf.undo_stack();
        let redo_stack = buf.redo_stack();

        let restored = Buffer::restore(
            PathBuf::from("dummy"),
            buf.lines(),
            buf.selection,
            buf.scroll,
            buf.dirty,
            buf.markers.clone(),
            undo_stack.clone(),
            redo_stack.clone(),
        );
        let mut r = restored;

        // Undo twice to step back through the history
        assert!(r.undo());
        assert_eq!(r.text(), "a");

        assert!(r.undo());
        assert_eq!(r.text(), "");
    }

    #[test]
    fn test_undo_redo_multiple_steps() {
        let mut buf = Buffer::from_text("");

        buf.insert("a");
        buf.insert("b");

        assert_eq!(buf.text(), "ab");
        assert_eq!(buf.history_position(), 1);

        buf.undo();
        assert_eq!(buf.text(), "");

        buf.redo();
        assert_eq!(buf.text(), "ab");

        assert!(!buf.redo());
    }

    #[test]
    fn test_undo_after_new_edit() {
        let mut buf = Buffer::from_text("");

        buf.insert("a");
        buf.insert("b");

        // "a" and "b" merge → one undo clears both
        buf.undo();
        assert_eq!(buf.text(), "");

        buf.insert("c");
        assert_eq!(buf.text(), "c");

        buf.undo();
        assert_eq!(buf.text(), "");
    }

    #[test]
    fn test_line_access() {
        let buf = Buffer::from_text("hello\nworld\n");

        assert_eq!(buf.line_count(), 3);
        assert_eq!(buf.line(0).unwrap().to_string(), "hello");
        assert_eq!(buf.line(1).unwrap().to_string(), "world");
    }

    #[test]
    fn test_cursor_clamping() {
        let mut buf = Buffer::from_text("hello");
        buf.cursor = Position::new(100, 100);
        buf.normalize_cursor();

        assert!(buf.cursor.line < buf.line_count());
        assert!(buf.cursor.column <= buf.line(0).unwrap().chars().count());
    }

    #[test]
    fn test_unicode() {
        let mut buf = Buffer::from_text("héllo\nwörld");
        assert_eq!(buf.line_count(), 2);

        buf.cursor = Position::new(0, 6);
        buf.insert("!");
        let line0 = buf.text.line(0, LineType::LF).to_string();
        assert!(
            line0.starts_with("héllo"),
            "line0 should start with héllo, got: {}",
            line0
        );
    }

    #[test]
    fn test_snapshot() {
        let buf = Buffer::from_text("hello");
        let snap = buf.snapshot();
        assert_eq!(snap.text.to_string(), "hello");
    }

    #[test]
    fn test_delete_forward() {
        let mut buf = Buffer::from_text("hello");
        buf.cursor = Position::new(0, 4);
        buf.delete_forward();
        assert_eq!(buf.text.to_string(), "hell");
    }

    #[test]
    fn test_empty_buffer() {
        let buf = Buffer::new();
        assert!(buf.is_empty());
        assert_eq!(buf.line_count(), 1);
        assert_eq!(buf.cursor, Position::new(0, 0));
    }

    #[test]
    fn test_buffer_id() {
        let buf1 = Buffer::new();
        let buf2 = Buffer::new();
        assert_ne!(buf1.id(), buf2.id());
    }

    #[test]
    fn test_version_increment() {
        let mut buf = Buffer::from_text("hello");
        let v1 = buf.version();
        buf.insert(" world");
        let v2 = buf.version();
        assert!(v2 > v1);
    }

    #[test]
    fn test_fast_typing_merges_into_single_undo() {
        let mut buf = Buffer::from_text("");

        buf.insert("h");
        buf.insert("e");
        buf.insert("l");
        buf.insert("l");
        buf.insert("o");

        assert_eq!(buf.text(), "hello");
        // all five chars are adjacent and fast → one history entry
        assert_eq!(buf.history_position(), 1);

        buf.undo();
        assert_eq!(buf.text(), "");
    }

    #[test]
    fn test_fast_backspace_merges_into_single_undo() {
        let mut buf = Buffer::from_text("hello");
        buf.set_cursor(Position::new(0, 5));

        buf.delete_backward();
        buf.delete_backward();
        buf.delete_backward();

        assert_eq!(buf.text(), "he");
        assert_eq!(buf.history_position(), 1);

        buf.undo();
        assert_eq!(buf.text(), "hello");
    }

    #[test]
    fn test_structural_ops_break_merge_chain() {
        let mut buf = Buffer::from_text("");

        buf.insert("a");
        buf.insert("b"); // merges with "a" → 1 entry
        buf.insert_newline(); // Structural → new entry
        buf.insert("c"); // InsertText → new entry (different from Structural)

        assert_eq!(buf.text(), "ab\nc");
        // "ab" (merged), newline, "c" → 3 entries
        assert_eq!(buf.history_position(), 3);

        buf.undo();
        assert_eq!(buf.text(), "ab\n");

        buf.undo();
        assert_eq!(buf.text(), "ab");

        buf.undo();
        assert_eq!(buf.text(), "");
        buf.undo();
        assert_eq!(buf.text(), "");
    }

    #[test]
    fn test_mixed_undo_steps() {
        let mut buf = Buffer::from_text("");

        buf.insert("hello");
        buf.insert_newline();
        buf.insert("world");

        buf.undo();
        assert_eq!(buf.text(), "hello\n");

        buf.undo();
        assert_eq!(buf.text(), "hello");

        buf.undo();
        assert_eq!(buf.text(), "");
    }

    #[test]
    fn test_cross_line_insert_over_selection() {
        let mut buf = Buffer::from_text("hello\nworld");
        buf.set_selection(Some(Selection::new(
            Position::new(0, 2),
            Position::new(1, 3),
        )));
        buf.insert("X");
        assert_eq!(buf.text(), "heXld");
    }

    #[test]
    fn test_cross_line_delete_backward_with_selection() {
        let mut buf = Buffer::from_text("hello\nworld");
        buf.set_selection(Some(Selection::new(
            Position::new(0, 5),
            Position::new(1, 0),
        )));
        buf.delete_backward();
        assert_eq!(buf.text(), "helloworld");
    }

    #[test]
    fn test_cross_line_selection_delete_all() {
        let mut buf = Buffer::from_text("abc\ndef\nghi");
        buf.set_selection(Some(Selection::new(
            Position::new(0, 0),
            Position::new(2, 3),
        )));
        buf.insert("X");
        assert_eq!(buf.text(), "X");
    }

    #[test]
    fn test_cross_line_selection_undo_redo() {
        let mut buf = Buffer::from_text("hello\nworld");
        // Selection: anchor=(0,3), active=(1,2), cursor=(1,2)
        buf.set_selection(Some(Selection::new(
            Position::new(0, 3),
            Position::new(1, 2),
        )));
        buf.insert("X");
        assert_eq!(buf.text(), "helXrld");
        assert_eq!(buf.cursor(), Position::new(0, 4)); // after "helX"
        buf.undo();
        assert_eq!(buf.text(), "hello\nworld");
        // Undo restores cursor to before_cursor = (1,2) (active end of original selection)
        assert_eq!(buf.cursor(), Position::new(1, 2));
        buf.redo();
        assert_eq!(buf.text(), "helXrld");
        assert_eq!(buf.cursor(), Position::new(0, 4));
    }

    #[test]
    fn test_offset_up_excludes_trailing_newline() {
        let buf = Buffer::from_text("hello\nworld");
        let pos = buf.offset_up(Position::new(1, 5));
        assert_eq!(pos, Position::new(0, 5));
    }

    #[test]
    fn test_offset_up_clamps_to_shorter_line() {
        let buf = Buffer::from_text("hi\nworld");
        let pos = buf.offset_up(Position::new(1, 5));
        assert_eq!(pos, Position::new(0, 2));
    }

    #[test]
    fn test_delete_forward_multibyte() {
        let mut buf = Buffer::from_text("αβγ");
        buf.set_cursor(Position::new(0, 1));
        buf.delete_forward();
        assert_eq!(buf.text(), "αγ");
    }

    #[test]
    fn test_delete_forward_newline() {
        let mut buf = Buffer::from_text("abc\ndef");
        buf.set_cursor(Position::new(0, 3));
        buf.delete_forward();
        assert_eq!(buf.text(), "abcdef");
    }

    #[test]
    fn test_pos_to_char_and_char_to_pos() {
        let buf = Buffer::from_text("hello\nworld");
        assert_eq!(buf.pos_to_char(Position::new(0, 3)), 3);
        // "hello\n" = 6 chars, so line 1 col 2 = char 8
        assert_eq!(buf.pos_to_char(Position::new(1, 2)), 8);
        assert_eq!(buf.char_to_pos(3), Position::new(0, 3));
        assert_eq!(buf.char_to_pos(8), Position::new(1, 2));
    }

    #[test]
    fn test_set_selection_normalizes_positions() {
        let mut buf = Buffer::from_text("hi\nworld");
        buf.set_selection(Some(Selection::new(
            Position::new(0, 100), // "hi" has only 2 chars
            Position::new(1, 3),
        )));
        let sel = buf.selection().unwrap();
        assert_eq!(sel.anchor, Position::new(0, 2));
        assert_eq!(sel.active, Position::new(1, 3));
    }

    #[test]
    fn test_newline_undo_roundtrip() {
        let mut buf = Buffer::from_text("helloworld");
        buf.set_cursor(Position::new(0, 5));
        buf.insert_newline();
        assert_eq!(buf.text(), "hello\nworld");
        assert_eq!(buf.cursor(), Position::new(1, 0));
        buf.undo();
        assert_eq!(buf.text(), "helloworld");
        assert_eq!(buf.cursor(), Position::new(0, 5));
    }

    // ── insert_at ──────────────────────────────────────────────────────────

    #[test]
    fn test_insert_at_advances_cursor_when_at_position() {
        let mut buf = Buffer::from_text("hello");
        buf.set_cursor(Position::new(0, 5));
        buf.insert_at(Position::new(0, 5), " world");
        assert_eq!(buf.text(), "hello world");
        assert_eq!(buf.cursor(), Position::new(0, 11));
    }

    #[test]
    fn test_insert_at_does_not_move_cursor_when_before_cursor() {
        let mut buf = Buffer::from_text("world");
        buf.set_cursor(Position::new(0, 5));
        buf.insert_at(Position::new(0, 0), "hello ");
        assert_eq!(buf.text(), "hello world");
        assert_eq!(buf.cursor(), Position::new(0, 5));
    }

    // ── selected_text ──────────────────────────────────────────────────────

    #[test]
    fn test_selected_text_single_line() {
        let mut buf = Buffer::from_text("hello world");
        buf.set_selection(Some(Selection {
            anchor: Position::new(0, 0),
            active: Position::new(0, 5),
        }));
        assert_eq!(buf.selected_text(), "hello");
    }

    #[test]
    fn test_selected_text_multiline() {
        let mut buf = Buffer::from_text("hello\nworld");
        buf.set_selection(Some(Selection {
            anchor: Position::new(0, 3),
            active: Position::new(1, 3),
        }));
        assert_eq!(buf.selected_text(), "lo\nwor");
    }

    #[test]
    fn test_selected_text_empty_when_no_selection() {
        let buf = Buffer::from_text("hello");
        assert_eq!(buf.selected_text(), "");
    }

    // ── word_range_at ─────────────────────────────────────────────────────

    #[test]
    fn test_word_range_at_middle_of_word() {
        let buf = Buffer::from_text("hello world");
        let (start, end) = buf.word_range_at(Position::new(0, 2));
        // Position 2 is in the middle of "hello", so start is 2, end is 5
        assert_eq!(start, 2);
        assert_eq!(end, 5);
    }

    #[test]
    fn test_word_range_at_on_space() {
        let buf = Buffer::from_text("hello world");
        // Space is not a word character — just ensure no panic
        let _ = buf.word_range_at(Position::new(0, 5));
    }

    // ── delete_range edge cases ───────────────────────────────────────────

    #[test]
    fn test_delete_range_same_position_is_noop() {
        let mut buf = Buffer::from_text("hello");
        let pos = Position::new(0, 2);
        buf.delete_range(pos, pos);
        assert_eq!(buf.text(), "hello");
    }

    // ── char_count, current_line, slice ──────────────────────────────────

    #[test]
    fn test_char_count() {
        let buf = Buffer::from_text("hello\nworld");
        assert_eq!(buf.char_count(), 11);
    }

    #[test]
    fn test_current_line() {
        let mut buf = Buffer::from_text("hello\nworld");
        buf.set_cursor(Position::new(1, 0));
        assert_eq!(buf.current_line(), 1);
    }

    #[test]
    fn test_slice() {
        let buf = Buffer::from_text("hello world");
        let s = buf.slice(Position::new(0, 0)..Position::new(0, 5));
        assert_eq!(s, "hello");
    }

    // ── history_len ──────────────────────────────────────────────────────

    #[test]
    fn test_history_len() {
        let mut buf = Buffer::from_text("");
        assert_eq!(buf.history_len(), 0);
        buf.insert("a");
        assert!(buf.history_len() > 0);
    }

    // ── scroll_to_cursor ──────────────────────────────────────────────────

    #[test]
    fn test_scroll_to_cursor_scrolls_down_when_cursor_below_view() {
        let mut buf = Buffer::from_text("a\nb\nc\nd\ne");
        buf.set_cursor(Position::new(4, 0));
        buf.scroll_to_cursor(3);
        assert!(buf.scroll >= 2);
    }

    #[test]
    fn test_scroll_to_cursor_scrolls_up_when_cursor_above_view() {
        let mut buf = Buffer::from_text("a\nb\nc\nd\ne");
        buf.scroll = 4;
        buf.set_cursor(Position::new(0, 0));
        buf.scroll_to_cursor(3);
        assert_eq!(buf.scroll, 0);
    }

    // ── offset_down / offset_down_n ──────────────────────────────────────

    #[test]
    fn test_offset_down_clamps_column_to_shorter_line() {
        let buf = Buffer::from_text("hello world\nhi");
        let pos = buf.offset_down(Position::new(0, 8));
        assert_eq!(pos, Position::new(1, 2));
    }

    #[test]
    fn test_offset_down_at_last_line_stays() {
        let buf = Buffer::from_text("hello");
        let pos = buf.offset_down(Position::new(0, 3));
        assert_eq!(pos, Position::new(0, 3));
    }

    #[test]
    fn test_offset_down_n_stops_at_last_line() {
        let buf = Buffer::from_text("a\nb\nc");
        let pos = buf.offset_down_n(Position::new(0, 0), 100);
        assert_eq!(pos.line, 2);
    }

    // ── offset_left / offset_right wrapping ──────────────────────────────

    #[test]
    fn test_offset_left_wraps_to_end_of_previous_line() {
        let buf = Buffer::from_text("hello\nworld");
        let pos = buf.offset_left(Position::new(1, 0));
        assert_eq!(pos, Position::new(0, 5));
    }

    #[test]
    fn test_offset_right_wraps_to_start_of_next_line() {
        let buf = Buffer::from_text("hello\nworld");
        let pos = buf.offset_right(Position::new(0, 5));
        assert_eq!(pos, Position::new(1, 0));
    }

    // ── insert_text_raw CRLF normalization ───────────────────────────────

    #[test]
    fn test_insert_text_raw_normalizes_crlf() {
        let mut buf = Buffer::from_text("");
        buf.insert_text_raw("hello\r\nworld");
        assert_eq!(buf.text(), "hello\nworld");
    }

    #[test]
    fn test_insert_text_raw_normalizes_cr_only() {
        let mut buf = Buffer::from_text("");
        buf.insert_text_raw("hello\rworld");
        assert_eq!(buf.text(), "hello\nworld");
    }

    // ── replace_range no-op branch ─────────────────────────────────────────

    #[test]
    fn test_replace_range_same_text_at_cursor_is_noop_for_history() {
        let mut buf = Buffer::from_text("hello");
        buf.set_cursor(Position::new(0, 0));
        let before_history_pos = buf.history_position();
        buf.replace_range(Position::new(0, 0), Position::new(0, 5), "hello");
        assert_eq!(buf.history_position(), before_history_pos);
        assert_eq!(buf.text(), "hello");
    }

    // ── delete_word_forward starting on whitespace ────────────────────────

    #[test]
    fn test_delete_word_forward_from_whitespace() {
        let mut buf = Buffer::from_text("hello   world");
        buf.set_cursor(Position::new(0, 5));
        buf.delete_word_forward();
        assert_eq!(buf.text(), "helloworld");
    }

    // ── delete_line on last line ───────────────────────────────────────────

    #[test]
    fn test_delete_line_last_line_leaves_empty() {
        let mut buf = Buffer::from_text("hello");
        buf.set_cursor(Position::new(0, 0));
        buf.delete_line();
        assert_eq!(buf.line_count(), 1);
        assert_eq!(buf.line(0).unwrap(), "");
    }

    // ── save: trailing-newline normalization ──────────────────────────────

    /// Helper: create a settings instance that has `trim_on_save` set to the
    /// given value, using a temporary project directory so `Settings::new`
    /// succeeds without touching the real config.
    fn settings_with_trim(trim: bool) -> (crate::settings::Settings, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let project_config = dir.path().join(".oo").join("config.yaml");
        std::fs::create_dir(dir.path().join(".oo")).unwrap();
        let override_yaml = format!("editor:\n  trim_on_save: {}\n", trim);
        std::fs::write(&project_config, &override_yaml).unwrap();
        let s = crate::settings::Settings::new(&project_config).unwrap();
        (s, dir)
    }

    /// Save `content` to a temp file and return the bytes written to disk.
    fn save_content(content: &str, trim: bool) -> String {
        let (settings, dir) = settings_with_trim(trim);
        let file = dir.path().join("test.txt");
        let mut buf = Buffer::from_text(content);
        buf.path = Some(file.clone());
        buf.save(&settings).expect("save should succeed");
        std::fs::read_to_string(&file).expect("file should exist after save")
    }

    #[test]
    fn save_no_trailing_newline_is_preserved() {
        // A file with no trailing newline must not gain one.
        let on_disk = save_content("hello\nworld", false);
        assert_eq!(on_disk, "hello\nworld");
    }

    #[test]
    fn save_single_trailing_newline_is_preserved() {
        // A file with exactly one trailing newline must keep it.
        let on_disk = save_content("hello\nworld\n", false);
        assert_eq!(on_disk, "hello\nworld\n");
    }

    #[test]
    fn save_multiple_trailing_newlines_collapsed_to_one() {
        // Two or more trailing newlines must be collapsed to exactly one.
        let on_disk = save_content("hello\nworld\n\n", false);
        assert_eq!(on_disk, "hello\nworld\n");

        let on_disk = save_content("hello\nworld\n\n\n", false);
        assert_eq!(on_disk, "hello\nworld\n");
    }

    #[test]
    fn save_trim_on_save_preserves_trailing_newline() {
        // With trim_on_save the file must still end with exactly one newline
        // when the original content has one.
        let on_disk = save_content("hello  \nworld  \n", true);
        assert_eq!(on_disk, "hello\nworld\n");
    }

    #[test]
    fn save_trim_on_save_no_trailing_newline_not_added() {
        // With trim_on_save, if the original had no trailing newline, none is added.
        let on_disk = save_content("hello  \nworld  ", true);
        assert_eq!(on_disk, "hello\nworld");
    }

    #[test]
    fn save_trim_on_save_collapses_extra_trailing_newlines() {
        let on_disk = save_content("hello\nworld\n\n\n", true);
        assert_eq!(on_disk, "hello\nworld\n");
    }

    #[test]
    fn restore_clamps_stale_cursor_to_shorter_content() {
        // Simulates reopening a file whose line count decreased on disk.
        // The saved selection points to line 10, col 5 but the restored
        // content only has 3 lines — restore() must not panic and must
        // clamp the cursor to within bounds.
        use crate::editor::position::Position;
        use crate::editor::selection::Selection;
        let stale_selection = Some(Selection {
            anchor: Position::new(10, 3),
            active: Position::new(10, 5),
        });
        let short_lines = vec!["line0".to_owned(), "line1".to_owned(), "line2".to_owned()];
        let buf = Buffer::restore(
            std::path::PathBuf::from("/fake/path.rs"),
            short_lines,
            stale_selection,
            0,
            true, // dirty=true so lines are used as-is
            vec![],
            vec![],
            vec![],
        );
        // Cursor must be within the 3-line buffer.
        assert!(buf.cursor().line < 3, "cursor.line OOB: {}", buf.cursor().line);
        // Exercising key-movement helpers must not panic.
        let _ = buf.offset_right(buf.cursor());
        let _ = buf.offset_down(buf.cursor());
        let _ = buf.offset_line_end(buf.cursor());
    }
}