use std::cmp::{max, min};
use std::ops::Range;
use std::sync::{Arc, LazyLock};

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use regex::Regex;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

use crate::scrollback::table_geometry::{CellRef, TableGeometry};
use crate::scrollback::types::SelectionBoundary;
use crate::theme::Theme;

// ---------------------------------------------------------------------------
// Auto-scroll types
// ---------------------------------------------------------------------------

/// Direction for drag auto-scroll.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoScrollDirection {
    Up,
    Down,
}

/// State for timer-driven drag auto-scroll.
///
/// While active, `tick_drag_autoscroll` scrolls by `speed` rows per tick
/// in the given direction. The direction and speed are recomputed from the
/// mouse position each time the pointer moves, and the state is cleared
/// when the pointer returns inside the content area or the drag ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DragAutoScrollState {
    pub direction: AutoScrollDirection,
    /// Rows to scroll per tick.
    pub speed: u16,
}

/// Rows of near-edge interior zone that still trigger autoscroll.
const EDGE_THRESHOLD: u16 = 2;

/// Compute autoscroll direction and speed from mouse position relative to
/// the scrollback content area.
///
/// Returns `Some(state)` when the pointer is above, below, or within
/// [`EDGE_THRESHOLD`] rows of the content boundary. Returns `None` when
/// the pointer is comfortably inside the viewport.
pub fn compute_autoscroll(mouse_row: u16, content_area: Rect) -> Option<DragAutoScrollState> {
    let top = content_area.y;
    let bottom = content_area.y.saturating_add(content_area.height);

    if content_area.height == 0 {
        return None;
    }

    if mouse_row < top.saturating_add(EDGE_THRESHOLD) {
        // Above or near top edge.
        let distance = top.saturating_add(EDGE_THRESHOLD).saturating_sub(mouse_row);
        Some(DragAutoScrollState {
            direction: AutoScrollDirection::Up,
            speed: speed_for_distance(distance),
        })
    } else if mouse_row >= bottom.saturating_sub(EDGE_THRESHOLD) {
        // Below or near bottom edge.
        let distance = mouse_row
            .saturating_sub(bottom.saturating_sub(EDGE_THRESHOLD))
            .saturating_add(1);
        Some(DragAutoScrollState {
            direction: AutoScrollDirection::Down,
            speed: speed_for_distance(distance),
        })
    } else {
        None
    }
}

/// Map distance-beyond-edge to scroll speed (rows per tick).
fn speed_for_distance(distance: u16) -> u16 {
    match distance {
        0..=2 => 1,
        3..=5 => 2,
        6..=10 => 3,
        _ => 5,
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedSelectionModel {
    pub ranges: Vec<ResolvedSelectableRange>,
    pub visible_blocks: Vec<VisibleBlockGeometry>,
    pub content_area: Rect,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedSelectionBoundary {
    entry_idx: usize,
    range_id: u16,
    block_line_idx: usize,
    boundary: Arc<SelectionBoundary>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ResolvedSelectionBoundaries(Vec<ResolvedSelectionBoundary>);

impl ResolvedSelectionBoundaries {
    pub(crate) fn push(&mut self, line: &ResolvedSelectableLine, boundary: Arc<SelectionBoundary>) {
        self.0.push(ResolvedSelectionBoundary {
            entry_idx: line.entry_idx,
            range_id: line.range_id,
            block_line_idx: line.block_line_idx,
            boundary,
        });
    }

    fn boundary_for_line(&self, line: &ResolvedSelectableLine) -> Option<&SelectionBoundary> {
        self.0
            .iter()
            .find(|boundary| {
                boundary.entry_idx == line.entry_idx
                    && boundary.range_id == line.range_id
                    && boundary.block_line_idx == line.block_line_idx
            })
            .map(|boundary| boundary.boundary.as_ref())
    }

    pub(crate) fn boundary_for_hit(&self, hit: &RangeHit) -> Option<&SelectionBoundary> {
        self.0
            .iter()
            .find(|boundary| {
                boundary.entry_idx == hit.entry_idx
                    && boundary.range_id == hit.range_id
                    && boundary.block_line_idx == hit.block_line_idx
            })
            .map(|boundary| boundary.boundary.as_ref())
    }

    #[cfg(test)]
    pub(crate) fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSelectableRange {
    pub entry_idx: usize,
    pub range_id: u16,
    pub lines: Vec<ResolvedSelectableLine>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSelectableLine {
    pub entry_idx: usize,
    /// Block `selection_range` ids count up from 0; `u16::MAX` is reserved
    /// for the labeled group header's synthetic row
    /// (`render::GROUP_HEADER_RANGE_ID`).
    pub range_id: u16,
    pub block_line_idx: usize,
    pub screen_y: u16,
    pub screen_x: u16,
    pub selectable_cols: Range<u16>,
    pub text: String,
    pub joiner_to_previous: Option<String>,
}

impl ResolvedSelectableLine {
    /// `(col distance, clamped col-within-range)` for a pointer at screen
    /// `col` on this line: distance 0 with the exact offset inside the
    /// selectable span, otherwise the gap to the nearer edge with the offset
    /// clamped to that edge. `None` when the line has no selectable width.
    ///
    /// The single source of column semantics for every hit test, so their
    /// same-row behavior cannot diverge.
    fn col_metrics(&self, col: u16) -> Option<(u16, u16)> {
        let start = self.screen_x.saturating_add(self.selectable_cols.start);
        let end = self.screen_x.saturating_add(self.selectable_cols.end);
        if end <= start {
            return None;
        }

        let width = end.saturating_sub(start);
        Some(if col < start {
            (start.saturating_sub(col), 0)
        } else if col >= end {
            (
                col.saturating_sub(end.saturating_sub(1)),
                width.saturating_sub(1),
            )
        } else {
            (0, col.saturating_sub(start))
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibleBlockGeometry {
    pub entry_idx: usize,
    pub area: Rect,
    pub content_area: Rect,
    pub selection_area: Rect,
    pub content_width: u16,
    pub top_clipped: bool,
    pub bottom_clipped: bool,
    pub drag_startable: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RangeHit {
    pub entry_idx: usize,
    pub range_id: u16,
    /// Stable line identifier: the line's index within the block's full output.
    /// Unlike the position in the visible `range.lines[]` array, this does not
    /// change when the viewport scrolls.
    pub block_line_idx: usize,
    pub col_within_range: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PendingTextDrag {
    pub anchor: RangeHit,
    pub start_col: u16,
    pub start_row: u16,
    /// The anchor block's `VisibleBlockGeometry.content_width` at mouse-down
    /// (`None` when the block had no geometry then). Copy needs the width the
    /// drag's `block_line_idx` values were captured against even after the
    /// block scrolls fully out of `visible_blocks`.
    pub anchor_content_width: Option<u16>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveTextDrag {
    pub anchor: RangeHit,
    pub head: RangeHit,
    pub kind: SelectionKind,
    /// Carried over from [`PendingTextDrag::anchor_content_width`].
    pub anchor_content_width: Option<u16>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionEndpoint {
    pub block_line_idx: usize,
    pub col_within_range: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionOrigin {
    Drag,
    DoubleClick,
    TripleClick,
}

/// Shape of a text selection: `Linear` sweeps whole lines between the
/// endpoints; drags anchored inside a detected table cell are table-shaped
/// (see [`crate::scrollback::table_geometry`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionKind {
    #[default]
    Linear,
    /// Head latched to the anchor cell: text selection clamped to the cell's
    /// column band, spanning its wrapped fragment lines.
    TableCell,
    /// A rectangular range of whole cells (`anchor` to `head`), copied as
    /// TSV. Cells are carried, not re-derived: endpoints can sit on columns
    /// that resolve to no cell, and paint/copy must match resolution.
    TableGrid { anchor: CellRef, head: CellRef },
}

/// Side-car [`TableGeometry`] keyed to the selection it was resolved for.
/// Consumers check the key; a stale side-car is ignored (table kinds paint
/// nothing without geometry).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableSelectionGeometry {
    pub entry_idx: usize,
    pub range_id: u16,
    pub geometry: TableGeometry,
}

impl TableSelectionGeometry {
    /// The geometry, if it was resolved for (`entry_idx`, `range_id`).
    pub fn for_selection(&self, entry_idx: usize, range_id: u16) -> Option<&TableGeometry> {
        (self.entry_idx == entry_idx && self.range_id == range_id).then_some(&self.geometry)
    }
}

/// A text selection that persists after mouse-up for visual feedback.
///
/// Cleared on the next click elsewhere, Escape, or any scrollback navigation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PersistentTextSelection {
    pub entry_idx: usize,
    pub range_id: u16,
    pub anchor: SelectionEndpoint,
    pub head: SelectionEndpoint,
    pub origin: SelectionOrigin,
    pub kind: SelectionKind,
}

impl ResolvedSelectionModel {
    pub fn push_line(&mut self, line: ResolvedSelectableLine) {
        if let Some(last) = self.ranges.last_mut()
            && last.entry_idx == line.entry_idx
            && last.range_id == line.range_id
        {
            last.lines.push(line);
            return;
        }

        self.ranges.push(ResolvedSelectableRange {
            entry_idx: line.entry_idx,
            range_id: line.range_id,
            lines: vec![line],
        });
    }

    pub fn hit_test_selectable_range(&self, col: u16, row: u16) -> Option<RangeHit> {
        let mut best: Option<(u16, RangeHit)> = None;

        for range in &self.ranges {
            for line in &range.lines {
                if line.screen_y != row {
                    continue;
                }
                let Some((distance, col_within_range)) = line.col_metrics(col) else {
                    continue;
                };

                let hit = RangeHit {
                    entry_idx: range.entry_idx,
                    range_id: range.range_id,
                    block_line_idx: line.block_line_idx,
                    col_within_range,
                };
                if distance == 0 {
                    return Some(hit);
                }
                if best
                    .as_ref()
                    .is_none_or(|(best_distance, _)| distance < *best_distance)
                {
                    best = Some((distance, hit));
                }
            }
        }

        best.map(|(_, hit)| hit)
    }

    /// Nearest line of the anchor's `(entry_idx, range_id)` to `(col, row)`,
    /// by `(|screen_y - row|, then col distance)` — the drag-head resolver.
    ///
    /// Unlike [`Self::hit_test_selectable_range`] this never lands on another
    /// range and never misses while the anchor's range has visible lines, so
    /// the head tracks the pointer across gap/vpad/chrome rows and past the
    /// range's last line (native drag semantics). Same-row behavior is
    /// identical to `hit_test_selectable_range` restricted to that range.
    /// Full ties (a pointer row equidistant between two lines) resolve to the
    /// line farther from the anchor, so a drag over a dead row keeps
    /// extending the selection instead of retreating.
    ///
    /// `None` only when the range has no selectable lines in this model
    /// (scrolled fully out) — callers keep the previous head then.
    pub fn hit_test_nearest_in_range(
        &self,
        anchor: RangeHit,
        col: u16,
        row: u16,
    ) -> Option<RangeHit> {
        let range = self.range(anchor.entry_idx, anchor.range_id)?;
        let mut best: Option<((u16, u16), usize, RangeHit)> = None;

        for line in &range.lines {
            let Some((col_distance, col_within_range)) = line.col_metrics(col) else {
                continue;
            };

            let key = (line.screen_y.abs_diff(row), col_distance);
            let anchor_distance = line.block_line_idx.abs_diff(anchor.block_line_idx);
            let hit = RangeHit {
                entry_idx: range.entry_idx,
                range_id: range.range_id,
                block_line_idx: line.block_line_idx,
                col_within_range,
            };
            if best.as_ref().is_none_or(|(best_key, best_anchor_dist, _)| {
                key < *best_key || (key == *best_key && anchor_distance > *best_anchor_dist)
            }) {
                best = Some((key, anchor_distance, hit));
            }
        }

        best.map(|(_, _, hit)| hit)
    }

    pub fn hit_test_visible_block(&self, col: u16, row: u16) -> Option<&VisibleBlockGeometry> {
        self.visible_blocks
            .iter()
            .find(|block| rect_contains(block.area, col, row))
    }

    /// The content width `entry_idx`'s block was rendered at this frame, or
    /// `None` when the block is not in the viewport.
    pub fn visible_block_content_width(&self, entry_idx: usize) -> Option<u16> {
        self.visible_blocks
            .iter()
            .find(|b| b.entry_idx == entry_idx)
            .map(|b| b.content_width)
    }

    pub fn range(&self, entry_idx: usize, range_id: u16) -> Option<&ResolvedSelectableRange> {
        self.ranges
            .iter()
            .find(|range| range.entry_idx == entry_idx && range.range_id == range_id)
    }

    /// Hit-test for text selection: only return a hit when the click is
    /// directly on selectable columns (distance == 0).
    ///
    /// This ensures clicks on accent bars, borders, and padding fall through
    /// to block-level handling (fold toggle).
    pub fn hit_test_text_exact(&self, col: u16, row: u16) -> Option<RangeHit> {
        for range in &self.ranges {
            for line in &range.lines {
                if line.screen_y != row {
                    continue;
                }
                let Some((distance, col_within_range)) = line.col_metrics(col) else {
                    continue;
                };
                if distance == 0 {
                    return Some(RangeHit {
                        entry_idx: range.entry_idx,
                        range_id: range.range_id,
                        block_line_idx: line.block_line_idx,
                        col_within_range,
                    });
                }
            }
        }
        None
    }

    /// Look up the selectable line matching a [`RangeHit`].
    ///
    /// Convenience shorthand for `model.range(hit) → find(block_line_idx)`.
    pub fn line_for_hit(&self, hit: &RangeHit) -> Option<&ResolvedSelectableLine> {
        self.range(hit.entry_idx, hit.range_id)?
            .lines
            .iter()
            .find(|l| l.block_line_idx == hit.block_line_idx)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PendingBlockDrag {
    pub anchor_entry_idx: usize,
    pub start_col: u16,
    pub start_row: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveBlockDrag {
    pub anchor_entry_idx: usize,
    pub head_entry_idx: usize,
}

pub fn drag_threshold_exceeded(pending: &PendingTextDrag, col: u16, row: u16) -> bool {
    let dx = pending.start_col.abs_diff(col);
    let dy = pending.start_row.abs_diff(row);
    dx >= 1 || dy >= 1
}

pub fn reconstruct_selection_text(
    model: &ResolvedSelectionModel,
    drag: &ActiveTextDrag,
) -> Option<String> {
    reconstruct_selection_text_with_boundaries(model, &ResolvedSelectionBoundaries::default(), drag)
}

pub(crate) fn reconstruct_selection_text_with_boundaries(
    model: &ResolvedSelectionModel,
    boundaries: &ResolvedSelectionBoundaries,
    drag: &ActiveTextDrag,
) -> Option<String> {
    let range = model.range(drag.anchor.entry_idx, drag.anchor.range_id)?;
    let start_bl = min(drag.anchor.block_line_idx, drag.head.block_line_idx);
    let end_bl = max(drag.anchor.block_line_idx, drag.head.block_line_idx);
    let mut out = String::new();
    let mut first = true;

    for line in &range.lines {
        if line.block_line_idx < start_bl || line.block_line_idx > end_bl {
            continue;
        }
        if !first {
            out.push_str(line.joiner_to_previous.as_deref().unwrap_or("\n"));
        }
        first = false;
        let slice =
            selection_slice_for_line_by_block_idx(drag, line, boundaries.boundary_for_line(line))?;
        out.push_str(&slice);
    }

    if out.is_empty() && !first {
        return Some(out);
    }
    if first {
        // No visible lines found in the current model for the drag range.
        // This can happen when both anchor and head have scrolled off-screen.
        return None;
    }

    Some(out)
}

pub fn block_drag_threshold_exceeded(pending: &PendingBlockDrag, col: u16, row: u16) -> bool {
    let dx = pending.start_col.abs_diff(col);
    let dy = pending.start_row.abs_diff(row);
    dx >= 1 || dy >= 1
}

pub fn render_block_drag_overlay(
    model: &ResolvedSelectionModel,
    drag: &ActiveBlockDrag,
    buf: &mut Buffer,
) {
    let theme = Theme::current();
    let start = min(drag.anchor_entry_idx, drag.head_entry_idx);
    let end = max(drag.anchor_entry_idx, drag.head_entry_idx);

    for block in &model.visible_blocks {
        if block.entry_idx >= start && block.entry_idx <= end {
            for y in block.area.y..block.area.y.saturating_add(block.area.height) {
                for x in block.area.x..block.area.x.saturating_add(block.area.width) {
                    if let Some(cell) = buf.cell_mut((x, y)) {
                        apply_selection_highlight(&theme, cell);
                    }
                }
            }
        }
    }
}

pub fn render_active_selection_overlay(
    model: &ResolvedSelectionModel,
    drag: &ActiveTextDrag,
    table: Option<&TableGeometry>,
    buf: &mut Buffer,
) {
    render_selection_overlay_impl(
        model,
        drag.anchor.entry_idx,
        drag.anchor.range_id,
        SelectionEndpoint {
            block_line_idx: drag.anchor.block_line_idx,
            col_within_range: drag.anchor.col_within_range,
        },
        SelectionEndpoint {
            block_line_idx: drag.head.block_line_idx,
            col_within_range: drag.head.col_within_range,
        },
        drag.kind,
        table,
        buf,
    );
}

/// Render a persistent text selection overlay (after mouse-up).
///
/// Unlike [`render_active_selection_overlay`] (which reads from [`ActiveTextDrag`]),
/// this reads from [`PersistentTextSelection`] and maps stable `block_line_idx`
/// coordinates back to screen positions using the current frame's
/// [`ResolvedSelectionModel`].
pub fn render_persistent_selection_overlay(
    model: &ResolvedSelectionModel,
    selection: &PersistentTextSelection,
    table: Option<&TableGeometry>,
    buf: &mut Buffer,
) {
    render_selection_overlay_impl(
        model,
        selection.entry_idx,
        selection.range_id,
        selection.anchor,
        selection.head,
        selection.kind,
        table,
        buf,
    );
}

#[allow(clippy::too_many_arguments)]
fn render_selection_overlay_impl(
    model: &ResolvedSelectionModel,
    entry_idx: usize,
    range_id: u16,
    anchor: SelectionEndpoint,
    head: SelectionEndpoint,
    kind: SelectionKind,
    table: Option<&TableGeometry>,
    buf: &mut Buffer,
) {
    let Some(range) = model.range(entry_idx, range_id) else {
        return;
    };
    // Table kinds need their geometry; without it (btw overlay, stale
    // side-car) paint nothing rather than a misleading linear sweep.
    let table = match kind {
        SelectionKind::Linear => None,
        SelectionKind::TableCell | SelectionKind::TableGrid { .. } => match table {
            Some(geom) => Some(geom),
            None => return,
        },
    };

    let theme = Theme::current();
    let start_bl = min(anchor.block_line_idx, head.block_line_idx);
    let end_bl = max(anchor.block_line_idx, head.block_line_idx);

    for line in &range.lines {
        let col_ranges: Vec<Range<u16>> = if let Some(geom) = table {
            // Clipped to content so the highlight matches the trimmed copy.
            table_selected_cols_for_line(geom, kind, anchor, head, line.block_line_idx)
                .into_iter()
                .map(|cols| clip_cols_to_content(&line.text, cols))
                .collect()
        } else {
            if line.block_line_idx < start_bl || line.block_line_idx > end_bl {
                continue;
            }
            match selected_cols_for_endpoints(
                anchor.block_line_idx,
                anchor.col_within_range,
                head.block_line_idx,
                head.col_within_range,
                line,
            ) {
                Some(cols) => vec![cols],
                None => continue,
            }
        };
        for cols in col_ranges {
            for col in cols.start..cols.end {
                let screen_x = line
                    .screen_x
                    .saturating_add(line.selectable_cols.start)
                    .saturating_add(col);
                if let Some(cell) = buf.cell_mut((screen_x, line.screen_y)) {
                    apply_selection_highlight(&theme, cell);
                }
            }
        }
    }
}

/// A table-cell selection's endpoints clamped into the cell's box (fragment
/// lines x column band), normalized so start <= end. The head can sit
/// outside the cell whenever the latch kept the selection there.
fn table_cell_span(
    geom: &TableGeometry,
    cell: crate::scrollback::table_geometry::CellRef,
    anchor: SelectionEndpoint,
    head: SelectionEndpoint,
) -> ((usize, u16), (usize, u16)) {
    let lines = geom.row_lines(cell.row);
    let band = geom.band(cell.col);
    let clamp = |ep: SelectionEndpoint| {
        (
            ep.block_line_idx
                .clamp(lines.start, lines.end.saturating_sub(1)),
            ep.col_within_range
                .clamp(band.start, band.end.saturating_sub(1)),
        )
    };
    let a = clamp(anchor);
    let h = clamp(head);
    if a <= h { (a, h) } else { (h, a) }
}

/// Clip a painted column range to the non-whitespace content it covers,
/// mirroring the copy (which trims fragments and skips blank ones).
/// Whitespace between content columns stays inside the range.
fn clip_cols_to_content(text: &str, cols: Range<u16>) -> Range<u16> {
    let mut start: Option<u16> = None;
    let mut end = cols.start;
    let mut col = 0u16;
    for grapheme in text.graphemes(true) {
        let width = UnicodeWidthStr::width(grapheme) as u16;
        if width == 0 {
            continue;
        }
        let next = col.saturating_add(width);
        if col >= cols.end {
            break;
        }
        if next > cols.start && !grapheme.chars().all(char::is_whitespace) {
            start.get_or_insert(col.max(cols.start));
            end = next.min(cols.end);
        }
        col = next;
    }
    match start {
        Some(start) => start..end,
        None => cols.start..cols.start,
    }
}

/// Selected column ranges on one line of a table-shaped selection:
/// `TableCell` at most one band-clamped range, `TableGrid` one band per
/// selected column. Border glyphs are never included.
fn table_selected_cols_for_line(
    geom: &TableGeometry,
    kind: SelectionKind,
    anchor: SelectionEndpoint,
    head: SelectionEndpoint,
    block_line_idx: usize,
) -> Vec<Range<u16>> {
    // Lines outside the grid can never paint; keep them allocation-free.
    if !geom.line_range().contains(&block_line_idx) {
        return Vec::new();
    }
    match kind {
        SelectionKind::Linear => Vec::new(),
        SelectionKind::TableCell => {
            let Some(cell) = geom.cell_at(anchor.block_line_idx, anchor.col_within_range) else {
                return Vec::new();
            };
            let band = geom.band(cell.col);
            let ((l0, c0), (l1, c1)) = table_cell_span(geom, cell, anchor, head);
            if block_line_idx < l0 || block_line_idx > l1 {
                return Vec::new();
            }
            let start = if block_line_idx == l0 { c0 } else { band.start };
            let end = if block_line_idx == l1 {
                c1.saturating_add(1).min(band.end)
            } else {
                band.end
            };
            if start >= end {
                return Vec::new();
            }
            // Named binding: `vec![start..end]` trips single_range_in_vec_init.
            let band_cols: Range<u16> = start..end;
            vec![band_cols]
        }
        SelectionKind::TableGrid { anchor: a, head: h } => {
            let Some(row) = geom.row_of_line(block_line_idx) else {
                return Vec::new();
            };
            let (r0, r1) = (a.row.min(h.row), a.row.max(h.row));
            let (c0, c1) = (a.col.min(h.col), a.col.max(h.col));
            if row < r0 || row > r1 {
                return Vec::new();
            }
            (c0..=c1).map(|c| geom.band(c)).collect()
        }
    }
}

/// Resolve a drag's [`SelectionKind`], with hysteresis: the head is
/// latched from the cell the drag already holds, so only another cell's
/// content changes the mode. Grid-line anchors stay `Linear`.
pub fn resolve_table_drag_kind(
    geom: Option<&TableGeometry>,
    anchor: &RangeHit,
    head: &RangeHit,
    prev: SelectionKind,
) -> SelectionKind {
    let Some(geom) = geom else {
        return SelectionKind::Linear;
    };
    let Some(a) = geom.cell_at(anchor.block_line_idx, anchor.col_within_range) else {
        return SelectionKind::Linear;
    };
    let held = match prev {
        SelectionKind::TableGrid { head, .. } => head,
        SelectionKind::Linear | SelectionKind::TableCell => a,
    };
    let h = geom.latched_cell_at(held, head.block_line_idx, head.col_within_range);
    if a == h {
        SelectionKind::TableCell
    } else {
        SelectionKind::TableGrid { anchor: a, head: h }
    }
}

/// Copied text for a table-shaped selection: the band-clamped span for
/// `TableCell`, whole cells as TSV for `TableGrid`. `None` (= fall back to
/// linear) for `Linear` drags or an anchor that no longer resolves.
pub fn reconstruct_table_selection_text(
    geom: &TableGeometry,
    drag: &ActiveTextDrag,
    text_at: impl Fn(usize) -> Option<String>,
) -> Option<String> {
    let anchor = SelectionEndpoint {
        block_line_idx: drag.anchor.block_line_idx,
        col_within_range: drag.anchor.col_within_range,
    };
    let head = SelectionEndpoint {
        block_line_idx: drag.head.block_line_idx,
        col_within_range: drag.head.col_within_range,
    };
    match drag.kind {
        SelectionKind::Linear => None,
        SelectionKind::TableCell => {
            let cell = geom.cell_at(anchor.block_line_idx, anchor.col_within_range)?;
            let band = geom.band(cell.col);
            let ((l0, c0), (l1, c1)) = table_cell_span(geom, cell, anchor, head);
            let mut out = String::new();
            for line in l0..=l1 {
                let text = text_at(line)?;
                let start = if line == l0 { c0 } else { band.start };
                let end = if line == l1 {
                    c1.saturating_add(1).min(band.end)
                } else {
                    band.end
                };
                let slice = crate::scrollback::types::slice_display_cols(&text, start, end);
                let fragment = slice.trim();
                if fragment.is_empty() {
                    continue;
                }
                if !out.is_empty() {
                    out.push(' ');
                }
                out.push_str(fragment);
            }
            Some(out)
        }
        SelectionKind::TableGrid { anchor, head } => Some(geom.grid_tsv(anchor, head, text_at)),
    }
}

/// Uniform selection band in the classic inverted colors (`bg_base` on
/// `text_primary`): styled spans (inline code, links, syntax highlighting)
/// join the band instead of inverting to their own colors.
/// Terminal-native / colorless themes fall back to reverse video.
pub(crate) fn apply_selection_highlight(theme: &Theme, cell: &mut ratatui::buffer::Cell) {
    let band = theme.text_primary;
    if band == Color::Reset || theme.bg_base == Color::Reset {
        cell.modifier.insert(Modifier::REVERSED);
        return;
    }
    // A search-match highlight painted earlier in the frame sets REVERSED;
    // left in place it would swap the band right back out.
    cell.modifier.remove(Modifier::REVERSED);
    cell.set_fg(theme.bg_base);
    cell.set_bg(band);
}

fn selection_slice_for_line_by_block_idx(
    drag: &ActiveTextDrag,
    line: &ResolvedSelectableLine,
    boundary: Option<&SelectionBoundary>,
) -> Option<String> {
    let cols = selected_cols_for_line_by_block_idx(drag, line)?;
    let visible_width = line
        .selectable_cols
        .end
        .saturating_sub(line.selectable_cols.start);
    let selected = slice_text_cols(&line.text, cols.clone());
    Some(apply_selection_boundary(
        selected,
        boundary,
        cols.start == 0,
        cols.end == visible_width,
    ))
}

pub(crate) fn apply_selection_boundary(
    selected: String,
    boundary: Option<&SelectionBoundary>,
    include_prefix: bool,
    include_suffix: bool,
) -> String {
    let Some(boundary) = boundary else {
        return selected;
    };
    boundary.apply(selected, include_prefix, include_suffix)
}

/// Compute the selected column range for a given line based on anchor/head endpoints.
///
/// Shared implementation used by both active drag and persistent selection overlays:
/// - Single-line: `min(anchor_col, head_col)..max(anchor_col, head_col)+1`
/// - Multi-line first: `start_col..width`
/// - Multi-line last: `0..end_col+1`
/// - Multi-line middle: `0..width` (full line)
///
/// Returns `None` if the line falls outside the anchor/head range.
fn selected_cols_for_endpoints(
    anchor_block_line: usize,
    anchor_col: u16,
    head_block_line: usize,
    head_col: u16,
    line: &ResolvedSelectableLine,
) -> Option<Range<u16>> {
    let width = line
        .selectable_cols
        .end
        .saturating_sub(line.selectable_cols.start);
    let bl = line.block_line_idx;
    let start_bl = min(anchor_block_line, head_block_line);
    let end_bl = max(anchor_block_line, head_block_line);
    let anchor_is_start = anchor_block_line <= head_block_line;

    if bl < start_bl || bl > end_bl {
        return None;
    }

    if start_bl == end_bl {
        let start = min(anchor_col, head_col);
        let end = max(anchor_col, head_col).saturating_add(1);
        return Some(start.min(width)..end.min(width));
    }

    if bl == start_bl {
        let start = if anchor_is_start {
            anchor_col
        } else {
            head_col
        };
        return Some(start.min(width)..width);
    }
    if bl == end_bl {
        let end = if anchor_is_start {
            head_col
        } else {
            anchor_col
        };
        return Some(0..end.saturating_add(1).min(width));
    }
    Some(0..width)
}

fn selected_cols_for_line_by_block_idx(
    drag: &ActiveTextDrag,
    line: &ResolvedSelectableLine,
) -> Option<Range<u16>> {
    selected_cols_for_endpoints(
        drag.anchor.block_line_idx,
        drag.anchor.col_within_range,
        drag.head.block_line_idx,
        drag.head.col_within_range,
        line,
    )
}

/// Reconstruct the full selected text from the block's complete output lines.
///
/// Unlike [`reconstruct_selection_text`], which only has access to lines
/// currently visible on screen, this function reads from the block's full
/// output. This ensures copy produces the complete selection even when the
/// anchor or head has scrolled off-screen.
pub fn reconstruct_full_selection_text(
    block_lines: &[crate::scrollback::types::BlockLine],
    drag: &ActiveTextDrag,
) -> Option<String> {
    reconstruct_full_selection_text_with_boundaries(
        block_lines,
        &crate::scrollback::types::SelectionBoundaries::default(),
        drag,
    )
}

pub(crate) fn reconstruct_full_selection_text_with_boundaries(
    block_lines: &[crate::scrollback::types::BlockLine],
    boundaries: &crate::scrollback::types::SelectionBoundaries,
    drag: &ActiveTextDrag,
) -> Option<String> {
    use crate::scrollback::types::{derive_selection_text, selectable_cols};

    let start_bl = min(drag.anchor.block_line_idx, drag.head.block_line_idx);
    let end_bl = max(drag.anchor.block_line_idx, drag.head.block_line_idx);
    let anchor_is_start = drag.anchor.block_line_idx <= drag.head.block_line_idx;

    let mut out = String::new();
    let mut first = true;

    for (idx, line) in block_lines.iter().enumerate() {
        if idx < start_bl || idx > end_bl {
            continue;
        }
        // Only include lines that belong to the same selection range.
        if line.selection_range != Some(drag.anchor.range_id) {
            continue;
        }

        let text = derive_selection_text(line);
        let Some(cols) = selectable_cols(&line.content, &line.selectable) else {
            continue;
        };
        let width = cols.end.saturating_sub(cols.start);

        // Determine column slice for this line.
        let col_range = if start_bl == end_bl {
            let s = min(drag.anchor.col_within_range, drag.head.col_within_range);
            let e = max(drag.anchor.col_within_range, drag.head.col_within_range).saturating_add(1);
            s.min(width)..e.min(width)
        } else if idx == start_bl {
            let s = if anchor_is_start {
                drag.anchor.col_within_range
            } else {
                drag.head.col_within_range
            };
            s.min(width)..width
        } else if idx == end_bl {
            let e = if anchor_is_start {
                drag.head.col_within_range
            } else {
                drag.anchor.col_within_range
            };
            0..e.saturating_add(1).min(width)
        } else {
            0..width
        };

        if !first {
            out.push_str(line.joiner.as_deref().unwrap_or("\n"));
        }
        first = false;
        let selected = slice_text_cols(&text, col_range.clone());
        out.push_str(&apply_selection_boundary(
            selected,
            boundaries.get(idx).map(Arc::as_ref),
            col_range.start == 0,
            col_range.end == width,
        ));
    }

    if first {
        return None;
    }
    Some(out)
}

fn slice_text_cols(text: &str, cols: Range<u16>) -> String {
    crate::scrollback::types::slice_display_cols(text, cols.start, cols.end)
}

fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
    col >= rect.x
        && col < rect.x.saturating_add(rect.width)
        && row >= rect.y
        && row < rect.y.saturating_add(rect.height)
}

// ---------------------------------------------------------------------------
// Word / URL boundary detection (for double-click selection)
// ---------------------------------------------------------------------------

/// All printable ASCII punctuation except underscore, matching tmux's
/// `word-separators` default from `options-table.c`.
pub const DEFAULT_WORD_SEPARATORS: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~";

/// Load `[ui] word_separators` from config, falling back to [`DEFAULT_WORD_SEPARATORS`].
pub fn configured_word_separators() -> &'static str {
    static CACHED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    CACHED.get_or_init(|| {
        let root = match kigi_shell::config::load_effective_config() {
            Ok(r) => r,
            Err(_) => return DEFAULT_WORD_SEPARATORS.to_owned(),
        };
        root.get("ui")
            .and_then(|ui| ui.get("word_separators"))
            .and_then(|v| v.as_str())
            .unwrap_or(DEFAULT_WORD_SEPARATORS)
            .to_owned()
    })
}

/// Three-class partition for word boundary detection (whitespace / separator / word).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CharClass {
    Word,
    Whitespace,
    Separator,
}

fn classify_grapheme(grapheme: &str, separators: &str) -> CharClass {
    match grapheme.chars().next() {
        Some(c) if c.is_whitespace() => CharClass::Whitespace,
        Some(c) if separators.contains(c) => CharClass::Separator,
        _ => CharClass::Word,
    }
}

/// Find word boundaries around a display column using tmux-style three-class
/// grouping. Returns the range in display columns.
pub fn word_boundaries_at_col(text: &str, col: u16, separators: &str) -> Range<u16> {
    let mut segments: Vec<(u16, u16, CharClass)> = Vec::new();
    let mut current_col: u16 = 0;

    for grapheme in text.graphemes(true) {
        let width = UnicodeWidthStr::width(grapheme) as u16;
        if width == 0 {
            continue;
        }
        let next_col = current_col.saturating_add(width);
        segments.push((
            current_col,
            next_col,
            classify_grapheme(grapheme, separators),
        ));
        current_col = next_col;
    }

    if segments.is_empty() {
        return 0..0;
    }

    // If col is past the end, clamp to the last segment.
    let target_idx = segments
        .iter()
        .position(|(start, end, _)| col >= *start && col < *end)
        .unwrap_or(segments.len() - 1);

    let target_class = segments[target_idx].2;

    let mut left = target_idx;
    while left > 0 && segments[left - 1].2 == target_class {
        left -= 1;
    }

    let mut right = target_idx;
    while right + 1 < segments.len() && segments[right + 1].2 == target_class {
        right += 1;
    }

    segments[left].0..segments[right].1
}

/// Pre-compiled regex for URL detection, cached for the process lifetime.
static URL_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?i)\b(?:https?|ftp|file)://[^\s\x{00}-\x{1f}]+").expect("URL regex must compile")
});

/// Punctuation that may appear as trailing prose punctuation after a URL.
const TRAILING_URL_PUNCT: &[char] = &['.', ',', ':', ';', '!', '?', ')', ']', '}', '>', '"', '\''];

/// Strip trailing punctuation from a URL match, respecting balanced brackets.
///
/// Closing brackets are only stripped when unbalanced (no matching opener
/// inside the URL body), handling prose like `(see https://example.com)`.
fn strip_trailing_url_punctuation(url: &str) -> &str {
    let mut end = url.len();

    loop {
        let last = match url[..end].chars().next_back() {
            Some(c) if TRAILING_URL_PUNCT.contains(&c) => c,
            _ => break,
        };

        if let Some(open) = match last {
            ')' => Some('('),
            ']' => Some('['),
            '}' => Some('{'),
            '>' => Some('<'),
            _ => None,
        } {
            let opens = url[..end].chars().filter(|&c| c == open).count();
            let closes = url[..end].chars().filter(|&c| c == last).count();
            if opens >= closes {
                break;
            }
        }

        end -= last.len_utf8();
    }

    &url[..end]
}

/// Compute the display-column width of a string via grapheme clusters.
fn display_width(text: &str) -> u16 {
    text.graphemes(true).fold(0u16, |acc, g| {
        acc.saturating_add(UnicodeWidthStr::width(g) as u16)
    })
}

/// Try to find a URL that spans the given display column in `text`.
///
/// Scans `text` for URLs matching common schemes (`https?://`, `ftp://`,
/// `file://`) and returns the display-column range of the URL containing
/// `col`, or `None` if `col` is not within any URL.
///
/// Trailing punctuation (`.`, `,`, `)`, etc.) is stripped when unbalanced,
/// handling prose contexts like `"see https://example.com."`.
pub fn url_range_at_col(text: &str, col: u16) -> Option<Range<u16>> {
    for m in URL_RE.find_iter(text) {
        let col_start = display_width(&text[..m.start()]);
        let url = strip_trailing_url_punctuation(m.as_str());

        // Skip degenerate URLs reduced to just the scheme (e.g. "https://").
        if url.find("://").is_some_and(|i| url[i + 3..].is_empty()) {
            continue;
        }

        let col_end = col_start.saturating_add(display_width(url));

        if col >= col_start && col < col_end {
            return Some(col_start..col_end);
        }
    }

    None
}

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

    fn single_line_drag(block_line_idx: usize, width: u16) -> ActiveTextDrag {
        ActiveTextDrag {
            anchor: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx,
                col_within_range: 0,
            },
            head: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx,
                col_within_range: width - 1,
            },
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        }
    }

    #[test]
    fn ordinary_copy_uses_only_selected_visible_columns() {
        for (text, joiner, expected) in [
            ("world", Some(" ".to_string()), "world"),
            ("world   ", None, "world"),
        ] {
            let mut model = ResolvedSelectionModel::default();
            let boundaries = ResolvedSelectionBoundaries::default();
            model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 1,
                screen_y: 0,
                screen_x: 0,
                selectable_cols: 0..5,
                text: text.to_string(),
                joiner_to_previous: joiner.clone(),
            });
            assert!(boundaries.is_empty());

            assert_eq!(
                reconstruct_selection_text_with_boundaries(
                    &model,
                    &boundaries,
                    &single_line_drag(1, 5),
                ),
                Some(expected.to_string())
            );

            let block_line = crate::scrollback::types::BlockLine {
                content: ratatui::text::Line::from("world"),
                selectable: crate::scrollback::types::Selectable::All,
                selection_range: Some(0),
                selection_text: Some(text.to_string()),
                joiner,
                ..Default::default()
            };
            assert_eq!(
                reconstruct_full_selection_text(&[block_line], &single_line_drag(0, 5)),
                Some(expected.to_string())
            );
        }
    }

    #[test]
    fn edit_boundaries_apply_only_at_selected_path_edges() {
        use crate::scrollback::types::{
            BlockLine, Selectable, SelectionBoundaries, SelectionBoundary, SelectionBoundaryEntry,
        };

        let mut model = ResolvedSelectionModel::default();
        let mut resolved_boundaries = ResolvedSelectionBoundaries::default();
        for (idx, text, joiner, prefix, suffix) in [
            (1, "foo", None, "   ", ""),
            (2, "bar", Some(" ".to_string()), "", "   "),
        ] {
            let line = ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: idx,
                screen_y: idx as u16,
                screen_x: 0,
                selectable_cols: 0..3,
                text: text.to_string(),
                joiner_to_previous: joiner,
            };
            resolved_boundaries.push(
                &line,
                Arc::new(SelectionBoundary::new(
                    prefix.to_string(),
                    suffix.to_string(),
                )),
            );
            model.push_line(line);
        }
        let drag = ActiveTextDrag {
            anchor: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 1,
                col_within_range: 0,
            },
            head: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 2,
                col_within_range: 2,
            },
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        };
        assert_eq!(
            reconstruct_selection_text_with_boundaries(&model, &resolved_boundaries, &drag),
            Some("   foo bar   ".to_string())
        );

        let block_lines = vec![
            BlockLine::separator(ratatui::text::Line::from("Edit ")),
            BlockLine {
                content: ratatui::text::Line::from("foo"),
                selectable: crate::scrollback::types::Selectable::All,
                selection_range: Some(0),
                ..Default::default()
            },
            BlockLine {
                content: ratatui::text::Line::from("bar"),
                selectable: Selectable::All,
                selection_range: Some(0),
                joiner: Some(" ".to_string()),
                ..Default::default()
            },
        ];
        let boundaries = SelectionBoundaries::from_entries(vec![
            SelectionBoundaryEntry {
                line_index: 1,
                boundary: Arc::new(SelectionBoundary::new("   ".to_string(), String::new())),
            },
            SelectionBoundaryEntry {
                line_index: 2,
                boundary: Arc::new(SelectionBoundary::new(String::new(), "   ".to_string())),
            },
        ]);
        assert_eq!(
            reconstruct_full_selection_text_with_boundaries(&block_lines, &boundaries, &drag),
            Some("   foo bar   ".to_string())
        );
    }

    #[test]
    fn hit_test_selectable_range_returns_matching_hit() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 1,
            range_id: 7,
            block_line_idx: 0,
            screen_y: 3,
            screen_x: 10,
            selectable_cols: 2..6,
            text: "body".to_string(),
            joiner_to_previous: None,
        });

        let hit = model.hit_test_selectable_range(13, 3).unwrap();
        assert_eq!(hit.entry_idx, 1);
        assert_eq!(hit.range_id, 7);
        assert_eq!(hit.block_line_idx, 0);
        assert_eq!(hit.col_within_range, 1);
    }

    #[test]
    fn hit_test_selectable_range_clamps_to_nearest_column_on_same_row() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 1,
            range_id: 7,
            block_line_idx: 0,
            screen_y: 3,
            screen_x: 10,
            selectable_cols: 2..6,
            text: "body".to_string(),
            joiner_to_previous: None,
        });

        let left = model.hit_test_selectable_range(11, 3).unwrap();
        assert_eq!(left.col_within_range, 0);

        let right = model.hit_test_selectable_range(16, 3).unwrap();
        assert_eq!(right.col_within_range, 3);
    }

    // -----------------------------------------------------------------------
    // hit_test_nearest_in_range tests
    // -----------------------------------------------------------------------

    /// One selectable line for the nearest-in-range fixtures.
    fn nearest_line(
        range_id: u16,
        block_line_idx: usize,
        screen_y: u16,
        selectable_cols: Range<u16>,
    ) -> ResolvedSelectableLine {
        ResolvedSelectableLine {
            entry_idx: 0,
            range_id,
            block_line_idx,
            screen_y,
            screen_x: 4,
            selectable_cols,
            text: "text".to_string(),
            joiner_to_previous: None,
        }
    }

    fn range_anchor(range_id: u16, block_line_idx: usize) -> RangeHit {
        RangeHit {
            entry_idx: 0,
            range_id,
            block_line_idx,
            col_within_range: 0,
        }
    }

    /// Rows with a line of the anchor's range behave exactly like the
    /// range-restricted `hit_test_selectable_range`: exact col, and clamping
    /// to both ends.
    #[test]
    fn nearest_in_range_same_row_parity_with_selectable_range_hit() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(7, 0, 3, 2..6));
        let anchor = range_anchor(7, 0);

        for col in 0u16..14 {
            let nearest = model.hit_test_nearest_in_range(anchor, col, 3).unwrap();
            let direct = model.hit_test_selectable_range(col, 3).unwrap();
            assert_eq!(nearest, direct, "col {col} must match the same-row hit");
        }
    }

    /// A pointer on a dead row (no line of the range) snaps to the nearest
    /// row of the range instead of missing.
    #[test]
    fn nearest_in_range_snaps_across_gap_rows() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(0, 0, 2, 0..10));
        model.push_line(nearest_line(0, 2, 5, 0..10));
        let anchor = range_anchor(0, 0);

        // Row 3 is one row from the line at y=2 and two from y=5.
        let hit = model.hit_test_nearest_in_range(anchor, 6, 3).unwrap();
        assert_eq!(hit.block_line_idx, 0);
        assert_eq!(hit.col_within_range, 2);

        // Row 4 is nearer the line at y=5.
        let hit = model.hit_test_nearest_in_range(anchor, 6, 4).unwrap();
        assert_eq!(hit.block_line_idx, 2);
    }

    /// A pointer below the range's last line selects toward its end
    /// (native semantics) instead of freezing.
    #[test]
    fn nearest_in_range_snaps_from_below_last_line() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(0, 0, 2, 0..10));
        model.push_line(nearest_line(0, 1, 3, 0..8));
        let anchor = range_anchor(0, 0);

        let hit = model.hit_test_nearest_in_range(anchor, 30, 9).unwrap();
        assert_eq!(hit.block_line_idx, 1);
        // Pointer right of the line clamps to its last column.
        assert_eq!(hit.col_within_range, 7);

        let hit = model.hit_test_nearest_in_range(anchor, 1, 9).unwrap();
        assert_eq!(hit.block_line_idx, 1);
        // Pointer left of the line clamps to its first column.
        assert_eq!(hit.col_within_range, 0);
    }

    /// Lines of other ranges are never candidates, even on nearer rows —
    /// the head stays pinned to the anchor's range.
    #[test]
    fn nearest_in_range_ignores_other_ranges_on_nearer_rows() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(0, 0, 2, 0..10));
        model.push_line(nearest_line(1, 0, 6, 0..10));
        let anchor = range_anchor(0, 0);

        // Row 6 holds range 1's line; range 0's nearest is 4 rows away.
        let hit = model.hit_test_nearest_in_range(anchor, 3, 6).unwrap();
        assert_eq!(hit.range_id, 0);
        assert_eq!(hit.block_line_idx, 0);
    }

    /// A full (row, col) tie resolves to the line farther from the anchor,
    /// so a drag paused on a dead row keeps the selection extended across it.
    #[test]
    fn nearest_in_range_tie_prefers_line_farther_from_anchor() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(0, 0, 2, 0..10));
        model.push_line(nearest_line(0, 2, 4, 0..10));

        // Row 3 is equidistant; dragging down from line 0 crosses the gap.
        let hit = model
            .hit_test_nearest_in_range(range_anchor(0, 0), 5, 3)
            .unwrap();
        assert_eq!(hit.block_line_idx, 2);

        // Dragging up from line 2 crosses it the other way.
        let hit = model
            .hit_test_nearest_in_range(range_anchor(0, 2), 5, 3)
            .unwrap();
        assert_eq!(hit.block_line_idx, 0);
    }

    /// The range scrolled fully out of the model → no candidate; callers
    /// keep the previous head.
    #[test]
    fn nearest_in_range_misses_when_range_absent() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(nearest_line(1, 0, 2, 0..10));
        assert!(
            model
                .hit_test_nearest_in_range(range_anchor(0, 0), 5, 2)
                .is_none()
        );
    }

    #[test]
    fn hit_test_visible_block_uses_visible_geometry() {
        let block = VisibleBlockGeometry {
            entry_idx: 2,
            area: Rect::new(4, 5, 10, 3),
            content_area: Rect::new(7, 5, 6, 3),
            selection_area: Rect::new(3, 5, 12, 3),
            content_width: 6,
            top_clipped: false,
            bottom_clipped: false,
            drag_startable: true,
        };
        let model = ResolvedSelectionModel {
            visible_blocks: vec![block],
            ..Default::default()
        };

        let hit = model.hit_test_visible_block(8, 6).unwrap();
        assert_eq!(hit.entry_idx, 2);
        assert!(model.hit_test_visible_block(20, 20).is_none());
    }

    #[test]
    fn block_drag_overlay_inverts_selected_blocks() {
        let model = ResolvedSelectionModel {
            visible_blocks: vec![
                VisibleBlockGeometry {
                    entry_idx: 0,
                    area: Rect::new(0, 0, 5, 1),
                    content_area: Rect::new(2, 0, 3, 1),
                    selection_area: Rect::new(0, 0, 5, 1),
                    content_width: 3,
                    top_clipped: false,
                    bottom_clipped: false,
                    drag_startable: true,
                },
                VisibleBlockGeometry {
                    entry_idx: 1,
                    area: Rect::new(0, 1, 5, 1),
                    content_area: Rect::new(2, 1, 3, 1),
                    selection_area: Rect::new(0, 1, 5, 1),
                    content_width: 3,
                    top_clipped: false,
                    bottom_clipped: false,
                    drag_startable: true,
                },
                VisibleBlockGeometry {
                    entry_idx: 2,
                    area: Rect::new(0, 2, 5, 1),
                    content_area: Rect::new(2, 2, 3, 1),
                    selection_area: Rect::new(0, 2, 5, 1),
                    content_width: 3,
                    top_clipped: false,
                    bottom_clipped: false,
                    drag_startable: true,
                },
            ],
            ..Default::default()
        };
        let drag = ActiveBlockDrag {
            anchor_entry_idx: 0,
            head_entry_idx: 1,
        };
        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 3));
        render_block_drag_overlay(&model, &drag, &mut buf);
        // Blocks 0 and 1 should be inverted, block 2 should not.
        // Just verify it runs without panic — visual correctness
        // is validated by manual testing.
        assert_eq!(buf.area.height, 3);
    }

    #[test]
    fn autoscroll_above_viewport() {
        let area = Rect::new(0, 10, 80, 20);
        let result = compute_autoscroll(5, area);
        assert!(result.is_some());
        let s = result.unwrap();
        assert_eq!(s.direction, AutoScrollDirection::Up);
        assert!(s.speed >= 1);
    }

    #[test]
    fn autoscroll_below_viewport() {
        let area = Rect::new(0, 10, 80, 20);
        let result = compute_autoscroll(35, area);
        assert!(result.is_some());
        let s = result.unwrap();
        assert_eq!(s.direction, AutoScrollDirection::Down);
        assert!(s.speed >= 1);
    }

    #[test]
    fn autoscroll_inside_viewport() {
        let area = Rect::new(0, 10, 80, 20);
        assert!(compute_autoscroll(20, area).is_none());
    }

    #[test]
    fn autoscroll_near_top_edge() {
        let area = Rect::new(0, 10, 80, 20);
        // Row 11 is within EDGE_THRESHOLD (2) of top (10)
        let result = compute_autoscroll(11, area);
        assert!(result.is_some());
        assert_eq!(result.unwrap().direction, AutoScrollDirection::Up);
    }

    #[test]
    fn autoscroll_near_bottom_edge() {
        let area = Rect::new(0, 10, 80, 20);
        // Row 28 is within EDGE_THRESHOLD (2) of bottom (30)
        let result = compute_autoscroll(28, area);
        assert!(result.is_some());
        assert_eq!(result.unwrap().direction, AutoScrollDirection::Down);
    }

    #[test]
    fn autoscroll_speed_ramp() {
        // Farther from edge → faster speed
        assert!(speed_for_distance(1) < speed_for_distance(5));
        assert!(speed_for_distance(5) <= speed_for_distance(15));
    }

    #[test]
    fn block_drag_threshold() {
        let pending = PendingBlockDrag {
            anchor_entry_idx: 0,
            start_col: 5,
            start_row: 5,
        };
        assert!(!block_drag_threshold_exceeded(&pending, 5, 5));
        assert!(block_drag_threshold_exceeded(&pending, 6, 5));
        assert!(block_drag_threshold_exceeded(&pending, 5, 6));
    }

    /// Simulate selecting from the last line, then scrolling so the anchor
    /// scrolls off-screen. The overlay and copy should still work for all
    /// visible lines in the selection range.
    #[test]
    fn selection_survives_anchor_scrolling_off_screen() {
        // 10 lines, block_line_idx 0..9. Initially all visible.
        let lines: Vec<&str> = vec![
            "line zero",
            "line one",
            "line two",
            "line three",
            "line four",
            "line five",
            "line six",
            "line seven",
            "line eight",
            "line nine",
        ];

        // Build initial model with all 10 lines visible.
        let mut model = ResolvedSelectionModel::default();
        for (i, text) in lines.iter().enumerate() {
            model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: i,
                screen_y: i as u16,
                screen_x: 0,
                selectable_cols: 0..(text.len() as u16),
                text: text.to_string(),
                joiner_to_previous: if i > 0 { Some("\n".to_string()) } else { None },
            });
        }

        // User clicks on line 9 (last line).
        let anchor = model.hit_test_selectable_range(0, 9).unwrap();
        assert_eq!(anchor.block_line_idx, 9);

        // User drags to line 5.
        let head = model.hit_test_selectable_range(0, 5).unwrap();
        assert_eq!(head.block_line_idx, 5);

        let drag = ActiveTextDrag {
            anchor,
            head,
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        };

        // Selection overlay and copy work normally.
        // Both anchor (line 9) and head (line 5) are at col 0, so
        // the start line gets partial selection (from col 0 to end)
        // and the end line gets partial selection (from 0 to col 0+1).
        let text = reconstruct_selection_text(&model, &drag).unwrap();
        assert!(
            text.contains("line five"),
            "head line should be in selection"
        );
        assert!(
            text.contains("line eight"),
            "middle lines should be fully selected"
        );

        // Now simulate scroll: viewport shifts so only lines 0-6 are visible.
        // Lines 7-9 (including anchor at 9) are OFF SCREEN.
        let mut scrolled_model = ResolvedSelectionModel::default();
        for (i, line_text) in lines.iter().enumerate().take(7) {
            scrolled_model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: i,
                screen_y: i as u16,
                screen_x: 0,
                selectable_cols: 0..(line_text.len() as u16),
                text: line_text.to_string(),
                joiner_to_previous: if i > 0 { Some("\n".to_string()) } else { None },
            });
        }

        // The anchor (block_line_idx=9) is now off-screen. During autoscroll
        // the resolver misses there and the caller keeps the previous head
        // (pinned by active_drag_motion_miss_keeps_previous_head).
        let new_head = head; // head was at block_line_idx=5

        let scrolled_drag = ActiveTextDrag {
            anchor,
            head: new_head,
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        };

        // Overlay should highlight visible lines 5-6 (head=5, anchor=9 off-screen,
        // so range is 5..=9, visible portion is lines 5 and 6).
        let mut buf = Buffer::empty(Rect::new(0, 0, 30, 7));
        render_active_selection_overlay(&scrolled_model, &scrolled_drag, None, &mut buf);

        // Copy from the visible-only model produces text for visible lines in range.
        let text = reconstruct_selection_text(&scrolled_model, &scrolled_drag).unwrap();
        assert!(text.contains("line five"), "should contain head line");
        assert!(
            text.contains("line six"),
            "should contain visible line in range"
        );
        assert!(
            !text.contains("line zero"),
            "line before head not in selection"
        );
        // Off-screen lines aren't in the model, so they can't be in the text.
        assert!(!text.contains("line nine"), "off-screen line not in model");
    }

    // -----------------------------------------------------------------------
    // word_boundaries_at_col tests
    // -----------------------------------------------------------------------

    /// Shorthand for tests using the default tmux separator set.
    fn wb(text: &str, col: u16) -> Range<u16> {
        word_boundaries_at_col(text, col, DEFAULT_WORD_SEPARATORS)
    }

    #[test]
    fn word_boundaries_empty_string() {
        assert_eq!(wb("", 0), 0..0);
    }

    #[test]
    fn word_boundaries_single_word() {
        // "hello" → all Word class, col anywhere → 0..5
        assert_eq!(wb("hello", 0), 0..5);
        assert_eq!(wb("hello", 2), 0..5);
        assert_eq!(wb("hello", 4), 0..5);
    }

    #[test]
    fn word_boundaries_two_words() {
        // "hello world" → Word(0..5), Whitespace(5..6), Word(6..11)
        assert_eq!(wb("hello world", 0), 0..5);
        assert_eq!(wb("hello world", 4), 0..5);
        assert_eq!(wb("hello world", 5), 5..6);
        assert_eq!(wb("hello world", 6), 6..11);
        assert_eq!(wb("hello world", 10), 6..11);
    }

    #[test]
    fn word_boundaries_underscore_joins_words() {
        // "foo_bar" → all Word class
        assert_eq!(wb("foo_bar", 0), 0..7);
        assert_eq!(wb("foo_bar", 3), 0..7); // on '_'
        assert_eq!(wb("foo_bar", 6), 0..7);
    }

    #[test]
    fn word_boundaries_punctuation_run() {
        // "a---b" → Word(0..1), Separator(1..4), Word(4..5)
        assert_eq!(wb("a---b", 0), 0..1);
        assert_eq!(wb("a---b", 1), 1..4);
        assert_eq!(wb("a---b", 3), 1..4);
        assert_eq!(wb("a---b", 4), 4..5);
    }

    #[test]
    fn word_boundaries_whitespace_run() {
        // "a   b" → Word(0..1), Whitespace(1..4), Word(4..5)
        assert_eq!(wb("a   b", 1), 1..4);
        assert_eq!(wb("a   b", 3), 1..4);
    }

    #[test]
    fn word_boundaries_mixed_punct_and_word() {
        // "hello.world" → Word(0..5), Separator(5..6), Word(6..11)
        assert_eq!(wb("hello.world", 4), 0..5);
        assert_eq!(wb("hello.world", 5), 5..6);
        assert_eq!(wb("hello.world", 6), 6..11);
    }

    #[test]
    fn word_boundaries_line_start_and_end() {
        // " hello " → Whitespace(0..1), Word(1..6), Whitespace(6..7)
        assert_eq!(wb(" hello ", 0), 0..1);
        assert_eq!(wb(" hello ", 1), 1..6);
        assert_eq!(wb(" hello ", 6), 6..7);
    }

    #[test]
    fn word_boundaries_single_char() {
        assert_eq!(wb("a", 0), 0..1);
        assert_eq!(wb(".", 0), 0..1);
        assert_eq!(wb(" ", 0), 0..1);
    }

    #[test]
    fn word_boundaries_col_beyond_text() {
        // col past end clamps to last segment
        assert_eq!(wb("ab", 10), 0..2);
        assert_eq!(wb("a b", 10), 2..3);
    }

    #[test]
    fn word_boundaries_wide_char_cjk() {
        // CJK characters are not in DEFAULT_WORD_SEPARATORS and not whitespace,
        // so they are Word class (matching tmux). Each occupies 2 display cols.
        // "a\u{754c}b" → all Word class → 0..4
        assert_eq!(wb("a\u{754c}b", 0), 0..4);
        assert_eq!(wb("a\u{754c}b", 1), 0..4); // first col of wide char
        assert_eq!(wb("a\u{754c}b", 2), 0..4); // second col of wide char
        assert_eq!(wb("a\u{754c}b", 3), 0..4);
    }

    #[test]
    fn word_boundaries_consecutive_wide_chars() {
        // Two CJK chars → both Word class, grouped: Word(0..4)
        assert_eq!(wb("\u{754c}\u{4e16}", 0), 0..4);
        assert_eq!(wb("\u{754c}\u{4e16}", 2), 0..4);
    }

    #[test]
    fn word_boundaries_combining_mark() {
        // "e\u{0301}f" → graphemes: "e\u{0301}" (width 1) + "f" (width 1)
        // Both are Word class (not separators, not whitespace).
        assert_eq!(wb("e\u{0301}f", 0), 0..2);
        assert_eq!(wb("e\u{0301}f", 1), 0..2);
    }

    #[test]
    fn word_boundaries_digits_are_word_chars() {
        // "x86_64" → all Word
        assert_eq!(wb("x86_64", 0), 0..6);
        assert_eq!(wb("x86_64", 5), 0..6);
    }

    #[test]
    fn word_boundaries_mixed_separators_same_class_grouped() {
        // "-=" → both Separator, grouped as consecutive same-class
        assert_eq!(wb("-=", 0), 0..2);
        assert_eq!(wb("-=", 1), 0..2);
    }

    #[test]
    fn word_boundaries_tab_is_whitespace() {
        // Tab has width 1 via UnicodeWidthStr 0.2 and is classified as
        // Whitespace, so it separates adjacent Word segments:
        // Word(0..1), Whitespace(1..2), Word(2..3).
        assert_eq!(wb("a\tb", 0), 0..1);
        assert_eq!(wb("a\tb", 1), 1..2);
        assert_eq!(wb("a\tb", 2), 2..3);
    }

    #[test]
    fn word_boundaries_non_ascii_letters_are_word_chars() {
        // Non-ASCII letters group with adjacent ASCII word chars,
        // matching tmux behaviour where only ASCII punctuation is a separator.
        // "caf\u{e9}" → all Word class → 0..4
        assert_eq!(wb("caf\u{e9}", 0), 0..4);
        assert_eq!(wb("caf\u{e9}", 3), 0..4);
    }

    #[test]
    fn word_boundaries_cjk_between_separators() {
        // Separator, CJK (Word), Separator:
        // ".\u{754c}." → Separator(0..1), Word(1..3), Separator(3..4)
        assert_eq!(wb(".\u{754c}.", 0), 0..1);
        assert_eq!(wb(".\u{754c}.", 1), 1..3);
        assert_eq!(wb(".\u{754c}.", 3), 3..4);
    }

    #[test]
    fn word_boundaries_separator_chars_from_tmux_defaults() {
        // Verify all separator characters from DEFAULT_WORD_SEPARATORS are classified correctly.
        // Each should be its own Separator group when surrounded by Word chars.
        for sep in [
            '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '+', '=', '[', ']', '{', '}',
            '|', '\\', '/', ':', ';', '\'', '"', ',', '.', '<', '>', '?', '`', '~',
        ] {
            let text = format!("a{sep}b");
            assert_eq!(
                wb(&text, 0),
                0..1,
                "char '{sep}' should be a separator (Word before)",
            );
            assert_eq!(
                wb(&text, 1),
                1..2,
                "char '{sep}' should be its own Separator group",
            );
            assert_eq!(
                wb(&text, 2),
                2..3,
                "char '{sep}' should be a separator (Word after)",
            );
        }
    }

    #[test]
    fn word_boundaries_underscore_is_not_a_separator() {
        // Underscore is NOT in DEFAULT_WORD_SEPARATORS (matches tmux).
        // "a_b" → all Word → 0..3
        assert_eq!(wb("a_b", 0), 0..3);
        assert_eq!(wb("a_b", 1), 0..3);
        assert_eq!(wb("a_b", 2), 0..3);
    }

    #[test]
    fn word_boundaries_custom_separators_empty() {
        // Empty separators string → only whitespace breaks words.
        // "hello-world" with no separators → all Word → 0..11
        assert_eq!(word_boundaries_at_col("hello-world", 0, ""), 0..11);
        assert_eq!(word_boundaries_at_col("hello-world", 5, ""), 0..11);
        assert_eq!(word_boundaries_at_col("hello-world", 6, ""), 0..11);
        // "a.b@c" → all Word → 0..5
        assert_eq!(word_boundaries_at_col("a.b@c", 2, ""), 0..5);
    }

    #[test]
    fn word_boundaries_custom_separators_subset() {
        // Only "." and "/" are separators — hyphen and @ are word chars.
        let seps = "./";
        assert_eq!(word_boundaries_at_col("hello-world", 0, seps), 0..11);
        assert_eq!(word_boundaries_at_col("hello.world", 0, seps), 0..5);
        assert_eq!(word_boundaries_at_col("hello.world", 5, seps), 5..6);
        assert_eq!(word_boundaries_at_col("a/b", 0, seps), 0..1);
        assert_eq!(word_boundaries_at_col("user@host", 0, seps), 0..9);
    }

    // -----------------------------------------------------------------------
    // strip_trailing_url_punctuation tests
    // -----------------------------------------------------------------------

    #[test]
    fn strip_trailing_no_punctuation() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com/path"),
            "https://example.com/path"
        );
    }

    #[test]
    fn strip_trailing_period() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com."),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_comma() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com,"),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_multiple_punct() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com.)"),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_balanced_parens_kept() {
        assert_eq!(
            strip_trailing_url_punctuation(
                "https://en.wikipedia.org/wiki/Rust_(programming_language)"
            ),
            "https://en.wikipedia.org/wiki/Rust_(programming_language)"
        );
    }

    #[test]
    fn strip_trailing_unbalanced_close_paren() {
        // "(see https://example.com)" → regex captures "https://example.com)"
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com)"),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_balanced_then_unbalanced() {
        // URL has one ( and two ): the last ) is unbalanced
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com/wiki_(test))"),
            "https://example.com/wiki_(test)"
        );
    }

    #[test]
    fn strip_trailing_exclamation() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com!"),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_semicolon() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com;"),
            "https://example.com"
        );
    }

    #[test]
    fn strip_trailing_colon() {
        assert_eq!(
            strip_trailing_url_punctuation("https://example.com:"),
            "https://example.com"
        );
    }

    // -----------------------------------------------------------------------
    // url_range_at_col tests
    // -----------------------------------------------------------------------

    #[test]
    fn url_range_simple_https() {
        let text = "see https://example.com here";
        // "see " = 4 cols, URL = 19 cols → 4..23
        assert_eq!(url_range_at_col(text, 4), Some(4..23));
        assert_eq!(url_range_at_col(text, 10), Some(4..23));
        assert_eq!(url_range_at_col(text, 22), Some(4..23));
    }

    #[test]
    fn url_range_no_url() {
        assert_eq!(url_range_at_col("hello world", 0), None);
        assert_eq!(url_range_at_col("hello world", 5), None);
    }

    #[test]
    fn url_range_empty_string() {
        assert_eq!(url_range_at_col("", 0), None);
    }

    #[test]
    fn url_range_url_at_start() {
        let text = "https://example.com rest";
        assert_eq!(url_range_at_col(text, 0), Some(0..19));
    }

    #[test]
    fn url_range_url_at_end() {
        let text = "visit https://example.com";
        assert_eq!(url_range_at_col(text, 6), Some(6..25));
        assert_eq!(url_range_at_col(text, 24), Some(6..25));
    }

    #[test]
    fn url_range_trailing_period_stripped() {
        let text = "see https://example.com.";
        // URL match = "https://example.com." → stripped to "https://example.com"
        assert_eq!(url_range_at_col(text, 4), Some(4..23));
        assert_eq!(url_range_at_col(text, 22), Some(4..23));
        // Click ON the trailing period → past the stripped range → None
        assert_eq!(url_range_at_col(text, 23), None);
    }

    #[test]
    fn url_range_trailing_comma_stripped() {
        let text = "see https://example.com, more";
        assert_eq!(url_range_at_col(text, 4), Some(4..23));
        // Click on comma → None
        assert_eq!(url_range_at_col(text, 23), None);
    }

    #[test]
    fn url_range_balanced_parens_preserved() {
        let text = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
        let len = text.len() as u16;
        assert_eq!(url_range_at_col(text, 0), Some(0..len));
        assert_eq!(url_range_at_col(text, len - 1), Some(0..len));
    }

    #[test]
    fn url_range_unbalanced_paren_in_prose() {
        let text = "(see https://example.com)";
        // Match: "https://example.com)" → stripped to "https://example.com"
        assert_eq!(url_range_at_col(text, 5), Some(5..24));
        // Click on the trailing ')' → None
        assert_eq!(url_range_at_col(text, 24), None);
    }

    #[test]
    fn url_range_query_string() {
        let text = "https://example.com/path?q=1&r=2";
        assert_eq!(url_range_at_col(text, 0), Some(0..32));
    }

    #[test]
    fn url_range_fragment() {
        let text = "https://example.com/page#section";
        assert_eq!(url_range_at_col(text, 0), Some(0..32));
    }

    #[test]
    fn url_range_ftp_scheme() {
        let text = "get ftp://files.example.com/data";
        assert_eq!(url_range_at_col(text, 4), Some(4..32));
    }

    #[test]
    fn url_range_file_scheme() {
        let text = "open file:///tmp/readme.txt";
        assert_eq!(url_range_at_col(text, 5), Some(5..27));
    }

    #[test]
    fn url_range_case_insensitive() {
        let text = "HTTP://EXAMPLE.COM/PATH";
        assert_eq!(url_range_at_col(text, 0), Some(0..23));
    }

    #[test]
    fn url_range_multiple_urls() {
        let text = "see https://a.com and https://b.com";
        // First URL: col 4..17
        assert_eq!(url_range_at_col(text, 4), Some(4..17));
        // Between URLs: None
        assert_eq!(url_range_at_col(text, 18), None);
        // Second URL: col 22..35
        assert_eq!(url_range_at_col(text, 22), Some(22..35));
    }

    #[test]
    fn url_range_col_outside_text() {
        let text = "https://example.com";
        assert_eq!(url_range_at_col(text, 100), None);
    }

    #[test]
    fn url_range_not_a_url_scheme() {
        // "notascheme://foo" should not match
        assert_eq!(url_range_at_col("notascheme://foo", 0), None);
    }

    #[test]
    fn url_range_url_with_port() {
        let text = "http://localhost:8080/api";
        assert_eq!(url_range_at_col(text, 0), Some(0..25));
    }

    #[test]
    fn url_range_degenerate_scheme_only_skipped() {
        // "https://." → regex matches "https://." → strip produces "https://"
        // which is scheme-only and should be skipped.
        assert_eq!(url_range_at_col("see https://.", 4), None);
    }

    #[test]
    fn url_range_combined_with_word_boundaries() {
        // Simulates the double-click fallback: url_range_at_col → word_boundaries_at_col
        let text = "click https://example.com/path or this_word";
        // On URL → url_range returns the range
        let url = url_range_at_col(text, 6);
        assert!(url.is_some());
        // On plain word → url_range returns None, word_boundaries takes over
        let non_url_col = 36; // inside "this_word"
        assert_eq!(url_range_at_col(text, non_url_col), None);
        assert_eq!(wb(text, non_url_col), 34..43);
    }

    // -----------------------------------------------------------------------
    // selected_cols_for_endpoints tests
    // -----------------------------------------------------------------------

    fn make_test_line(
        block_line_idx: usize,
        selectable_cols: Range<u16>,
    ) -> ResolvedSelectableLine {
        ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx,
            screen_y: block_line_idx as u16,
            screen_x: 0,
            selectable_cols,
            text: String::new(),
            joiner_to_previous: None,
        }
    }

    #[test]
    fn endpoints_single_line_selection() {
        let line = make_test_line(5, 0..20);
        assert_eq!(selected_cols_for_endpoints(5, 3, 5, 10, &line), Some(3..11));
    }

    #[test]
    fn endpoints_single_line_anchor_after_head() {
        let line = make_test_line(5, 0..20);
        // head before anchor produces same result (min/max)
        assert_eq!(selected_cols_for_endpoints(5, 10, 5, 3, &line), Some(3..11));
    }

    #[test]
    fn endpoints_single_line_same_col() {
        let line = make_test_line(5, 0..20);
        assert_eq!(selected_cols_for_endpoints(5, 7, 5, 7, &line), Some(7..8));
    }

    #[test]
    fn endpoints_multi_line_first_line() {
        let line = make_test_line(2, 0..15);
        // anchor=2, head=5: line 2 is first, selects anchor_col..width
        assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(4..15));
    }

    #[test]
    fn endpoints_multi_line_last_line() {
        let line = make_test_line(5, 0..15);
        // anchor=2, head=5: line 5 is last, selects 0..head_col+1
        assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(0..11));
    }

    #[test]
    fn endpoints_multi_line_middle_line() {
        let line = make_test_line(3, 0..15);
        // anchor=2, head=5: line 3 is middle, selects 0..width (full line)
        assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(0..15));
    }

    #[test]
    fn endpoints_multi_line_reversed_anchor_after_head() {
        // anchor=5, head=2 (reversed): first line (2) uses head_col, last line (5) uses anchor_col
        let first = make_test_line(2, 0..15);
        assert_eq!(
            selected_cols_for_endpoints(5, 10, 2, 4, &first),
            Some(4..15)
        );
        let last = make_test_line(5, 0..15);
        assert_eq!(selected_cols_for_endpoints(5, 10, 2, 4, &last), Some(0..11));
        let middle = make_test_line(3, 0..15);
        assert_eq!(
            selected_cols_for_endpoints(5, 10, 2, 4, &middle),
            Some(0..15)
        );
    }

    #[test]
    fn endpoints_line_outside_range_returns_none() {
        let line = make_test_line(10, 0..20);
        assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), None);

        let before = make_test_line(0, 0..20);
        assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &before), None);
    }

    #[test]
    fn endpoints_width_clamping_single_line() {
        let line = make_test_line(5, 0..10);
        // head_col 20 exceeds width 10, clamped to width
        assert_eq!(selected_cols_for_endpoints(5, 3, 5, 20, &line), Some(3..10));
    }

    #[test]
    fn endpoints_width_clamping_both_cols_beyond() {
        let line = make_test_line(5, 0..5);
        // Both cols 15 and 20 exceed width 5 -> clamped to 5..5 (empty range)
        assert_eq!(selected_cols_for_endpoints(5, 15, 5, 20, &line), Some(5..5));
    }

    #[test]
    fn endpoints_width_clamping_first_line() {
        let line = make_test_line(2, 0..8);
        // anchor=2 (first line), col=12 exceeds width=8 -> 8..8
        assert_eq!(selected_cols_for_endpoints(2, 12, 5, 3, &line), Some(8..8));
    }

    #[test]
    fn endpoints_width_clamping_last_line() {
        let line = make_test_line(5, 0..8);
        // head=5 (last line), col=20 exceeds width=8 -> 0..8
        assert_eq!(selected_cols_for_endpoints(2, 3, 5, 20, &line), Some(0..8));
    }

    #[test]
    fn endpoints_nonzero_selectable_start() {
        // selectable_cols 5..15 means width = 10
        let line = make_test_line(3, 5..15);
        assert_eq!(selected_cols_for_endpoints(3, 2, 3, 7, &line), Some(2..8));
    }

    #[test]
    fn endpoints_wrapper_matches_direct_call() {
        // Verify selected_cols_for_line_by_block_idx produces the same result
        // as a direct call to selected_cols_for_endpoints with the drag fields.
        let line = make_test_line(3, 0..20);
        let drag = ActiveTextDrag {
            anchor: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 2,
                col_within_range: 5,
            },
            head: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 4,
                col_within_range: 12,
            },
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        };
        let via_wrapper = selected_cols_for_line_by_block_idx(&drag, &line);
        let via_direct = selected_cols_for_endpoints(2, 5, 4, 12, &line);
        assert_eq!(via_wrapper, via_direct);
        assert_eq!(via_wrapper, Some(0..20));
    }

    // -----------------------------------------------------------------------
    // render_persistent_selection_overlay tests
    // -----------------------------------------------------------------------

    /// Marker foreground: test themes quantize to no color, so the
    /// highlight takes its reverse-video path (a modifier change).
    fn paint_marker(buf: &mut Buffer) {
        for y in buf.area.y..buf.area.y.saturating_add(buf.area.height) {
            for x in buf.area.x..buf.area.x.saturating_add(buf.area.width) {
                if let Some(cell) = buf.cell_mut((x, y)) {
                    cell.set_fg(Color::White);
                }
            }
        }
    }

    /// Returns true if the cell at `(x, y)` was modified by the overlay
    /// relative to the baseline buffer.
    fn cell_was_modified(buf: &Buffer, baseline: &Buffer, x: u16, y: u16) -> bool {
        let cell = buf.cell((x, y)).unwrap();
        let orig = baseline.cell((x, y)).unwrap();
        (cell.fg, cell.bg, cell.modifier) != (orig.fg, orig.bg, orig.modifier)
    }

    #[test]
    fn persistent_overlay_renders_multi_line_correctly() {
        let mut model = ResolvedSelectionModel::default();
        for i in 0..3u16 {
            model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: i as usize,
                screen_y: i,
                screen_x: 2,
                selectable_cols: 0..10,
                text: format!("line {i}"),
                joiner_to_previous: if i > 0 { Some("\n".into()) } else { None },
            });
        }
        // anchor=(line 0, col 3), head=(line 2, col 5)
        let sel = PersistentTextSelection {
            entry_idx: 0,
            range_id: 0,
            anchor: SelectionEndpoint {
                block_line_idx: 0,
                col_within_range: 3,
            },
            head: SelectionEndpoint {
                block_line_idx: 2,
                col_within_range: 5,
            },
            origin: SelectionOrigin::Drag,
            kind: SelectionKind::Linear,
        };
        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
        paint_marker(&mut buf);
        let baseline = buf.clone();
        render_persistent_selection_overlay(&model, &sel, None, &mut buf);

        // Line 0 (first): cols 3..10 → screen_x 2+3=5 through 2+9=11
        assert!(!cell_was_modified(&buf, &baseline, 4, 0));
        for x in 5..12 {
            assert!(
                cell_was_modified(&buf, &baseline, x, 0),
                "line 0, screen_x={x} should be inverted"
            );
        }
        assert!(!cell_was_modified(&buf, &baseline, 12, 0));

        // Line 1 (middle): cols 0..10 → screen_x 2..12
        assert!(!cell_was_modified(&buf, &baseline, 1, 1));
        for x in 2..12 {
            assert!(
                cell_was_modified(&buf, &baseline, x, 1),
                "line 1, screen_x={x} should be inverted"
            );
        }
        assert!(!cell_was_modified(&buf, &baseline, 12, 1));

        // Line 2 (last): cols 0..6 → screen_x 2..8
        assert!(!cell_was_modified(&buf, &baseline, 1, 2));
        for x in 2..8 {
            assert!(
                cell_was_modified(&buf, &baseline, x, 2),
                "line 2, screen_x={x} should be inverted"
            );
        }
        assert!(!cell_was_modified(&buf, &baseline, 8, 2));
    }

    #[test]
    fn persistent_overlay_nonexistent_range_is_noop() {
        let model = ResolvedSelectionModel::default();
        let sel = PersistentTextSelection {
            entry_idx: 99,
            range_id: 5,
            anchor: SelectionEndpoint {
                block_line_idx: 0,
                col_within_range: 0,
            },
            head: SelectionEndpoint {
                block_line_idx: 0,
                col_within_range: 5,
            },
            origin: SelectionOrigin::DoubleClick,
            kind: SelectionKind::Linear,
        };
        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
        paint_marker(&mut buf);
        let baseline = buf.clone();
        render_persistent_selection_overlay(&model, &sel, None, &mut buf);
        assert_eq!(buf, baseline);
    }

    #[test]
    fn persistent_overlay_single_line_word_selection() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 0,
            screen_x: 5,
            selectable_cols: 0..20,
            text: "hello world foo bar".into(),
            joiner_to_previous: None,
        });
        // Selecting cols 6..11 ("world") on a line at screen_x=5.
        let sel = PersistentTextSelection {
            entry_idx: 0,
            range_id: 0,
            anchor: SelectionEndpoint {
                block_line_idx: 0,
                col_within_range: 6,
            },
            head: SelectionEndpoint {
                block_line_idx: 0,
                col_within_range: 10,
            },
            origin: SelectionOrigin::DoubleClick,
            kind: SelectionKind::Linear,
        };
        let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1));
        paint_marker(&mut buf);
        let baseline = buf.clone();
        render_persistent_selection_overlay(&model, &sel, None, &mut buf);

        // screen cols: 5+0+6=11 through 5+0+10=15 (cols 6..11 = values 6,7,8,9,10)
        assert!(!cell_was_modified(&buf, &baseline, 10, 0));
        for x in 11..16 {
            assert!(
                cell_was_modified(&buf, &baseline, x, 0),
                "screen_x={x} should be inverted"
            );
        }
        assert!(!cell_was_modified(&buf, &baseline, 16, 0));
    }

    #[test]
    fn persistent_and_active_overlay_agree_on_same_endpoints() {
        // Pin theme to avoid races with parallel tests that call `cache::set`.
        crate::theme::cache::set(crate::theme::ThemeKind::KigiNight);
        let mut model = ResolvedSelectionModel::default();
        for i in 0..4u16 {
            model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: i as usize,
                screen_y: i,
                screen_x: 0,
                selectable_cols: 0..15,
                text: format!("line {i} content"),
                joiner_to_previous: if i > 0 { Some("\n".into()) } else { None },
            });
        }
        let drag = ActiveTextDrag {
            anchor: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 1,
                col_within_range: 3,
            },
            head: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 3,
                col_within_range: 8,
            },
            kind: SelectionKind::Linear,
            anchor_content_width: None,
        };
        let sel = PersistentTextSelection {
            entry_idx: 0,
            range_id: 0,
            anchor: SelectionEndpoint {
                block_line_idx: 1,
                col_within_range: 3,
            },
            head: SelectionEndpoint {
                block_line_idx: 3,
                col_within_range: 8,
            },
            origin: SelectionOrigin::Drag,
            kind: SelectionKind::Linear,
        };
        // Render both overlays into separate buffers and compare results.
        let mut buf_active = Buffer::empty(Rect::new(0, 0, 20, 4));
        let mut buf_persist = Buffer::empty(Rect::new(0, 0, 20, 4));
        render_active_selection_overlay(&model, &drag, None, &mut buf_active);
        render_persistent_selection_overlay(&model, &sel, None, &mut buf_persist);

        for y in 0..4u16 {
            for x in 0..20u16 {
                let a = buf_active.cell((x, y)).unwrap();
                let p = buf_persist.cell((x, y)).unwrap();
                assert_eq!((a.fg, a.bg), (p.fg, p.bg), "mismatch at ({x}, {y})");
            }
        }
    }

    // hit_test_text_exact tests

    #[test]
    fn hit_test_text_exact_returns_hit_on_selectable_column() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 5,
            screen_x: 4,
            selectable_cols: 2..10,
            text: "hello wo".to_string(),
            joiner_to_previous: None,
        });

        // Column 6 = screen_x(4) + selectable_cols.start(2) = within range.
        let hit = model.hit_test_text_exact(6, 5).unwrap();
        assert_eq!(hit.entry_idx, 0);
        assert_eq!(hit.range_id, 0);
        assert_eq!(hit.block_line_idx, 0);
        assert_eq!(hit.col_within_range, 0);

        // Column 10 = screen_x(4) + 6 → col_within_range = 4.
        let hit = model.hit_test_text_exact(10, 5).unwrap();
        assert_eq!(hit.col_within_range, 4);
    }

    #[test]
    fn hit_test_text_exact_returns_none_outside_selectable_cols() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 5,
            screen_x: 4,
            selectable_cols: 2..6,
            text: "body".to_string(),
            joiner_to_previous: None,
        });

        // Click on accent bar (col 4, which is screen_x but before selectable_cols.start).
        assert!(model.hit_test_text_exact(4, 5).is_none());
        assert!(model.hit_test_text_exact(5, 5).is_none());

        // Click past end of selectable cols (col 10 = 4 + 6).
        assert!(model.hit_test_text_exact(10, 5).is_none());

        // Wrong row.
        assert!(model.hit_test_text_exact(7, 4).is_none());
    }

    #[test]
    fn hit_test_text_exact_vs_nearest_range_comparison() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 3,
            screen_x: 10,
            selectable_cols: 2..6,
            text: "body".to_string(),
            joiner_to_previous: None,
        });

        // Click at col 11 (within screen_x but before selectable start 12).
        // Nearest-range should return a hit (clamped), exact should return None.
        let nearest = model.hit_test_selectable_range(11, 3);
        assert!(nearest.is_some());
        assert!(model.hit_test_text_exact(11, 3).is_none());

        // Click at col 12 (selectable start). Both should succeed.
        assert!(model.hit_test_selectable_range(12, 3).is_some());
        assert!(model.hit_test_text_exact(12, 3).is_some());
    }

    #[test]
    fn hit_test_text_exact_multiple_ranges_same_row() {
        let mut model = ResolvedSelectionModel::default();
        model.push_line(ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 2,
            screen_x: 0,
            selectable_cols: 0..5,
            text: "hello".to_string(),
            joiner_to_previous: None,
        });
        model.push_line(ResolvedSelectableLine {
            entry_idx: 1,
            range_id: 1,
            block_line_idx: 0,
            screen_y: 2,
            screen_x: 10,
            selectable_cols: 0..5,
            text: "world".to_string(),
            joiner_to_previous: None,
        });

        // Hit on first range.
        let hit = model.hit_test_text_exact(2, 2).unwrap();
        assert_eq!(hit.entry_idx, 0);
        assert_eq!(hit.col_within_range, 2);

        // Gap between ranges — no exact hit.
        assert!(model.hit_test_text_exact(7, 2).is_none());

        // Hit on second range.
        let hit = model.hit_test_text_exact(12, 2).unwrap();
        assert_eq!(hit.entry_idx, 1);
        assert_eq!(hit.col_within_range, 2);
    }

    // ── Table-aware selection ────────────────────────────────────────────

    const TABLE_LINES: &[&str] = &[
        "┌─────────┬────────┐",
        "│ Name    │ Role   │",
        "├─────────┼────────┤",
        "│ Alice   │ Eng    │",
        "│ Smith   │        │",
        "├─────────┼────────┤",
        "│ Bob     │ Design │",
        "└─────────┴────────┘",
    ];

    fn table_text_at(i: usize) -> Option<String> {
        TABLE_LINES.get(i).map(|s| s.to_string())
    }

    fn table_geometry() -> TableGeometry {
        TableGeometry::detect(table_text_at, 3).expect("grid")
    }

    fn table_model() -> ResolvedSelectionModel {
        let mut model = ResolvedSelectionModel::default();
        for (i, text) in TABLE_LINES.iter().enumerate() {
            model.push_line(ResolvedSelectableLine {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: i,
                screen_y: i as u16,
                screen_x: 0,
                selectable_cols: 0..(text.chars().count() as u16),
                text: text.to_string(),
                joiner_to_previous: None,
            });
        }
        model
    }

    fn table_hit(block_line_idx: usize, col_within_range: u16) -> RangeHit {
        RangeHit {
            entry_idx: 0,
            range_id: 0,
            block_line_idx,
            col_within_range,
        }
    }

    fn table_drag(anchor: (usize, u16), head: (usize, u16), kind: SelectionKind) -> ActiveTextDrag {
        ActiveTextDrag {
            anchor: table_hit(anchor.0, anchor.1),
            head: table_hit(head.0, head.1),
            kind,
            anchor_content_width: None,
        }
    }

    #[test]
    fn resolve_kind_same_cell_vs_grid_vs_border() {
        let geom = table_geometry();
        let linear = SelectionKind::Linear;
        // Same cell (Name/Alice) → TableCell, even across its wrapped line.
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &table_hit(3, 3), &table_hit(4, 6), linear),
            SelectionKind::TableCell
        );
        // Reaching the Role column's content → TableGrid carrying that cell.
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &table_hit(3, 3), &table_hit(3, 14), linear),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 1, col: 0 },
                head: CellRef { row: 1, col: 1 },
            }
        );
        // Head on the bottom border latches back to the anchor cell.
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &table_hit(6, 3), &table_hit(7, 3), linear),
            SelectionKind::TableCell
        );
        // Grid-line anchors stay Linear (the line-by-line escape hatch for
        // selecting the rendered table text); whole-table TSV is the
        // triple-click gesture instead.
        for anchor_line in [0, 2, 5, 7] {
            assert_eq!(
                resolve_table_drag_kind(
                    Some(&geom),
                    &table_hit(anchor_line, 3),
                    &table_hit(3, 3),
                    linear
                ),
                SelectionKind::Linear,
                "border anchor line {anchor_line}"
            );
        }
        // No geometry → Linear.
        assert_eq!(
            resolve_table_drag_kind(None, &table_hit(3, 3), &table_hit(4, 3), linear),
            SelectionKind::Linear
        );
    }

    #[test]
    fn resolve_kind_dead_zone_and_hysteresis() {
        let geom = table_geometry();
        let anchor = table_hit(3, 3); // Name/Alice cell
        let cell = SelectionKind::TableCell;
        // Junction column (10), own padding (9), and the neighbor's padding
        // (11) keep the cell selection — no escalation on a small overshoot.
        for col in [9, 10, 11] {
            assert_eq!(
                resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, col), cell),
                SelectionKind::TableCell,
                "boundary col {col} must not escalate"
            );
        }
        // The divider row below keeps the cell selection too.
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(5, 3), cell),
            SelectionKind::TableCell
        );
        // Neighbor content escalates; boundary touches then keep the grid
        // (and its head cell) instead of flickering back.
        let grid = resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 14), cell);
        let anchor_cell = CellRef { row: 1, col: 0 };
        let head = CellRef { row: 1, col: 1 };
        assert_eq!(
            grid,
            SelectionKind::TableGrid {
                anchor: anchor_cell,
                head
            }
        );
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 10), grid),
            SelectionKind::TableGrid {
                anchor: anchor_cell,
                head
            }
        );
        // Returning into the anchor cell's content de-escalates.
        assert_eq!(
            resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 5), grid),
            SelectionKind::TableCell
        );
    }

    #[test]
    fn reconstruct_cell_selection_joins_wrapped_fragments() {
        let geom = table_geometry();
        // Whole Name cell of the wrapped row (lines 3-4).
        let drag = table_drag((3, 1), (4, 9), SelectionKind::TableCell);
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Alice Smith".to_string())
        );
        // Reversed endpoints produce the same text.
        let drag = table_drag((4, 9), (3, 1), SelectionKind::TableCell);
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Alice Smith".to_string())
        );
        // Partial selection within the cell respects the columns
        // (cols 2..=4 of "│ Alice" are "Ali").
        let drag = table_drag((3, 2), (3, 4), SelectionKind::TableCell);
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Ali".to_string())
        );
    }

    #[test]
    fn reconstruct_grid_selection_as_tsv() {
        let geom = table_geometry();
        // Column drag down the Name column: header row through Bob.
        let drag = table_drag(
            (1, 3),
            (6, 3),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 0, col: 0 },
                head: CellRef { row: 2, col: 0 },
            },
        );
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Name\nAlice Smith\nBob".to_string())
        );
        // Rectangle over both columns of the body rows (reversed endpoints).
        let drag = table_drag(
            (6, 14),
            (3, 3),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 2, col: 1 },
                head: CellRef { row: 1, col: 0 },
            },
        );
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Alice Smith\tEng\nBob\tDesign".to_string())
        );
        // Whole-table selection (grid-line anchor): every cell as TSV.
        let drag = table_drag(
            (2, 3),
            (3, 3),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 0, col: 0 },
                head: CellRef { row: 2, col: 1 },
            },
        );
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Name\tRole\nAlice Smith\tEng\nBob\tDesign".to_string())
        );
        // Empty cell lands as an empty TSV field (constructed grid state;
        // resolution itself would keep this same-cell drag a TableCell).
        let drag = table_drag(
            (3, 12),
            (4, 12),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 1, col: 1 },
                head: CellRef { row: 1, col: 1 },
            },
        );
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            Some("Eng".to_string())
        );
    }

    #[test]
    fn linear_drag_ignores_table_reconstruction() {
        let geom = table_geometry();
        let drag = table_drag((3, 3), (4, 3), SelectionKind::Linear);
        assert_eq!(
            reconstruct_table_selection_text(&geom, &drag, table_text_at),
            None
        );
    }

    #[test]
    fn table_overlay_paints_bands_not_borders() {
        let geom = table_geometry();
        let model = table_model();
        let area = Rect::new(0, 0, 25, 8);

        // Grid selection over both columns of the wrapped row + Bob row.
        let drag = table_drag(
            (3, 3),
            (6, 14),
            SelectionKind::TableGrid {
                anchor: CellRef { row: 1, col: 0 },
                head: CellRef { row: 2, col: 1 },
            },
        );
        let mut buf = Buffer::empty(area);
        paint_marker(&mut buf);
        let baseline = buf.clone();
        render_active_selection_overlay(&model, &drag, Some(&geom), &mut buf);

        // Cell interiors of selected rows are painted...
        assert!(
            cell_was_modified(&buf, &baseline, 2, 3),
            "Name band, Alice row"
        );
        assert!(
            cell_was_modified(&buf, &baseline, 12, 6),
            "Role band, Bob row"
        );
        assert!(
            cell_was_modified(&buf, &baseline, 2, 4),
            "Name band, wrapped row"
        );
        // ...while padding spaces and all-blank fragments are clipped out...
        assert!(
            !cell_was_modified(&buf, &baseline, 1, 3),
            "padding before Alice"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 8, 3),
            "padding after Alice"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 12, 4),
            "blank Role fragment of the wrapped row"
        );
        // ...and border columns, border rows, and unselected rows are not.
        for y in 0..8u16 {
            assert!(
                !cell_was_modified(&buf, &baseline, 0, y),
                "left border, y={y}"
            );
            assert!(
                !cell_was_modified(&buf, &baseline, 10, y),
                "mid border, y={y}"
            );
            assert!(
                !cell_was_modified(&buf, &baseline, 19, y),
                "right border, y={y}"
            );
        }
        for x in 0..20u16 {
            assert!(
                !cell_was_modified(&buf, &baseline, x, 0),
                "top border, x={x}"
            );
            assert!(
                !cell_was_modified(&buf, &baseline, x, 1),
                "header row, x={x}"
            );
            assert!(!cell_was_modified(&buf, &baseline, x, 2), "divider, x={x}");
            assert!(!cell_was_modified(&buf, &baseline, x, 5), "divider, x={x}");
            assert!(
                !cell_was_modified(&buf, &baseline, x, 7),
                "bottom border, x={x}"
            );
        }

        // A table-shaped selection with no geometry paints nothing.
        let mut buf2 = Buffer::empty(area);
        paint_marker(&mut buf2);
        render_active_selection_overlay(&model, &drag, None, &mut buf2);
        for y in 0..8u16 {
            for x in 0..25u16 {
                assert!(!cell_was_modified(&buf2, &baseline, x, y));
            }
        }
    }

    #[test]
    fn table_cell_overlay_clamps_to_band() {
        let geom = table_geometry();
        let model = table_model();
        let area = Rect::new(0, 0, 25, 8);

        // Whole Name cell on the wrapped row.
        let drag = table_drag((3, 1), (4, 9), SelectionKind::TableCell);
        let mut buf = Buffer::empty(area);
        paint_marker(&mut buf);
        let baseline = buf.clone();
        render_active_selection_overlay(&model, &drag, Some(&geom), &mut buf);

        assert!(
            cell_was_modified(&buf, &baseline, 2, 3),
            "band on fragment 1"
        );
        assert!(
            cell_was_modified(&buf, &baseline, 2, 4),
            "band on fragment 2"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 0, 3),
            "left border untouched"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 10, 3),
            "mid border untouched"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 12, 3),
            "other column untouched"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 2, 6),
            "other row untouched"
        );
        // Content clipping: the drag endpoints sit in the padding (cols 1
        // and 9) but only "Alice" / "Smith" glyph columns paint.
        assert!(
            !cell_was_modified(&buf, &baseline, 1, 3),
            "leading padding untouched"
        );
        assert!(
            !cell_was_modified(&buf, &baseline, 7, 4),
            "trailing padding untouched"
        );
    }

    #[test]
    fn clip_cols_to_content_trims_padding_and_blanks() {
        //        0123456789
        let text = "│ Quick Fox │";
        // Full band (1..12) clips to the content span, keeping the interior space.
        assert_eq!(clip_cols_to_content(text, 1..12), 2..11);
        // A sub-range starting in padding clips its leading edge only.
        assert_eq!(clip_cols_to_content(text, 1..7), 2..7);
        // All-blank ranges collapse to empty.
        assert_eq!(clip_cols_to_content("│        │", 1..9), 1..1);
        assert_eq!(clip_cols_to_content("", 0..5), 0..0);
        // Wide glyphs count display columns.
        assert_eq!(clip_cols_to_content("│ 名前 │", 1..6), 2..6);
    }
}
