use crate::model::buffer::Buffer;
use crate::model::cursor::Cursor;
use crate::primitives::display_width::{char_width, str_width};
use crate::primitives::line_wrapping::WrapConfig;
use crate::view::ui::view_pipeline::{LineStart, ViewLine};
/// The viewport - what portion of the buffer is visible
#[derive(Debug, Clone)]
pub struct Viewport {
    /// Byte position of the first visible line
    /// **This is the authoritative source of truth for all viewport operations**
    /// The line number for this byte is obtained from Buffer's LineCache
    pub top_byte: usize,

    /// View line offset within the current top_byte position
    /// Used when virtual lines precede source content at top_byte.
    /// For example, if top_byte=0 and there are 120 virtual lines before
    /// source line 1, top_view_line_offset=100 means skip the first 100
    /// virtual lines and start rendering from virtual line 101.
    pub top_view_line_offset: usize,

    /// Left column offset (horizontal scroll position)
    pub left_column: usize,

    /// Terminal dimensions
    pub width: u16,
    pub height: u16,

    /// Scroll offset (lines to keep visible above/below cursor)
    pub scroll_offset: usize,

    /// Horizontal scroll offset (columns to keep visible left/right of cursor)
    pub horizontal_scroll_offset: usize,

    /// Whether line wrapping is enabled
    /// When true, horizontal scrolling is disabled
    pub line_wrap_enabled: bool,

    /// Whether wrapped continuation lines should be indented to match leading whitespace
    pub wrap_indent: bool,

    /// Column at which to wrap lines (None = viewport width)
    pub wrap_column: Option<usize>,

    /// Compose-mode page width override.  When `Some(cw)` and the
    /// viewport is wider than `cw`, the renderer wraps content at
    /// `cw` columns and centers it inside the split.  Mirrors
    /// `SplitViewState::compose_width`.
    ///
    /// Scroll math (`Viewport::scroll_*`,
    /// `scrollbar_math::ensure_index`) reads this so per-line visual
    /// row counts are computed at the renderer's effective wrap
    /// width, not the raw split width.  Without that, on a wide
    /// terminal with `compose_width` set, mouse wheel and scrollbar
    /// drag stop short of the buffer's tail because each long
    /// paragraph is counted as 1–2 rows by scroll math but drawn as
    /// 3–4 rows by the renderer.
    pub compose_width: Option<u16>,

    /// Whether line numbers are visible in this viewport.  When
    /// hidden (typical in compose mode), `gutter_width` returns 0
    /// instead of `digits + 4` — keeping scroll math's wrap budget
    /// in sync with the renderer's, which uses
    /// `state.margins.left_total_width()` and gives 0 when the line
    /// number column is suppressed.  Mirrors
    /// `SplitViewState::show_line_numbers`.
    pub show_line_numbers: bool,

    /// Whether viewport needs synchronization with cursor positions
    /// When true, ensure_visible needs to be called before rendering
    /// This allows batching multiple cursor movements into a single viewport update
    needs_sync: bool,

    /// Whether to skip viewport sync on next resize
    /// This is set when restoring a session to prevent the restored scroll position
    /// from being overwritten by ensure_visible during the first render
    skip_resize_sync: bool,

    /// Whether to skip ensure_visible on next render
    /// This is set after scroll actions (Ctrl+Up/Down) to prevent the scroll
    /// from being immediately undone by ensure_visible
    skip_ensure_visible: bool,

    /// Maximum line length encountered so far (in display columns).
    /// Updated incrementally as visible lines are rendered, avoiding full-file scans.
    pub max_line_length_seen: usize,

    /// When true, the next render pass should scroll to show the last line at
    /// the bottom of the viewport.  Set by same-buffer scroll sync when the
    /// active split is at the end of the document.  Consumed (cleared) during
    /// rendering after the adjustment is applied.
    pub sync_scroll_to_end: bool,

    /// Set by the byte-oriented `ensure_visible` when it scrolled UP in wrap
    /// mode (cursor was above the viewport and we shifted `top_byte` to an
    /// earlier logical line).  Consumed by `ensure_visible_in_layout`, which
    /// then pulls `top_view_line_offset` down so the cursor lands at exactly
    /// `effective_offset` rows from the viewport top — correcting the
    /// off-by-one that arises because `wrap_line` (used here) and
    /// `apply_wrapping_transform` (used by the real render pipeline) disagree
    /// by one wrap segment for some long paragraphs (issue #1574, Up-arrow
    /// jumpy variant).
    pub(crate) scrolled_up_in_wrap: bool,

    /// Small per-viewport row-count cache used by the scroll hot paths
    /// (`scroll_down_visual`, `apply_visual_scroll_limit`, etc.) to
    /// avoid re-running `apply_wrapping_transform` on the same logical
    /// line for every mouse-wheel tick. Particularly important for
    /// buffers with a single very long wrapped line — without this,
    /// each tick pays the O(n²) word-boundary wrap cost over the whole
    /// line.
    ///
    /// Distinct from the cross-consumer cache on `EditorState`: that
    /// one is shared between the renderer and scroll math, while this
    /// one is purely a local memoization for the viewport's own
    /// per-tick counting. The keys use `buffer.version()` as the
    /// version; plugin-driven (soft-break / conceal) changes aren't
    /// detected here, but in the absence of plugins the per-line row
    /// count depends only on buffer content + geometry, which is what
    /// this cache covers.
    pub(crate) wrap_row_cache: crate::view::line_wrap_cache::LineWrapCache,
}

impl Viewport {
    /// Create a new viewport
    pub fn new(width: u16, height: u16) -> Self {
        Self {
            top_byte: 0,
            top_view_line_offset: 0,
            left_column: 0,
            width,
            height,
            scroll_offset: 3,
            horizontal_scroll_offset: 5,
            line_wrap_enabled: false,
            wrap_indent: true,
            wrap_column: None,
            compose_width: None,
            show_line_numbers: true,
            needs_sync: false,
            skip_resize_sync: false,
            skip_ensure_visible: false,
            max_line_length_seen: 0,
            sync_scroll_to_end: false,
            scrolled_up_in_wrap: false,
            // 512 KiB byte budget — the scroll hot paths only ever
            // touch a handful of nearby lines per event, so this cache
            // doesn't need to remember every line of every buffer.
            wrap_row_cache: crate::view::line_wrap_cache::LineWrapCache::with_byte_budget(
                512 * 1024,
            ),
        }
    }

    /// If `pos` falls inside a hidden fold range, return that range.
    fn containing_hidden_range(
        hidden_ranges: &[(usize, usize)],
        pos: usize,
    ) -> Option<(usize, usize)> {
        hidden_ranges
            .iter()
            .find(|&&(start, end)| pos >= start && pos < end)
            .copied()
    }

    /// Mark viewport to skip sync on next resize (used after session restore)
    pub fn set_skip_resize_sync(&mut self) {
        self.skip_resize_sync = true;
    }

    /// Check and clear the skip_resize_sync flag
    /// Returns true if sync should be skipped
    pub fn should_skip_resize_sync(&mut self) -> bool {
        let skip = self.skip_resize_sync;
        self.skip_resize_sync = false;
        skip
    }

    /// Mark viewport to skip ensure_visible on next render
    /// This is used after scroll actions to prevent the scroll from being undone
    pub fn set_skip_ensure_visible(&mut self) {
        tracing::trace!("set_skip_ensure_visible: setting flag to true");
        self.skip_ensure_visible = true;
    }

    /// Check if ensure_visible should be skipped (does NOT consume the flag)
    /// Returns true if ensure_visible should be skipped
    pub fn should_skip_ensure_visible(&self) -> bool {
        self.skip_ensure_visible
    }

    /// Clear the skip_ensure_visible flag
    /// This should be called after all ensure_visible calls in a render pass
    pub fn clear_skip_ensure_visible(&mut self) {
        self.skip_ensure_visible = false;
    }

    /// Set the scroll offset
    pub fn set_scroll_offset(&mut self, offset: usize) {
        self.scroll_offset = offset;
    }

    /// Update terminal dimensions
    pub fn resize(&mut self, width: u16, height: u16) {
        self.width = width;
        self.height = height;
    }

    /// Effective wrap width for compose-aware scroll math.  Returns
    /// the viewport width clamped to `compose_width` when set.  The
    /// renderer wraps at this width; scroll math must match or
    /// `max_scroll_row` ends up wrong on wide viewports with a narrow
    /// page width.
    #[inline]
    pub fn effective_width(&self) -> u16 {
        match self.compose_width {
            Some(cw) => cw.min(self.width).max(1),
            None => self.width,
        }
    }

    /// Get the number of visible lines
    pub fn visible_line_count(&self) -> usize {
        self.height as usize
    }

    /// Calculate the gutter width based on buffer length
    /// Format: "[indicator]{:>N} │ " where N is the number of digits for line numbers
    /// - Indicator column: 1 char (space, or symbols like ●/✗/⚠)
    /// - Line numbers: N digits (min 2), right-aligned
    /// - Separator: " │ " = 3 chars (space, box char, space)
    ///
    /// Total width = 1 + N + 3 = N + 4 (where N >= 2 minimum, so min 6 total).
    /// The width adapts to the buffer's line count — small files don't waste
    /// space on a 4-digit-wide column. `MIN_LINE_NUMBER_DIGITS` keeps it from
    /// shrinking so much that a 1-line buffer feels cramped.
    pub fn gutter_width(&self, buffer: &Buffer) -> usize {
        let byte_offset_mode = buffer.line_count().is_none();
        let gutter_estimate = if byte_offset_mode {
            // In byte offset mode, gutter shows byte offsets up to file size
            buffer.len().max(1)
        } else {
            buffer.line_count().unwrap_or(1)
        };
        let digits = if gutter_estimate == 0 {
            1
        } else {
            ((gutter_estimate as f64).log10().floor() as usize) + 1
        };
        1 + digits.max(crate::view::margin::MIN_LINE_NUMBER_DIGITS) + 3
    }

    /// Count visual rows for a single logical line, accounting for plugin soft
    /// breaks (e.g. markdown_compose's hanging-indent wrapping).
    ///
    /// `soft_breaks` is a sorted slice of `(byte_position, indent)` pairs
    /// describing plugin-injected line breaks.  When any fall in
    /// `[line_start, line_end)` we run the renderer's full wrap pipeline
    /// per soft-break-bounded segment (`apply_soft_breaks` →
    /// `apply_wrapping_transform`) so the scroll math agrees row-for-row
    /// with the rendered output even when individual segments still
    /// need word-wrap (markdown_compose's wide tables, very long
    /// paragraphs).  Without breaks we run word-wrap on the whole line.
    ///
    /// Lock-step with the renderer (see `apply_soft_breaks` /
    /// `apply_wrapping_transform` in `split_rendering::transforms`).
    fn count_visual_rows_for_line(
        line_start: usize,
        line_end: usize,
        line_text: &str,
        wrap_config: &WrapConfig,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        cache: Option<(&mut crate::view::line_wrap_cache::LineWrapCache, u64)>,
    ) -> usize {
        // Plugin virtual lines (e.g. markdown_compose's `┌─┬─┐` table
        // borders) draw real rows the renderer paints; without them
        // here, mouse wheel / PageDown clamp short of the buffer's
        // tail.
        let v_lo = virtual_lines.partition_point(|p| *p < line_start);
        let v_hi = virtual_lines.partition_point(|p| *p < line_end);
        let extra_virtual_rows = v_hi - v_lo;
        let lo = soft_breaks.partition_point(|p| p.0 < line_start);
        let hi = soft_breaks.partition_point(|p| p.0 < line_end);
        let line_breaks = &soft_breaks[lo..hi];
        if !line_breaks.is_empty() {
            // Run the renderer's full pipeline per segment so segments
            // that still need word-wrap (long paragraph text after a
            // narrow soft-break wrap) are counted at their true row
            // count, not assumed to be one row each.  Skips the cache
            // (the count is already cheap and the soft-break-aware
            // helper isn't keyed for the per-line cache).
            let effective_width = wrap_config
                .first_line_width
                .saturating_add(wrap_config.gutter_width)
                .max(2);
            return crate::view::line_wrap_cache::count_visual_rows_for_text_with_soft_breaks(
                line_text,
                line_start,
                line_breaks,
                effective_width,
                wrap_config.gutter_width,
                wrap_config.hanging_indent,
            ) as usize
                + extra_virtual_rows;
        }
        {
            // Run the renderer's wrap function on a single-Text-token view of
            // the line.  This matches `apply_wrapping_transform`'s word-
            // boundary semantics and uses the same effective width the
            // renderer uses — no more char-wrap-vs-word-wrap drift.
            // See docs/internal/line-wrap-cache-plan.md.
            // `apply_wrapping_transform`'s available text width is
            //   effective_width - gutter_width
            // where `effective_width` is the value passed as its
            // `content_width` parameter.
            //
            // We want the inner `available_width` to equal
            // `wrap_config.first_line_width` — that IS the text column
            // budget the renderer uses (its content_width is
            // `viewport.width - 1` for EOL-cursor reservation, and
            // viewport.width already excludes the scrollbar; WrapConfig
            // happens to encode the same end result because its caller
            // doubles the scrollbar subtraction).
            //
            // So: effective_width = first_line_width + gutter_width.
            let effective_width = wrap_config
                .first_line_width
                .saturating_add(wrap_config.gutter_width)
                .max(2);

            // The viewport-local cache is a count-only memoization for
            // the scroll hot paths — it doesn't need real ViewLine
            // layout.  Compute the row count via the pure wrap helper
            // and wrap it in a placeholder `Vec<ViewLine>` so the
            // shared `LineWrapCache` value type is honoured; consumers
            // of this cache only read `.len()`.  The cross-consumer
            // cache on `EditorState` holds real `ViewLine`s populated
            // from the full pipeline.
            let compute = || {
                let n = compute_wrap_row_count_for_text(
                    line_text,
                    effective_width,
                    wrap_config.gutter_width,
                    wrap_config.hanging_indent,
                );
                crate::view::line_wrap_cache::placeholder_layout_for_row_count(n)
            };
            if let Some((cache, pipeline_inputs_ver)) = cache {
                use crate::view::line_wrap_cache::{CacheViewMode, LineWrapKey};
                let key = LineWrapKey {
                    pipeline_inputs_version: pipeline_inputs_ver,
                    view_mode: CacheViewMode::Source,
                    line_start,
                    effective_width: effective_width as u32,
                    gutter_width: wrap_config.gutter_width as u16,
                    wrap_column: None,
                    hanging_indent: wrap_config.hanging_indent,
                    line_wrap_enabled: true,
                };
                return cache.get_or_insert_with(key, compute).len() + extra_virtual_rows;
            }
            compute().len() + extra_virtual_rows
        }
    }
}

/// Compute the visual-row count for a single line's text by running
/// `apply_wrapping_transform` on a single-Text-token input and walking
/// the output token stream. Module-private helper shared by the cache
/// hit and miss paths of `count_visual_rows_for_line`.
fn compute_wrap_row_count_for_text(
    line_text: &str,
    effective_width: usize,
    gutter_width: usize,
    hanging_indent: bool,
) -> u32 {
    use crate::view::ui::split_rendering::transforms::apply_wrapping_transform;
    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};

    let tokens = vec![ViewTokenWire {
        source_offset: Some(0),
        kind: ViewTokenWireKind::Text(line_text.to_string()),
        style: None,
    }];
    let wrapped = apply_wrapping_transform(tokens, effective_width, gutter_width, hanging_indent);
    // Count non-empty visual rows.  `apply_wrapping_transform` can emit a
    // *trailing* `Break` when the last chunk fills `effective_width` exactly
    // — that Break is width-triggered and is followed by nothing, so it
    // doesn't represent a real wrap. Walk the stream, start a new row on
    // each Break, and only count rows that contained at least one content
    // token.
    let mut rows: u32 = 0;
    let mut row_has_content = false;
    for t in &wrapped {
        match &t.kind {
            ViewTokenWireKind::Newline => break,
            ViewTokenWireKind::Break => {
                if row_has_content {
                    rows += 1;
                }
                row_has_content = false;
            }
            ViewTokenWireKind::Text(s) => {
                if !s.is_empty() {
                    row_has_content = true;
                }
            }
            ViewTokenWireKind::Space | ViewTokenWireKind::BinaryByte(_) => {
                row_has_content = true;
            }
        }
    }
    if row_has_content {
        rows += 1;
    }
    rows.max(1)
}

impl Viewport {
    /// Scroll up by N lines (byte-based)
    /// When line_wrap_enabled is true, scrolls by visual rows instead of logical lines
    ///
    /// `soft_breaks` is a sorted slice of plugin-injected break byte positions.
    /// Pass an empty slice when there are no plugin breaks (raw mode, etc.).
    pub fn scroll_up(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        lines: usize,
    ) {
        if self.line_wrap_enabled {
            self.scroll_up_visual(buffer, soft_breaks, virtual_lines, lines);
        } else {
            let mut iter = buffer.line_iterator(self.top_byte, 80);
            for _ in 0..lines {
                if iter.prev().is_none() {
                    break;
                }
            }
            let new_position = iter.current_position();
            self.set_top_byte_with_limit(buffer, soft_breaks, virtual_lines, new_position);
        }
    }

    /// Scroll down by N lines (byte-based)
    /// When line_wrap_enabled is true, scrolls by visual rows instead of logical lines
    ///
    /// `soft_breaks` is a sorted slice of plugin-injected break byte positions.
    /// Pass an empty slice when there are no plugin breaks (raw mode, etc.).
    pub fn scroll_down(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        lines: usize,
    ) {
        if self.line_wrap_enabled {
            self.scroll_down_visual(buffer, soft_breaks, virtual_lines, lines);
        } else {
            let mut iter = buffer.line_iterator(self.top_byte, 80);
            for _ in 0..lines {
                if iter.next_line().is_none() {
                    break;
                }
            }
            let new_position = iter.current_position();
            self.set_top_byte_with_limit(buffer, soft_breaks, virtual_lines, new_position);
        }
    }

    /// Scroll up by N visual rows (for line-wrapped content)
    /// This counts wrapped segments, not logical lines
    fn scroll_up_visual(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        visual_rows: usize,
    ) {
        if visual_rows == 0 {
            return;
        }

        let buffer_version = buffer.version();
        let gutter_width = self.gutter_width(buffer);
        let wrap_config = WrapConfig::new(
            self.effective_width() as usize,
            gutter_width,
            true,
            self.wrap_indent,
        );

        // We need to move backwards through visual rows
        // Start from current top_byte and count backwards
        let mut rows_remaining = visual_rows;
        let mut current_byte = self.top_byte;

        // First, check if we have a top_view_line_offset (mid-line position)
        // If so, we can scroll up within the current line first
        if self.top_view_line_offset > 0 {
            let rows_in_offset = self.top_view_line_offset.min(rows_remaining);
            self.top_view_line_offset -= rows_in_offset;
            rows_remaining -= rows_in_offset;
            if rows_remaining == 0 {
                return;
            }
        }

        // Now scroll backwards through logical lines, counting visual rows
        let mut iter = buffer.line_iterator(current_byte, 80);

        while rows_remaining > 0 {
            // Move to previous line
            if iter.prev().is_none() {
                // Hit beginning of buffer
                self.top_byte = 0;
                self.top_view_line_offset = 0;
                return;
            }

            // Get the line content to calculate how many visual rows it has
            let line_start = iter.current_position();
            let (line_end, line_content) = if let Some((_, content)) = iter.next_line() {
                let end = iter.current_position();
                (end, content.trim_end_matches(['\n', '\r']).to_string())
            } else {
                (line_start, String::new())
            };
            // Move back to the line start position
            iter = buffer.line_iterator(line_start, 80);

            let visual_rows_in_line = Self::count_visual_rows_for_line(
                line_start,
                line_end,
                &line_content,
                &wrap_config,
                soft_breaks,
                virtual_lines,
                Some((&mut self.wrap_row_cache, buffer_version)),
            );

            if visual_rows_in_line >= rows_remaining {
                // This line has enough visual rows to satisfy the remaining scroll
                // Position at the appropriate segment within this line
                self.top_byte = line_start;
                self.top_view_line_offset = visual_rows_in_line - rows_remaining;
                return;
            }

            // This line doesn't have enough rows, continue to previous line
            rows_remaining -= visual_rows_in_line;
            current_byte = line_start;
        }

        self.top_byte = current_byte;
        self.top_view_line_offset = 0;
    }

    /// Scroll down by N visual rows (for line-wrapped content)
    /// This counts wrapped segments, not logical lines
    fn scroll_down_visual(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        visual_rows: usize,
    ) {
        if visual_rows == 0 {
            return;
        }

        let buffer_version = buffer.version();
        let gutter_width = self.gutter_width(buffer);
        let wrap_config = WrapConfig::new(
            self.effective_width() as usize,
            gutter_width,
            true,
            self.wrap_indent,
        );
        let buffer_len = buffer.len();

        let mut rows_remaining = visual_rows;
        let current_top = self.top_byte;
        let mut iter = buffer.line_iterator(current_top, 80);

        // First, handle any existing top_view_line_offset
        // Get current line's visual row count to see how many rows are left in it
        let (current_line_end, current_line_content) = if let Some((_, content)) = iter.next_line()
        {
            let end = iter.current_position();
            let c = content.trim_end_matches(['\n', '\r']).to_string();
            // Reset iterator to start of this line for later use
            iter = buffer.line_iterator(current_top, 80);
            (end, c)
        } else {
            (current_top, String::new())
        };

        let current_visual_rows = Self::count_visual_rows_for_line(
            current_top,
            current_line_end,
            &current_line_content,
            &wrap_config,
            soft_breaks,
            virtual_lines,
            Some((&mut self.wrap_row_cache, buffer_version)),
        );
        let rows_left_in_current = current_visual_rows.saturating_sub(self.top_view_line_offset);

        if rows_remaining < rows_left_in_current {
            // Can satisfy scroll within current line, but we still
            // need to reclamp: if the current line is the last line
            // of the buffer, advancing `top_view_line_offset` can push
            // the viewport past the point where it can be filled with
            // real content, leaving past-EOF `~` rows below.
            self.top_view_line_offset += rows_remaining;
            self.apply_visual_scroll_limit(buffer, soft_breaks, virtual_lines, &wrap_config);
            return;
        }

        // Move past the current line
        rows_remaining -= rows_left_in_current;
        self.top_view_line_offset = 0;

        // Move to next line
        if iter.next_line().is_none() {
            // Already at end of buffer
            return;
        }

        // Continue scrolling through subsequent lines
        loop {
            let line_start = iter.current_position();

            // Check for end of buffer
            if line_start >= buffer_len {
                self.set_top_byte_with_limit(buffer, soft_breaks, virtual_lines, line_start);
                return;
            }

            let (line_end, line_content) = if let Some((_, content)) = iter.next_line() {
                let end = iter.current_position();
                (end, content.trim_end_matches(['\n', '\r']).to_string())
            } else {
                // End of buffer
                self.set_top_byte_with_limit(buffer, soft_breaks, virtual_lines, line_start);
                return;
            };

            let visual_rows_in_line = Self::count_visual_rows_for_line(
                line_start,
                line_end,
                &line_content,
                &wrap_config,
                soft_breaks,
                virtual_lines,
                Some((&mut self.wrap_row_cache, buffer_version)),
            );

            if rows_remaining < visual_rows_in_line {
                // This line has enough visual rows to satisfy the scroll
                self.top_byte = line_start;
                self.top_view_line_offset = rows_remaining;
                // Apply visual-row-aware scroll limit
                self.apply_visual_scroll_limit(buffer, soft_breaks, virtual_lines, &wrap_config);
                return;
            }

            // Not enough rows in this line, continue to next
            rows_remaining -= visual_rows_in_line;

            if rows_remaining == 0 {
                // Exactly consumed this line, position at start of next
                let next_pos = iter.current_position();
                self.top_byte = next_pos;
                self.top_view_line_offset = 0;
                // Apply visual-row-aware scroll limit
                self.apply_visual_scroll_limit(buffer, soft_breaks, virtual_lines, &wrap_config);
                return;
            }
        }
    }

    /// Apply visual-row-aware scroll limit to prevent over-scrolling.
    /// This ensures the viewport is always filled with content when possible.
    /// Returns true if position was adjusted, false if no adjustment needed.
    fn apply_visual_scroll_limit(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        wrap_config: &WrapConfig,
    ) {
        let viewport_height = self.visible_line_count();
        if viewport_height == 0 {
            return;
        }

        let buffer_version = buffer.version();
        // Count visual rows from current position to end of buffer
        let mut visual_rows_remaining = 0;
        let mut iter = buffer.line_iterator(self.top_byte, 80);

        // First, count rows in current line (from top_view_line_offset to end)
        if let Some((line_start, content)) = iter.next_line() {
            let line_end = iter.current_position();
            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
            let line_visual_rows = Self::count_visual_rows_for_line(
                line_start,
                line_end,
                &line_content,
                wrap_config,
                soft_breaks,
                virtual_lines,
                Some((&mut self.wrap_row_cache, buffer_version)),
            );
            visual_rows_remaining += line_visual_rows.saturating_sub(self.top_view_line_offset);
        }

        // Count rows in subsequent lines
        while let Some((line_start, content)) = iter.next_line() {
            let line_end = iter.current_position();
            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
            visual_rows_remaining += Self::count_visual_rows_for_line(
                line_start,
                line_end,
                &line_content,
                wrap_config,
                soft_breaks,
                virtual_lines,
                Some((&mut self.wrap_row_cache, buffer_version)),
            );

            // Early exit if we have enough rows
            if visual_rows_remaining >= viewport_height {
                return; // No need to adjust
            }
        }

        // If we don't have enough rows to fill viewport, find the max scroll position
        // and set it directly (instead of calling scroll_up_visual which can be jumpy)
        if visual_rows_remaining < viewport_height {
            // Find the max scroll position by scanning from the beginning
            let (max_byte, max_offset) = self.find_max_visual_scroll_position(
                buffer,
                soft_breaks,
                virtual_lines,
                wrap_config,
                viewport_height,
            );
            self.top_byte = max_byte;
            self.top_view_line_offset = max_offset;
        }
    }

    /// Find the maximum scroll position that still shows viewport_height visual rows.
    /// Returns (top_byte, top_view_line_offset) for the max scroll position.
    fn find_max_visual_scroll_position(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        wrap_config: &WrapConfig,
        viewport_height: usize,
    ) -> (usize, usize) {
        let buffer_version = buffer.version();
        let buffer_len = buffer.len();
        if buffer_len == 0 {
            return (0, 0);
        }

        // Scan backward from the end to find a starting point, then scan forward.
        // This is O(viewport_height) instead of O(total_lines), avoiding a full-file
        // scan that hangs on large files.
        let scan_start = {
            let mut iter = buffer.line_iterator(buffer_len, 80);
            // Go back 2x viewport_height logical lines — each line produces at least
            // 1 visual row, so this guarantees enough visual rows.
            for _ in 0..(viewport_height * 2) {
                if iter.prev().is_none() {
                    break;
                }
            }
            iter.current_position()
        };

        // Build visual row positions from scan_start to end of file
        let mut positions: Vec<(usize, usize)> = Vec::new();
        let mut iter = buffer.line_iterator(scan_start, 80);
        while let Some((line_start, content)) = iter.next_line() {
            let line_end = iter.current_position();
            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
            let visual_rows_in_line = Self::count_visual_rows_for_line(
                line_start,
                line_end,
                &line_content,
                wrap_config,
                soft_breaks,
                virtual_lines,
                Some((&mut self.wrap_row_cache, buffer_version)),
            );

            for offset in 0..visual_rows_in_line {
                positions.push((line_start, offset));
            }
        }

        let total_rows = positions.len();
        if total_rows <= viewport_height {
            // Everything from scan_start fits — the whole file fits in the viewport
            return (0, 0);
        }

        let max_scroll_row = total_rows - viewport_height;
        positions[max_scroll_row]
    }

    /// Scroll through ViewLines (view-transform aware)
    ///
    /// This method scrolls through display lines rather than source lines,
    /// correctly handling view transforms that inject headers or other content.
    ///
    /// # Arguments
    /// * `view_lines` - The current display lines (from ViewLineIterator)
    /// * `line_offset` - Positive to scroll down, negative to scroll up
    ///
    /// # Returns
    /// The new top_byte position after scrolling
    pub fn scroll_view_lines(&mut self, view_lines: &[ViewLine], line_offset: isize) {
        let viewport_height = self.visible_line_count();
        if view_lines.is_empty() || viewport_height == 0 {
            return;
        }

        // Find the current view line index that corresponds to top_byte
        let current_idx = self.find_view_line_for_byte(view_lines, self.top_byte);

        // Calculate target index
        let target_idx = if line_offset >= 0 {
            current_idx.saturating_add(line_offset as usize)
        } else {
            current_idx.saturating_sub(line_offset.unsigned_abs())
        };

        // Apply scroll limit: don't scroll past the point where viewport can't be filled
        let max_top_idx = view_lines.len().saturating_sub(viewport_height);
        let clamped_idx = target_idx.min(max_top_idx);

        // Get the source byte for the target view line
        if let Some(new_top_byte) = self.get_source_byte_for_view_line(view_lines, clamped_idx) {
            tracing::trace!(
                "scroll_view_lines: offset={}, current_idx={}, target_idx={}, clamped_idx={}, new_top_byte={}",
                line_offset, current_idx, target_idx, clamped_idx, new_top_byte
            );
            self.top_byte = new_top_byte;
        }
    }

    /// Find the view line index that contains a source byte position
    /// Returns the line where the byte falls within its range, not just the first line
    /// starting at or after the byte.
    fn find_view_line_for_byte(&self, view_lines: &[ViewLine], target_byte: usize) -> usize {
        // Find the line that contains the target byte by checking if target is
        // between this line's start and the next line's start
        let mut best_match = 0;

        for (idx, line) in view_lines.iter().enumerate() {
            if let Some(first_source) = line.char_source_bytes.iter().find_map(|m| *m) {
                if first_source <= target_byte {
                    // This line starts at or before target, so it might contain it
                    best_match = idx;
                } else {
                    // This line starts after target, so previous line contains it
                    break;
                }
            }
        }

        // If the cursor is past the last source-mapped byte, check whether there
        // is a trailing empty view line (after a source newline) that the cursor
        // belongs on — e.g. the empty line after a file's trailing '\n'.
        if let Some(last) = view_lines.last() {
            let last_idx = view_lines.len() - 1;
            if last_idx > best_match
                && last.char_source_bytes.is_empty()
                && matches!(last.line_start, LineStart::AfterSourceNewline)
            {
                let best_max = view_lines[best_match]
                    .char_source_bytes
                    .iter()
                    .filter_map(|b| *b)
                    .max()
                    .unwrap_or(0);
                if target_byte > best_max {
                    return last_idx;
                }
            }
        }

        best_match
    }

    /// Get the source byte position for a view line index
    /// For injected lines (headers), walks forward to find the next source line
    fn get_source_byte_for_view_line(&self, view_lines: &[ViewLine], idx: usize) -> Option<usize> {
        // Start from the requested index and walk forward to find a line with source mapping
        for line in view_lines.iter().skip(idx) {
            if let Some(source_byte) = line.char_source_bytes.iter().find_map(|m| *m) {
                return Some(source_byte);
            }
        }
        // If all remaining lines are injected, try to get the last known source position
        // by walking backwards
        for line in view_lines.iter().take(idx).rev() {
            if let Some(source_byte) = line.char_source_bytes.iter().find_map(|m| *m) {
                // This is the last source position before our target
                // We want to stay at that position
                return Some(source_byte);
            }
        }
        // No source bytes found at all - keep current position
        Some(self.top_byte)
    }

    /// Set `top_byte` and `top_view_line_offset` so that
    /// `view_lines[target_idx]` becomes the first visible view line.
    ///
    /// For wrap-continuation targets (`line_start == AfterBreak`) this
    /// SNAPS `top_byte` back to the containing logical line's source
    /// byte and stashes the wrap-segment offset into
    /// `top_view_line_offset`.  Without that snap the render pipeline's
    /// `calculate_view_anchor` (which runs AFTER slicing by
    /// `top_view_line_offset`) skips forward over any view lines whose
    /// source byte is < `top_byte` — effectively undoing subsequent
    /// decrements of `top_view_line_offset` and producing
    /// identical-looking renders before and after a Ctrl+Up (issue
    /// #1574 Ctrl+Up/Ctrl+Down round-trip variant).
    ///
    /// For NON-wrap targets (source-line starts, virtual/injected
    /// lines, or the very first line of the view) we use the original
    /// behavior: `top_byte` at the target's source byte and
    /// `top_view_line_offset` as the absolute target index — that's
    /// required by plugin view transforms that inject virtual lines at
    /// the top of the view (see
    /// `test_view_transform_scroll_with_many_virtual_lines`), where the
    /// offset counts over the injected prefix.
    fn snap_to_logical_line_start(&mut self, view_lines: &[ViewLine], target_idx: usize) {
        if view_lines.is_empty() {
            return;
        }
        let clamped_target = target_idx.min(view_lines.len() - 1);
        let target_is_wrap_continuation =
            matches!(view_lines[clamped_target].line_start, LineStart::AfterBreak);

        if target_is_wrap_continuation {
            // Walk backward from target_idx until we find a view line
            // that is NOT a wrap continuation.  That's the start of the
            // logical line containing `target_idx`.
            let mut line_start_idx = clamped_target;
            while line_start_idx > 0
                && matches!(view_lines[line_start_idx].line_start, LineStart::AfterBreak)
            {
                line_start_idx -= 1;
            }

            if let Some(new_top_byte) =
                self.get_source_byte_for_view_line(view_lines, line_start_idx)
            {
                self.top_byte = new_top_byte;
            }
            self.top_view_line_offset = target_idx.saturating_sub(line_start_idx);
        } else {
            // Classic behavior: absolute offset, source-byte top.
            self.top_view_line_offset = target_idx;
            if let Some(new_top_byte) = self.get_source_byte_for_view_line(view_lines, target_idx) {
                self.top_byte = new_top_byte;
            }
        }
    }

    /// Ensure cursor is visible using view lines (Layout-aware)
    ///
    /// This method uses view lines to check visibility, correctly handling
    /// view transforms that inject headers or other virtual content.
    ///
    /// # Arguments
    /// * `view_lines` - The current display lines (from ViewLineIterator)
    /// * `cursor` - The cursor to ensure is visible
    /// * `gutter_width` - Width of the gutter (for cursor positioning)
    ///
    /// Returns true if scrolling occurred.
    pub fn ensure_visible_in_layout(
        &mut self,
        view_lines: &[ViewLine],
        cursor: &Cursor,
        gutter_width: usize,
    ) -> bool {
        // Check if we should skip sync due to session restore
        // This prevents the restored scroll position from being overwritten
        if self.should_skip_resize_sync() {
            return false;
        }

        // Check if we should skip ensure_visible due to scroll action
        // This prevents scroll actions (Ctrl+Up/Down) from being immediately undone
        if self.should_skip_ensure_visible() {
            tracing::trace!("ensure_visible_in_layout: SKIPPING due to skip_ensure_visible flag");
            return false;
        }
        tracing::trace!(
            "ensure_visible_in_layout: NOT skipping, skip_ensure_visible={}",
            self.skip_ensure_visible
        );

        let viewport_height = self.visible_line_count();
        if view_lines.is_empty() || viewport_height == 0 {
            tracing::trace!(
                "ensure_visible_in_layout: early-out, view_lines.len={} viewport_height={} cursor_pos={} top_byte={}",
                view_lines.len(),
                viewport_height,
                cursor.position,
                self.top_byte,
            );
            self.scrolled_up_in_wrap = false;
            return false;
        }

        // Find the cursor's absolute view line position (in the full view_lines array)
        let cursor_view_line = self.find_view_line_for_byte(view_lines, cursor.position);

        tracing::trace!(
            "ensure_visible_in_layout: enter cursor_pos={} cursor_view_line={} top_view_line_offset={} top_byte={} viewport_height={} view_lines.len={} line_wrap_enabled={}",
            cursor.position,
            cursor_view_line,
            self.top_view_line_offset,
            self.top_byte,
            viewport_height,
            view_lines.len(),
            self.line_wrap_enabled,
        );

        // Consume the "just scrolled up in wrap mode" signal from the byte-
        // oriented `ensure_visible`.  When set, we pull `top_view_line_offset`
        // down so the cursor lands at exactly `scroll_offset` rows from the
        // viewport top.  The byte-oriented pass uses `wrap_line` (char-based)
        // to count wrap segments, which disagrees by one with the rendering
        // pipeline's word-boundary wrapping for some paragraphs — without
        // this fine-tune, subsequent Up presses keep the cursor one row
        // deeper than the scroll margin and the viewport stalls instead of
        // scrolling one row per press (issue #1574, Up-arrow jumpy variant
        // step 17 of the width-sweep).
        let fine_tune_scroll_up = self.scrolled_up_in_wrap && self.line_wrap_enabled;
        self.scrolled_up_in_wrap = false;
        if fine_tune_scroll_up {
            let desired_offset = self.scroll_offset.min(viewport_height / 2);
            // Use a clamped shift so we never push the viewport past
            // buffer boundaries (handled by max_top below via saturating).
            let max_top_candidate = view_lines.len().saturating_sub(viewport_height);
            let target_top = cursor_view_line.saturating_sub(desired_offset);
            let new_offset = target_top.min(max_top_candidate);
            if new_offset != self.top_view_line_offset {
                tracing::trace!(
                    "ensure_visible_in_layout: fine-tune scroll-up offset {} -> {} (cursor_view_line={})",
                    self.top_view_line_offset,
                    new_offset,
                    cursor_view_line,
                );
                self.top_view_line_offset = new_offset;
                return true;
            }
        }

        // The effective top view line is the offset we've scrolled through
        let effective_top = self.top_view_line_offset;
        let effective_bottom = effective_top + viewport_height;

        // Apply the same scroll margin that the byte-oriented `ensure_visible`
        // uses, so that in heavily wrapped buffers (where `ensure_visible`
        // bails out because `top_view_line_offset > 0`) the cursor still
        // triggers a scroll as soon as it enters the top or bottom margin
        // zone rather than only when it falls completely outside the
        // viewport. Without this, the cursor can slide all the way to the
        // last visible row in wrapped content before a single Down press
        // finally scrolls — and then the next press stalls — because
        // this view-line-aware routine only treated "completely off-screen"
        // as a scroll trigger (issue #1574).
        //
        // Only apply the margin logic when the wrapping pipeline is actually
        // managing the scroll position (either line-wrap is on, or we've
        // been asked to skip into a wrapped logical line via
        // `top_view_line_offset > 0`).  In plain non-wrapped mode the
        // byte-oriented `ensure_visible` already placed the cursor inside
        // the safe zone with margin, so re-applying it here — on top of
        // `view_lines` that only cover the current viewport window — can
        // produce incorrect scroll decisions for large files in
        // byte-offset mode.
        let apply_margin = self.line_wrap_enabled || self.top_view_line_offset > 0;
        let effective_offset = if apply_margin {
            self.scroll_offset.min(viewport_height / 2)
        } else {
            0
        };
        let max_top = view_lines.len().saturating_sub(viewport_height);

        // Cursor is in the top margin zone when it sits within
        // `effective_offset` rows of the top of the viewport.  When
        // `effective_offset == 0` (non-wrapped fallback) this degrades to
        // the pre-existing "cursor above the viewport" check.
        let in_top_margin = cursor_view_line < effective_top + effective_offset;
        // Cursor is in the bottom margin zone when it sits within
        // `effective_offset` rows of the bottom. `+1` because margin is
        // *within* the last `effective_offset` rows (inclusive).  With
        // `effective_offset == 0` this degrades to "cursor at or below
        // the last visible row".
        let in_bottom_margin = cursor_view_line + effective_offset + 1 > effective_bottom;

        tracing::trace!(
            "ensure_visible_in_layout: margins effective_top={} effective_bottom={} effective_offset={} apply_margin={} in_top_margin={} in_bottom_margin={} max_top={}",
            effective_top,
            effective_bottom,
            effective_offset,
            apply_margin,
            in_top_margin,
            in_bottom_margin,
            max_top,
        );

        if in_top_margin || in_bottom_margin {
            // Compute the ideal top_view_line_offset that puts the cursor
            // just inside the safe zone (margin away from the nearer edge).
            let target_top = if in_top_margin {
                // Put cursor at `effective_offset` rows from the top.
                cursor_view_line.saturating_sub(effective_offset)
            } else {
                // Put cursor at `effective_offset` rows from the bottom,
                // i.e. row `viewport_height - 1 - effective_offset` from the
                // new top.
                (cursor_view_line + effective_offset + 1).saturating_sub(viewport_height)
            };

            // Clamp to valid range. `max_top` is the largest top that still
            // keeps a full viewport's worth of rows below it.
            let new_offset = target_top.min(max_top);

            // Only actually scroll if that moves the viewport. If the
            // cursor is in a margin but we can't scroll any further in
            // that direction (e.g. at the very top or very bottom of the
            // document), keep the viewport put and fall through to the
            // virtual-lines / horizontal-scroll handling below.
            if new_offset == self.top_view_line_offset {
                tracing::trace!(
                    "ensure_visible_in_layout: in margin but clamped — target_top={} max_top={} new_offset={} top_view_line_offset={} cursor_view_line={} in_top_margin={} in_bottom_margin={}",
                    target_top,
                    max_top,
                    new_offset,
                    self.top_view_line_offset,
                    cursor_view_line,
                    in_top_margin,
                    in_bottom_margin,
                );
            }
            if new_offset != self.top_view_line_offset {
                tracing::trace!(
                    "ensure_visible_in_layout: scrolling from offset {} to {}, cursor_view_line={}, in_top_margin={}, in_bottom_margin={}",
                    self.top_view_line_offset,
                    new_offset,
                    cursor_view_line,
                    in_top_margin,
                    in_bottom_margin,
                );

                // Snap top_byte to the LOGICAL LINE START of the new first
                // visible view line, and set top_view_line_offset to the
                // wrap-segment offset within that line.  The render pipeline
                // invokes `calculate_view_anchor` after slicing, and that
                // helper scans forward through the sliced view-lines until
                // it finds the first one whose source byte is >= top_byte.
                // If we left top_byte at a *mid-line* source byte (the first
                // source byte of a wrapped continuation), `calculate_view_anchor`
                // would re-skip past any earlier wrap-continuation view lines
                // whose source is < top_byte — effectively undoing the slice
                // whenever we decrement top_view_line_offset within the same
                // logical line (issue #1574, Ctrl+Up/Ctrl+Down round-trip
                // symmetry).  Keeping top_byte at the line start keeps the
                // slice authoritative.
                self.snap_to_logical_line_start(view_lines, new_offset);
                return true;
            }
        }

        // Special case: When cursor is at the first view line of the viewport,
        // check if there are virtual lines above the cursor that should be visible.
        // Scroll up to show them, but keep the cursor visible within the viewport.
        let cursor_position_in_viewport = cursor_view_line.saturating_sub(effective_top);
        if cursor_position_in_viewport == 0 && cursor_view_line > 0 {
            // Cursor is at the top of the viewport, and there are lines above it
            // Count how many virtual lines (lines without source content) precede the cursor
            let mut virtual_lines_above = 0;
            for i in (0..cursor_view_line).rev() {
                let has_source = view_lines[i].char_source_bytes.iter().any(|m| m.is_some());
                if has_source {
                    break; // Hit a source line, stop counting
                }
                virtual_lines_above += 1;
            }

            if virtual_lines_above > 0 {
                // Scroll up to show virtual lines, but ensure cursor stays visible
                // The cursor should be at the bottom of the visible area at most
                let max_scroll_up = virtual_lines_above.min(viewport_height.saturating_sub(1));
                let new_offset = effective_top.saturating_sub(max_scroll_up);

                if new_offset != self.top_view_line_offset {
                    tracing::trace!(
                        "ensure_visible_in_layout: showing {} virtual lines above cursor, scrolling from {} to {}",
                        virtual_lines_above,
                        self.top_view_line_offset,
                        new_offset
                    );
                    self.top_view_line_offset = new_offset;
                    // Also update top_byte to match the new scroll position
                    if let Some(new_top_byte) =
                        self.get_source_byte_for_view_line(view_lines, new_offset)
                    {
                        self.top_byte = new_top_byte;
                    }
                    return true;
                }
            }
        }

        // Handle horizontal scrolling for cursor column
        if cursor_view_line < view_lines.len() {
            let line = &view_lines[cursor_view_line];
            // Get the byte position of the first character in this line
            // Then calculate cursor column as visual width from line start
            let line_start = line.char_source_bytes.iter().find_map(|m| *m).unwrap_or(0);

            // Calculate the byte position where this line ends (start of next line or end of view)
            // If cursor is beyond this line's content, skip horizontal scroll - the cursor
            // is on a line not in view_lines (e.g., a newly inserted line)
            let line_end_byte = if cursor_view_line + 1 < view_lines.len() {
                // Next line exists, use its start as this line's end
                view_lines[cursor_view_line + 1]
                    .char_source_bytes
                    .iter()
                    .find_map(|m| *m)
                    .unwrap_or(usize::MAX)
            } else {
                // This is the last view line - check if cursor is beyond line content
                // The line's content length (including newline) determines the end
                let content_bytes = line.text.len();
                line_start.saturating_add(content_bytes)
            };

            // Only handle horizontal scroll if cursor is actually within this line
            if cursor.position < line_end_byte {
                let cursor_byte_offset = cursor.position.saturating_sub(line_start);

                // Calculate visual column by walking through characters and summing widths
                // until we've consumed cursor_byte_offset bytes
                let line_text = line.text.trim_end_matches('\n');
                let mut bytes_consumed = 0usize;
                let mut cursor_visual_col = 0usize;
                for ch in line_text.chars() {
                    if bytes_consumed >= cursor_byte_offset {
                        break;
                    }
                    cursor_visual_col += char_width(ch);
                    bytes_consumed += ch.len_utf8();
                }

                let line_visual_width = str_width(line_text);
                self.ensure_column_visible_simple(
                    cursor_visual_col,
                    line_visual_width,
                    gutter_width,
                );
            }
            // If cursor.position >= line_end_byte, cursor is on a line not in view_lines
            // Skip horizontal scroll handling - ensure_visible already handled it correctly
        }

        false
    }

    /// Simple column visibility check (doesn't need buffer)
    fn ensure_column_visible_simple(
        &mut self,
        column: usize,
        line_length: usize,
        gutter_width: usize,
    ) {
        // Skip if line wrapping is enabled (all columns visible via wrapping)
        if self.line_wrap_enabled {
            self.left_column = 0;
            return;
        }

        let scrollbar_width = 1;
        let visible_width = (self.width as usize)
            .saturating_sub(gutter_width)
            .saturating_sub(scrollbar_width);

        if visible_width == 0 {
            return;
        }

        let effective_offset = self.horizontal_scroll_offset.min(visible_width / 2);
        let ideal_left = self.left_column + effective_offset;
        let ideal_right = self.left_column + visible_width.saturating_sub(effective_offset);

        if column < ideal_left {
            self.left_column = column.saturating_sub(effective_offset);
        } else if column >= ideal_right {
            let target_position = visible_width
                .saturating_sub(effective_offset)
                .saturating_sub(1);
            self.left_column = column.saturating_sub(target_position);
        }

        // Limit scroll to line length
        if line_length > 0 {
            let max_left_column = line_length.saturating_sub(visible_width.saturating_sub(1));
            if self.left_column > max_left_column {
                self.left_column = max_left_column;
            }
        }
    }

    /// Set top_byte with automatic scroll limit enforcement
    /// This prevents scrolling past the end of the buffer by ensuring
    /// the viewport can be filled from the proposed position
    fn set_top_byte_with_limit(
        &mut self,
        buffer: &mut Buffer,
        soft_breaks: &[(usize, u16)],
        virtual_lines: &[usize],
        proposed_top_byte: usize,
    ) {
        tracing::trace!(
            "DEBUG set_top_byte_with_limit: proposed_top_byte={}",
            proposed_top_byte
        );

        let viewport_height = self.visible_line_count();
        if viewport_height == 0 {
            self.top_byte = proposed_top_byte;
            return;
        }

        let buffer_len = buffer.len();
        if buffer_len == 0 {
            self.top_byte = 0;
            return;
        }

        if self.line_wrap_enabled {
            // When line wrapping is enabled, count visual rows (wrapped segments)
            // instead of logical lines. Each logical line may wrap into multiple
            // visual rows, so we must account for that when checking whether the
            // viewport can be filled from proposed_top_byte.
            let buffer_version = buffer.version();
            let gutter_width = self.gutter_width(buffer);
            let wrap_config = WrapConfig::new(
                self.effective_width() as usize,
                gutter_width,
                true,
                self.wrap_indent,
            );

            let mut iter = buffer.line_iterator(proposed_top_byte, 80);
            let mut visual_rows = 0;

            while let Some((line_start, content)) = iter.next_line() {
                let line_end = iter.current_position();
                let line_text = content.trim_end_matches(['\n', '\r']);
                visual_rows += Self::count_visual_rows_for_line(
                    line_start,
                    line_end,
                    line_text,
                    &wrap_config,
                    soft_breaks,
                    virtual_lines,
                    Some((&mut self.wrap_row_cache, buffer_version)),
                );
                if visual_rows >= viewport_height {
                    self.top_byte = proposed_top_byte;
                    return;
                }
            }

            if visual_rows >= viewport_height {
                self.top_byte = proposed_top_byte;
                return;
            }

            // Not enough visual rows to fill viewport from proposed position.
            // Use find_max_visual_scroll_position which correctly counts wrapped rows.
            let (max_byte, max_offset) = self.find_max_visual_scroll_position(
                buffer,
                soft_breaks,
                virtual_lines,
                &wrap_config,
                viewport_height,
            );
            // Only backtrack if the proposed position is past the maximum
            if proposed_top_byte > max_byte
                || (proposed_top_byte == max_byte && self.top_view_line_offset > max_offset)
            {
                self.top_byte = max_byte;
                self.top_view_line_offset = max_offset;
            } else {
                self.top_byte = proposed_top_byte;
            }
            return;
        }

        // Non-wrapped mode: count logical lines
        let mut iter = buffer.line_iterator(proposed_top_byte, 80);
        let mut lines_visible = 0;

        while let Some((_, _)) = iter.next_line() {
            lines_visible += 1;
            if lines_visible >= viewport_height {
                // We have a full viewport of content, use proposed position
                tracing::trace!(
                    "DEBUG: Full viewport available, setting top_byte={}",
                    proposed_top_byte
                );
                self.top_byte = proposed_top_byte;
                return;
            }
        }

        tracing::trace!(
            "DEBUG: After iteration, lines_visible={}, viewport_height={}",
            lines_visible,
            viewport_height
        );

        // If we have enough lines to fill the viewport, we're good
        if lines_visible >= viewport_height {
            tracing::trace!(
                "DEBUG: Enough lines to fill viewport, setting top_byte={}",
                proposed_top_byte
            );
            self.top_byte = proposed_top_byte;
            return;
        }

        // We don't have enough lines to fill the viewport from proposed_top_byte
        // Calculate how many lines we're short and scroll back
        let lines_short = viewport_height - lines_visible;
        tracing::trace!("DEBUG: lines_short={}, scrolling back", lines_short);

        let mut backtrack_iter = buffer.line_iterator(proposed_top_byte, 80);
        tracing::trace!(
            "DEBUG: Backtracking from byte {}",
            backtrack_iter.current_position()
        );
        for i in 0..lines_short {
            let pos_before = backtrack_iter.current_position();
            if backtrack_iter.prev().is_none() {
                tracing::trace!(
                    "DEBUG: Hit beginning of buffer at backtrack iteration {}",
                    i
                );
                break; // Hit the beginning of the buffer
            }
            let pos_after = backtrack_iter.current_position();
            tracing::trace!(
                "DEBUG: Backtrack iteration {}: {} -> {}",
                i,
                pos_before,
                pos_after
            );
        }

        let final_top_byte = backtrack_iter.current_position();
        tracing::trace!(
            "DEBUG: After backtracking, setting top_byte={}",
            final_top_byte
        );
        self.top_byte = final_top_byte;
    }

    /// Scroll to a specific line (byte-based)
    /// This seeks from the beginning to find the byte position of the line
    pub fn scroll_to(&mut self, buffer: &mut Buffer, line: usize) {
        // Seek from the beginning to find the byte position for this line
        let mut iter = buffer.line_iterator(0, 80);
        let mut current_line = 0;

        while current_line < line {
            if let Some((line_start, _)) = iter.next_line() {
                if current_line + 1 == line {
                    // Soft breaks unknown here (called from cursor flows that
                    // don't have ready access to the buffer's marker state).
                    // Pass empty: limit calc will use width-only wrap_line.
                    self.set_top_byte_with_limit(buffer, &[], &[], line_start);
                    return;
                }
                current_line += 1;
            } else {
                // Reached end of buffer before target line
                break;
            }
        }

        // If we didn't find the line, stay at the last valid position
        let target_position = iter.current_position();
        self.set_top_byte_with_limit(buffer, &[], &[], target_position);
    }

    /// Scroll so the last view line sits at the bottom of the viewport.
    ///
    /// Works in view-line space (soft-break-aware) — the same coordinate system
    /// used by `ensure_visible_in_layout`.  Returns `true` if the viewport was
    /// actually adjusted.
    pub fn scroll_to_end_of_view(&mut self, view_lines: &[ViewLine]) -> bool {
        let viewport_height = self.visible_line_count();
        if view_lines.is_empty() || viewport_height == 0 {
            return false;
        }
        let max_top = view_lines.len().saturating_sub(viewport_height);
        if self.top_view_line_offset == max_top {
            return false;
        }
        self.top_view_line_offset = max_top;
        if let Some(new_top_byte) = self.get_source_byte_for_view_line(view_lines, max_top) {
            self.top_byte = new_top_byte;
        }
        true
    }

    /// Mark viewport as needing synchronization with cursor positions
    /// This defers the actual viewport update until sync_with_cursor is called
    pub fn mark_needs_sync(&mut self) {
        self.needs_sync = true;
    }

    /// Check if viewport needs synchronization
    pub fn needs_sync(&self) -> bool {
        self.needs_sync
    }

    /// Synchronize viewport with cursor position (deferred ensure_visible)
    /// This should be called before rendering to batch multiple cursor movements
    pub fn sync_with_cursor(&mut self, buffer: &mut Buffer, cursor: &Cursor) {
        if self.needs_sync {
            self.ensure_visible(buffer, cursor, &[]);
            self.needs_sync = false;
        }
    }

    /// Low-level: ensure cursor is visible, scrolling if necessary.
    ///
    /// Callers should prefer [`BufferViewState::ensure_cursor_visible`] which
    /// automatically resolves fold ranges from the marker list. Use this
    /// directly only from the rendering pipeline where fold ranges are already
    /// resolved, or from unit tests (pass `&[]` for `hidden_ranges`).
    ///
    /// `hidden_ranges` contains `(start_byte, end_byte)` pairs for collapsed
    /// fold regions so that line counting skips hidden lines.
    pub(crate) fn ensure_visible(
        &mut self,
        buffer: &mut Buffer,
        cursor: &Cursor,
        hidden_ranges: &[(usize, usize)],
    ) {
        let _span = tracing::trace_span!(
            "ensure_visible",
            cursor_pos = cursor.position,
            top_byte = self.top_byte,
        )
        .entered();

        // When `top_view_line_offset > 0` the true top of the viewport is
        // some number of view lines into the wrapped line that starts at
        // `top_byte`. This routine has only a byte-oriented view of the
        // buffer, so its visibility math measures from `top_byte`, which
        // sits above the actual visible top. For a cursor that is below
        // `top_byte`, that undercounts the effective viewport and can
        // declare a cursor that is visually at the bottom of the screen
        // "outside the viewport", triggering a spurious scroll. Let the
        // view-line-aware `ensure_visible_in_layout` make the scroll
        // decision in that case — otherwise the viewport drifts one row
        // per arrow key press in heavily wrapped buffers (issue #1574).
        //
        // If the cursor is *above* `top_byte`, the byte-oriented math is
        // still correct (scroll up to bring the earlier line into view),
        // so we only skip when the cursor is below the current top.
        //
        // BUT: only defer when the cursor is plausibly inside (or just past)
        // the visible area. If the cursor is far below `top_byte` — say more
        // than two viewport heights of source lines away — the byte-math
        // undercount cannot account for the disagreement, and
        // `ensure_visible_in_layout` won't help either because its
        // `view_lines` slice only covers the rendered window. Falling
        // through here lets us perform a real scroll so cursor jumps
        // (programmatic scroll, plugin cursor-set, restored-state with stale
        // cursor) end up on screen instead of stranding the cursor far below
        // a frozen viewport (#1689 follow-up).
        if self.top_view_line_offset > 0 && cursor.position >= self.top_byte {
            let top_line = buffer.get_line_number(self.top_byte);
            let cursor_line = buffer.get_line_number(cursor.position);
            let viewport_height = self.visible_line_count().max(1);
            if cursor_line < top_line.saturating_add(viewport_height.saturating_mul(2)) {
                return;
            }
            // Cursor is far below — fall through and let the byte-oriented
            // scroll path bring the viewport down.
        }

        // Check if we should skip sync due to session restore
        // This prevents the restored scroll position from being overwritten
        if self.should_skip_resize_sync() {
            tracing::trace!("ensure_visible: SKIPPING due to skip_resize_sync");
            return;
        }

        // Check if we should skip ensure_visible due to scroll action
        // This prevents scroll actions (Ctrl+Up/Down) from being immediately undone
        if self.should_skip_ensure_visible() {
            tracing::trace!("ensure_visible: SKIPPING due to skip_ensure_visible flag");
            return;
        }
        tracing::trace!(
            "ensure_visible: NOT skipping, skip_ensure_visible={}",
            self.skip_ensure_visible
        );

        // For large files with lazy loading, ensure data around cursor is loaded
        let viewport_lines = self.visible_line_count().max(1);

        tracing::trace!(
            "ensure_visible: cursor={}, top_byte={}, viewport_lines={}, line_wrap={}",
            cursor.position,
            self.top_byte,
            viewport_lines,
            self.line_wrap_enabled
        );

        // CRITICAL: Load data around cursor position explicitly before using iterators
        // Load enough data to cover viewport above and below cursor
        let estimated_viewport_bytes = viewport_lines * 200;
        let load_start = cursor.position.saturating_sub(estimated_viewport_bytes * 2);
        // Cap load_length to not go past EOF
        let buffer_len = buffer.len();
        let remaining_bytes = buffer_len.saturating_sub(load_start);
        let load_length = (estimated_viewport_bytes * 3).min(remaining_bytes);

        // Force-load the data by actually requesting it (not just prepare_viewport)
        {
            let _span =
                tracing::trace_span!("ensure_visible_load", load_start, load_length,).entered();
            if let Err(e) = buffer.get_text_range_mut(load_start, load_length) {
                tracing::warn!(
                    "Failed to load data around cursor at {}: {}",
                    cursor.position,
                    e
                );
            }
        }

        // Find the start of the line containing the cursor using iterator
        let cursor_iter = buffer.line_iterator(cursor.position, 80);
        let cursor_line_start = cursor_iter.current_position();

        // Check if cursor is visible by counting VISUAL ROWS between top_byte and cursor
        // When line wrapping is enabled, we need to count wrapped rows, not logical lines!
        // Apply scroll_offset to keep cursor away from edges
        let effective_offset = self.scroll_offset.min(viewport_lines / 2);

        // Track whether cursor needs scrolling and in which direction:
        // true = cursor is near/above the top edge, false = near/below the bottom edge
        let mut cursor_near_top = false;

        let cursor_is_visible = if cursor_line_start < self.top_byte {
            // Cursor is above viewport
            cursor_near_top = true;
            false
        } else if self.line_wrap_enabled {
            // With line wrapping: count VISUAL ROWS (wrapped segments), not logical lines
            let gutter_width = self.gutter_width(buffer);
            let wrap_config = WrapConfig::new(
                self.effective_width() as usize,
                gutter_width,
                true,
                self.wrap_indent,
            );

            let mut iter = buffer.line_iterator(self.top_byte, 80);
            let mut visual_rows = 0;

            // Iterate through logical lines, but count their wrapped rows
            loop {
                let current_pos = iter.current_position();

                // If we reached the cursor's line, check if the cursor is within visible rows
                if current_pos >= cursor_line_start {
                    // The cursor's line starts within or after the viewport
                    if current_pos == cursor_line_start {
                        // We need to check if the cursor's SPECIFIC POSITION within the wrapped line is visible
                        // Get the line content
                        let line_content = if let Some((_, content)) = iter.next_line() {
                            content.trim_end_matches(['\n', '\r']).to_string()
                        } else {
                            // At EOF after trailing newline - empty line
                            String::new()
                        };

                        // Wrap the line via the renderer's word-boundary
                        // wrap so row counts and cursor positioning agree
                        // with what's actually drawn.  (See
                        // docs/internal/line-wrap-cache-plan.md — the
                        // whole branch is about eliminating the
                        // `wrap_line` / `apply_wrapping_transform` drift.)
                        let effective_width = wrap_config
                            .first_line_width
                            .saturating_add(wrap_config.gutter_width)
                            .max(2);
                        let layout = crate::view::line_wrap_cache::layout_for_plain_text(
                            &line_content,
                            effective_width,
                            wrap_config.gutter_width,
                            wrap_config.hanging_indent,
                            4,
                        );
                        let segments_count = layout.len().max(1); // empty line = 1 row

                        // Find which ViewLine the cursor is in.
                        let cursor_column = cursor.position.saturating_sub(cursor_line_start);
                        let (cursor_segment_idx, _) =
                            crate::view::line_wrap_cache::char_position_in_layout(
                                &layout,
                                cursor_column,
                            );

                        // Add the rows for this line up to and including the cursor's segment.
                        // Empty lines have cursor_segment_idx = 0 → 1 row.
                        visual_rows += cursor_segment_idx.min(segments_count - 1) + 1;

                        // Check if cursor's row is within viewport with scroll offset applied.
                        // visual_rows is 1-based (includes cursor's own row), so use `>`
                        // to match the 0-based `lines_from_top >= effective_offset` in non-wrapped mode.
                        // Both give a margin of exactly `effective_offset` rows from the top edge.
                        let vis = visual_rows > effective_offset
                            && visual_rows <= viewport_lines.saturating_sub(effective_offset);
                        if !vis && visual_rows <= effective_offset {
                            cursor_near_top = true;
                        }
                        break vis;
                    } else {
                        // We passed the cursor's line without finding it - shouldn't happen
                        break false;
                    }
                }

                // Skip entire hidden fold region at once
                if let Some((_start, end)) =
                    Self::containing_hidden_range(hidden_ranges, current_pos)
                {
                    while iter.current_position() < end
                        && iter.current_position() < cursor_line_start
                    {
                        if iter.next_line().is_none() {
                            break;
                        }
                    }
                    continue;
                }

                // Get the next line
                if let Some((_line_start, line_content)) = iter.next_line() {
                    // Count visual rows via the renderer's word-boundary wrap.
                    let line_text = line_content.trim_end_matches('\n');
                    let effective_width = wrap_config
                        .first_line_width
                        .saturating_add(wrap_config.gutter_width)
                        .max(2);
                    let layout = crate::view::line_wrap_cache::layout_for_plain_text(
                        line_text,
                        effective_width,
                        wrap_config.gutter_width,
                        wrap_config.hanging_indent,
                        4,
                    );
                    visual_rows += layout.len();

                    // If we've exceeded the viewport, cursor is not visible
                    if visual_rows >= viewport_lines {
                        break false;
                    }
                } else {
                    // Reached end of buffer
                    break false;
                }
            }
        } else {
            // Without line wrapping: count visible (non-hidden) logical lines
            let mut iter = buffer.line_iterator(self.top_byte, 80);
            let mut lines_from_top = 0;

            while iter.current_position() < cursor_line_start && lines_from_top < viewport_lines {
                let pos = iter.current_position();
                // Skip entire hidden fold region at once
                if let Some((_start, end)) = Self::containing_hidden_range(hidden_ranges, pos) {
                    while iter.current_position() < end
                        && iter.current_position() < cursor_line_start
                    {
                        if iter.next_line().is_none() {
                            break;
                        }
                    }
                    continue;
                }
                if iter.next_line().is_none() {
                    break;
                }
                lines_from_top += 1;
            }

            // Apply scroll offset: cursor should be between offset and (viewport_lines - offset)
            if lines_from_top < effective_offset {
                cursor_near_top = true;
            }
            let visible = lines_from_top >= effective_offset
                && lines_from_top < viewport_lines.saturating_sub(effective_offset);
            tracing::trace!(
                "ensure_visible (no wrap): lines_from_top={}, effective_offset={}, visible={}",
                lines_from_top,
                effective_offset,
                visible
            );
            visible
        };

        tracing::trace!(
            "ensure_visible: cursor_line_start={}, cursor_is_visible={}",
            cursor_line_start,
            cursor_is_visible
        );

        // If cursor is not visible, scroll minimally to bring it within the margin.
        // Instead of centering, we place the cursor just inside the scroll margin:
        // - If cursor is below viewport: place cursor at (viewport - margin) from top
        // - If cursor is above viewport: place cursor at margin from top
        if !cursor_is_visible {
            let _span =
                tracing::trace_span!("ensure_visible_scroll", cursor_near_top, cursor_line_start,)
                    .entered();
            // We want cursor at (viewport_lines - 1 - effective_offset) rows from new top
            // when scrolling down, or at effective_offset rows from new top when scrolling up.

            if self.line_wrap_enabled {
                // When wrapping is enabled, count visual rows (wrapped segments) not logical lines.
                // The wrapped backward scan starts visual_rows_counted at 1+ (cursor's line),
                // so the target must be 1 more than for the non-wrapped case.
                //
                // TODO: this backward walk recomputes per-line layouts via
                // `layout_for_plain_text`, bypassing both `LineWrapCache` and
                // the `VisualRowIndex` tier-2 cache.  Migrating it requires
                // threading `&mut EditorState` (or splitting borrows) through
                // `ensure_visible` + 6 call sites — left as a follow-up since
                // folds force a fallback path here regardless and the
                // dominant scrollbar-drag bottleneck is already fixed by the
                // tier-2 migration of `scrollbar_math` and `scrollbar.rs`.
                let target_visual_rows = if cursor_near_top {
                    effective_offset + 1
                } else {
                    viewport_lines.saturating_sub(effective_offset)
                };

                let gutter_width = self.gutter_width(buffer);
                let wrap_config = WrapConfig::new(
                    self.effective_width() as usize,
                    gutter_width,
                    true,
                    self.wrap_indent,
                );

                let mut iter = buffer.line_iterator(cursor_line_start, 80);
                let mut visual_rows_counted = 0;
                // Track the cursor's own wrap-segment index in its containing
                // logical line. If the cursor's line already contains enough
                // wrap segments to satisfy the scroll margin, we don't need to
                // walk backward at all — we can just shift `top_view_line_offset`
                // within the same line so the cursor lands at exactly
                // `effective_offset` rows from the viewport top. Without this,
                // stepping Up onto the last row of a long wrapped paragraph
                // would scroll `top_byte` all the way back to that paragraph's
                // logical line start and set `top_view_line_offset = 0`,
                // teleporting the cursor many rows down on screen (issue
                // #1574, Up-arrow jumpy variant at step 16).
                let mut cursor_segment_idx_in_line: usize = 0;

                // First, count how many rows the cursor's line takes up to the cursor position.
                if let Some((_line_start, line_content)) = iter.next_line() {
                    let line_text = if line_content.ends_with('\n') {
                        &line_content[..line_content.len() - 1]
                    } else {
                        &line_content
                    };
                    let effective_width = wrap_config
                        .first_line_width
                        .saturating_add(wrap_config.gutter_width)
                        .max(2);
                    let layout = crate::view::line_wrap_cache::layout_for_plain_text(
                        line_text,
                        effective_width,
                        wrap_config.gutter_width,
                        wrap_config.hanging_indent,
                        4,
                    );
                    let cursor_column = cursor.position.saturating_sub(cursor_line_start);
                    let (cursor_segment_idx, _) =
                        crate::view::line_wrap_cache::char_position_in_layout(
                            &layout,
                            cursor_column,
                        );
                    cursor_segment_idx_in_line = cursor_segment_idx;
                    visual_rows_counted += cursor_segment_idx + 1;
                } else {
                    // At EOF after trailing newline - cursor is on empty line, needs 1 row
                    visual_rows_counted += 1;
                }

                // Fast path: cursor's own line already has enough wrap
                // segments above the cursor to fill the scroll margin.
                // Stay on this line and use top_view_line_offset to place
                // the cursor at the right row.
                if cursor_near_top && visual_rows_counted >= target_visual_rows {
                    self.set_top_byte_with_limit(buffer, &[], &[], cursor_line_start);
                    self.top_view_line_offset =
                        cursor_segment_idx_in_line.saturating_sub(effective_offset);
                    self.scrolled_up_in_wrap = true;
                    return;
                }

                // Now move backwards counting visual rows until we reach target.
                iter = buffer.line_iterator(cursor_line_start, 80);
                // When scrolling UP (cursor above viewport) in wrap mode and
                // the backward walk overshoots the target, set
                // `top_view_line_offset` to the wrap-segment offset within
                // the landing line so the cursor lands at exactly
                // `effective_offset` rows from the new viewport top.
                // Without this the cursor can "teleport" many rows deeper
                // than intended when the preceding logical line is a long
                // wrapped paragraph (issue #1574, Up-arrow jumpy variant at
                // step 16 of the width-sweep).  This fix is gated to the
                // scroll-up direction — the scroll-DOWN path relies on the
                // original "land at line start" behavior (setting
                // `top_view_line_offset = 0`) to match the existing
                // Down-arrow scrolling invariants.
                let mut top_offset_in_landing_line: usize = 0;
                while visual_rows_counted < target_visual_rows {
                    if iter.prev().is_none() {
                        break; // Hit beginning of buffer
                    }
                    // Skip hidden fold regions backward (handles adjacent folds).
                    while let Some((start, _end)) =
                        Self::containing_hidden_range(hidden_ranges, iter.current_position())
                    {
                        while iter.current_position() >= start {
                            if iter.prev().is_none() {
                                break;
                            }
                        }
                    }
                    // Read the visible line's content to count wrapped rows,
                    // then rewind so the next prev() moves to the line before.
                    if let Some((_ls, line_content)) = iter.next_line() {
                        let line_text = if line_content.ends_with('\n') {
                            &line_content[..line_content.len() - 1]
                        } else {
                            &line_content
                        };
                        let effective_width = wrap_config
                            .first_line_width
                            .saturating_add(wrap_config.gutter_width)
                            .max(2);
                        let layout = crate::view::line_wrap_cache::layout_for_plain_text(
                            line_text,
                            effective_width,
                            wrap_config.gutter_width,
                            wrap_config.hanging_indent,
                            4,
                        );
                        let added = layout.len().max(1);
                        let new_total = visual_rows_counted + added;
                        if cursor_near_top && new_total >= target_visual_rows {
                            let rows_from_this_line =
                                target_visual_rows.saturating_sub(visual_rows_counted);
                            top_offset_in_landing_line = added.saturating_sub(rows_from_this_line);
                            iter.prev();
                            break;
                        }
                        visual_rows_counted = new_total;
                        iter.prev();
                    }
                }

                let new_top_byte = iter.current_position();
                // ensure_visible is called from cursor-driven flows that don't
                // surface soft-break markers here; the limit calc falls back
                // to width-only wrap_line counting.
                self.set_top_byte_with_limit(buffer, &[], &[], new_top_byte);
                self.top_view_line_offset = top_offset_in_landing_line;
                if cursor_near_top {
                    self.scrolled_up_in_wrap = true;
                }
            } else {
                // Non-wrapped mode: count only visible lines when scanning backward
                let target_rows_from_top = if cursor_near_top {
                    effective_offset
                } else {
                    viewport_lines.saturating_sub(effective_offset + 1)
                };

                let mut iter = buffer.line_iterator(cursor_line_start, 80);
                let mut visible_counted = 0;

                while visible_counted < target_rows_from_top {
                    if iter.prev().is_none() {
                        break; // Hit beginning of buffer
                    }
                    // Skip entire hidden fold region backward, then
                    // handle adjacent/nested folds with a loop.
                    while let Some((start, _end)) =
                        Self::containing_hidden_range(hidden_ranges, iter.current_position())
                    {
                        while iter.current_position() >= start {
                            if iter.prev().is_none() {
                                break;
                            }
                        }
                    }
                    // After any skips we're on a visible line — count it.
                    visible_counted += 1;
                }

                let new_top_byte = iter.current_position();
                // ensure_visible is called from cursor-driven flows that don't
                // surface soft-break markers here; the limit calc falls back
                // to width-only wrap_line counting.
                self.set_top_byte_with_limit(buffer, &[], &[], new_top_byte);
                self.top_view_line_offset = 0;
            }
        }

        // Horizontal scrolling - skip if line wrapping is enabled
        // When wrapping is enabled, all columns are always visible via wrapping
        if !self.line_wrap_enabled {
            let cursor_column = cursor.position.saturating_sub(cursor_line_start);

            // Get the line content to know its length (for limiting horizontal scroll)
            let mut line_iter = buffer.line_iterator(cursor_line_start, 80);
            let line_length = if let Some((_start, content)) = line_iter.next_line() {
                // Line length without the newline character
                content.trim_end_matches('\n').len()
            } else {
                0
            };

            self.ensure_column_visible(cursor_column, line_length, buffer);
        } else {
            // With line wrapping enabled, reset any horizontal scroll
            self.left_column = 0;
        }
    }

    /// Ensure a line is visible with scroll offset applied
    /// This is a legacy method kept for backward compatibility with tests
    /// In practice, use ensure_visible() which works directly with cursors and bytes
    pub fn ensure_line_visible(&mut self, buffer: &mut Buffer, line: usize) {
        // Seek to the target line to get its byte position
        let mut seek_iter = buffer.line_iterator(0, 80);
        let mut current_line = 0;
        let mut target_line_byte = 0;

        while current_line < line {
            if let Some((line_start, _)) = seek_iter.next_line() {
                if current_line + 1 == line {
                    target_line_byte = line_start;
                    break;
                }
                current_line += 1;
            } else {
                // Reached end of buffer before target line
                return;
            }
        }

        // Check if the line is already visible by iterating from top_byte
        let visible_count = self.visible_line_count();
        let mut iter = buffer.line_iterator(self.top_byte, 80);
        let mut lines_from_top = 0;
        let mut target_is_visible = false;

        while let Some((line_byte, _)) = iter.next_line() {
            if line_byte == target_line_byte {
                target_is_visible = lines_from_top < visible_count;
                break;
            }
            lines_from_top += 1;
            if lines_from_top >= visible_count {
                break;
            }
        }

        // If not visible, scroll to show it with scroll offset
        if !target_is_visible {
            let effective_offset = self.scroll_offset.min(visible_count / 2);
            let target_line_from_top = effective_offset;

            // Move backwards from target to find new top_byte
            let mut iter = buffer.line_iterator(target_line_byte, 80);
            for _ in 0..target_line_from_top {
                if iter.prev().is_none() {
                    break;
                }
            }
            let position = iter.current_position();
            // Cursor-positioning flow: no soft-break info available here.
            self.set_top_byte_with_limit(buffer, &[], &[], position);
        }
    }

    /// Ensure a column is visible with horizontal scroll offset applied
    ///
    /// # Arguments
    /// * `column` - The column position within the line (0-indexed)
    /// * `line_length` - The length of the line content (without newline)
    /// * `buffer` - The buffer (for calculating gutter width)
    pub fn ensure_column_visible(
        &mut self,
        column: usize,
        line_length: usize,
        buffer: &mut Buffer,
    ) {
        // Calculate visible width (accounting for line numbers gutter which is dynamic)
        let gutter_width = self.gutter_width(buffer);
        // Also account for scrollbar (always present, takes 1 column)
        let scrollbar_width = 1;
        let visible_width = (self.width as usize)
            .saturating_sub(gutter_width)
            .saturating_sub(scrollbar_width);

        if visible_width == 0 {
            return; // Terminal too narrow
        }

        // If viewport is too small for scroll offset, use what we can
        let effective_offset = self.horizontal_scroll_offset.min(visible_width / 2);

        // Calculate the ideal left and right boundaries with scroll offset
        let ideal_left = self.left_column + effective_offset;
        let ideal_right = self.left_column + visible_width.saturating_sub(effective_offset);

        if column < ideal_left {
            // Cursor is to the left of the ideal zone - scroll left
            self.left_column = column.saturating_sub(effective_offset);
        } else if column >= ideal_right {
            // Cursor is to the right of the ideal zone - scroll right
            // Place cursor at (visible_width - effective_offset - 1) to keep it in valid range [0, visible_width-1]
            let target_position = visible_width
                .saturating_sub(effective_offset)
                .saturating_sub(1);
            self.left_column = column.saturating_sub(target_position);
        }

        // BUGFIX: Limit left_column to ensure content is always visible
        // Don't scroll past the point where the end of the line would be off-screen to the left
        // This prevents the viewport from scrolling into "empty space" past the line content
        if line_length > 0 {
            // Calculate the maximum left_column that still shows some content
            // Account for cursor potentially being one position past the line content (at position line_length)
            // If the line is shorter than visible width, left_column should be 0
            // Otherwise, allow scrolling enough to show position line_length at the last visible column
            let max_left_column = line_length.saturating_sub(visible_width.saturating_sub(1));

            // Limit left_column to max_left_column
            if self.left_column > max_left_column {
                self.left_column = max_left_column;
            }
        }
    }

    /// Ensure multiple cursors are visible (smart scroll for multi-cursor)
    /// Prioritizes keeping the primary cursor visible
    pub fn ensure_cursors_visible(
        &mut self,
        buffer: &mut Buffer,
        cursors: &[(usize, &Cursor)], // (priority, cursor) - lower priority number = higher priority
    ) {
        if cursors.is_empty() {
            return;
        }

        // Sort cursors by priority (primary cursor first)
        let mut sorted_cursors: Vec<_> = cursors.to_vec();
        sorted_cursors.sort_by_key(|(priority, _)| *priority);

        // Get byte positions for all cursors (at line starts)
        let cursor_line_bytes: Vec<usize> = sorted_cursors
            .iter()
            .map(|(_, cursor)| {
                let iter = buffer.line_iterator(cursor.position, 80);
                iter.current_position()
            })
            .collect();

        // Count how many lines span between min and max cursors
        let min_byte = *cursor_line_bytes.iter().min().unwrap();
        let max_byte = *cursor_line_bytes.iter().max().unwrap();

        // Count lines between min and max using iterator
        let mut iter = buffer.line_iterator(min_byte, 80);
        let mut line_span = 0;
        while let Some((line_byte, _)) = iter.next_line() {
            if line_byte >= max_byte {
                break;
            }
            line_span += 1;
        }

        let visible_count = self.visible_line_count();

        // If all cursors fit in the viewport, center them
        if line_span < visible_count {
            let lines_to_go_back = visible_count / 2;
            let mut iter = buffer.line_iterator(min_byte, 80);
            for _ in 0..lines_to_go_back {
                if iter.prev().is_none() {
                    break;
                }
            }
            let position = iter.current_position();
            // Cursor-positioning flow: no soft-break info available here.
            self.set_top_byte_with_limit(buffer, &[], &[], position);
        } else {
            // Can't fit all cursors, ensure primary is visible
            let primary_cursor = sorted_cursors[0].1;
            self.ensure_visible(buffer, primary_cursor, &[]);
        }
    }

    /// Get the cursor screen position (x, y) which is (col, row) for rendering
    /// This returns the position relative to the viewport, accounting for horizontal scrolling
    ///
    /// NOTE: This function is kept for popup positioning and multi-cursor display,
    /// but is NO LONGER used for primary cursor rendering, which now happens during
    /// the line rendering loop in split_rendering.rs to eliminate duplicate line iteration.
    pub fn cursor_screen_position(&self, buffer: &mut Buffer, cursor: &Cursor) -> (u16, u16) {
        // Find line start using iterator
        let cursor_iter = buffer.line_iterator(cursor.position, 80);
        let line_start = cursor_iter.current_position();
        let column = cursor.position.saturating_sub(line_start);

        // Wrap config used for both visual-row counting (lines above the
        // cursor) and the cursor's own intra-line position. Built once.
        let wrap_config = if self.line_wrap_enabled {
            let gutter_width = self.gutter_width(buffer);
            Some(WrapConfig::new(
                self.effective_width() as usize,
                gutter_width,
                true,
                self.wrap_indent,
            ))
        } else {
            None
        };

        // Count visual rows from top_byte up to (but not including) the
        // cursor's line. With wrap enabled, lines above the cursor may
        // occupy multiple visual rows; counting logical lines anchors
        // popups (e.g. completion) to the wrong screen row in heavily
        // wrapped buffers — see issue #1794.
        let mut iter = buffer.line_iterator(self.top_byte, 80);
        let mut screen_row: usize = 0;

        while let Some((line_byte, content)) = iter.next_line() {
            if line_byte >= line_start {
                break;
            }
            if let Some(ref config) = wrap_config {
                let line_end = iter.current_position();
                let line_text = content.trim_end_matches(['\n', '\r']);
                screen_row += Self::count_visual_rows_for_line(
                    line_byte,
                    line_end,
                    line_text,
                    config,
                    &[],
                    &[],
                    None,
                );
            } else {
                screen_row += 1;
            }
        }

        // Calculate screen column and additional wrapped rows if line wrapping is enabled
        let (screen_col, additional_rows) = if let Some(ref config) = wrap_config {
            // Get the line text for wrapping
            let mut line_iter = buffer.line_iterator(line_start, 80);
            let line_text = if let Some((_start, content)) = line_iter.next_line() {
                // Remove trailing newline if present
                content.trim_end_matches(['\n', '\r']).to_string()
            } else {
                String::new()
            };

            // Wrap the line via the renderer's word-boundary wrap so the
            // returned screen coordinates match where the renderer draws
            // the cursor.
            let effective_width = config
                .first_line_width
                .saturating_add(config.gutter_width)
                .max(2);
            let layout = crate::view::line_wrap_cache::layout_for_plain_text(
                &line_text,
                effective_width,
                config.gutter_width,
                config.hanging_indent,
                4,
            );

            // Find which ViewLine the cursor is in and its visual column.
            let (segment_idx, col_in_segment) =
                crate::view::line_wrap_cache::char_position_in_layout(&layout, column);

            (col_in_segment as u16, segment_idx)
        } else {
            // No wrapping - account for horizontal scrolling
            let screen_col = column.saturating_sub(self.left_column) as u16;
            (screen_col, 0)
        };

        // If `top_byte` sits mid-line (visual offset into the first
        // visible logical line), the on-screen origin is shifted up by
        // that offset.
        let total_row = (screen_row + additional_rows).saturating_sub(self.top_view_line_offset);

        // Return (x, y) which is (col, row)
        (screen_col, total_row as u16)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::buffer::Buffer;
    use crate::model::cursor::Cursor;

    #[test]
    fn test_viewport_new() {
        let vp = Viewport::new(80, 24);
        assert_eq!(vp.width, 80);
        assert_eq!(vp.height, 24);
        assert_eq!(vp.top_byte, 0);
    }

    #[test]
    fn test_scroll_up_down() {
        // Create a buffer with more lines than the viewport to make scrolling possible
        let mut content = String::new();
        for i in 1..=50 {
            if i > 1 {
                content.push('\n');
            }
            content.push_str(&format!("line{}", i));
        }
        let mut buffer = Buffer::from_str_test(&content);
        let mut vp = Viewport::new(80, 24);

        vp.scroll_down(&mut buffer, &[], &[], 10);
        // Check that we scrolled down (top_byte should be > 0)
        assert!(vp.top_byte > 0);

        let prev_top = vp.top_byte;
        vp.scroll_up(&mut buffer, &[], &[], 5);
        // Check that we scrolled up (top_byte should be less than before)
        assert!(vp.top_byte < prev_top);

        vp.scroll_up(&mut buffer, &[], &[], 100);
        assert_eq!(vp.top_byte, 0); // Can't scroll past 0
    }

    #[test]
    fn test_ensure_line_visible() {
        let mut buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20\nline21\nline22\nline23\nline24\nline25\nline26\nline27\nline28\nline29\nline30\nline31\nline32\nline33\nline34\nline35\nline36\nline37\nline38\nline39\nline40\nline41\nline42\nline43\nline44\nline45\nline46\nline47\nline48\nline49\nline50\nline51");
        let mut vp = Viewport::new(80, 24);
        vp.scroll_offset = 3;

        // Line within scroll offset should adjust viewport
        vp.ensure_line_visible(&mut buffer, 2);
        // top_byte should be close to the beginning since line 2 is near the top
        assert!(vp.top_byte < 100);

        // Line far below should scroll down
        vp.ensure_line_visible(&mut buffer, 50);
        assert!(vp.top_byte > 0);
        // Verify the line is now visible by checking we can iterate to it
        let mut iter = buffer.line_iterator(vp.top_byte, 80);
        let mut found = false;
        for _ in 0..vp.visible_line_count() {
            if iter.next_line().is_none() {
                break;
            }
            found = true;
        }
        assert!(found);
    }

    #[test]
    fn test_ensure_visible_with_cursor() {
        let mut buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20");
        let mut vp = Viewport::new(80, 10);

        // Find byte position of line 15 using iterator
        let mut iter = buffer.line_iterator(0, 80);
        let mut cursor_pos = 0;
        for i in 0..15 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 14 {
                    cursor_pos = line_start;
                    break;
                }
            }
        }

        let cursor = Cursor::new(cursor_pos);
        vp.ensure_visible(&mut buffer, &cursor, &[]);

        // Verify cursor is now visible by checking we scrolled appropriately
        assert!(vp.top_byte > 0);
    }

    #[test]
    fn test_cursor_screen_position() {
        let mut buffer = Buffer::from_str_test("line1\nline2\nline3");
        let vp = Viewport::new(80, 24);

        let cursor = Cursor::new(6); // Start of line 1 ("line2")
        let (x, y) = vp.cursor_screen_position(&mut buffer, &cursor);
        // x is column (horizontal), y is row (vertical)
        assert_eq!(x, 0); // Column 0 (start of line)
        assert_eq!(y, 1); // Row 1 (second line, since top_line is 0)
    }

    /// Issue #1794: completion popup is anchored to the wrong screen row in
    /// heavily-wrapped buffers because the row count from `top_byte` to the
    /// cursor's line was being computed in *logical lines* rather than
    /// *visual rows*. With wrap enabled, lines above the cursor that occupy
    /// multiple visual rows must each contribute their full visual-row count.
    #[test]
    fn test_cursor_screen_position_with_wrapped_lines_above() {
        // Build 4 lines where each line wraps to ~3 visual rows in a 30-col
        // viewport. Identical wrap behaviour to the issue's repro: long
        // sentences that will be word-wrapped onto multiple rows.
        let long = "the quick brown fox jumps over the lazy dog and runs away";
        let content = format!("{long}\n{long}\n{long}\n{long}");
        let mut buffer = Buffer::from_str_test(&content);

        let mut vp = Viewport::new(30, 24);
        vp.line_wrap_enabled = true;
        vp.show_line_numbers = false; // simpler width math

        // Place the cursor at the END of line 4 (last logical line). With
        // ~30-col wrap, each of the 3 prior lines wraps to 3 visual rows
        // (= 9 rows total above), and the cursor's own line lands on its
        // last sub-row. The popup expects the cursor's true visual row.
        let cursor_pos = content.len();
        let cursor = Cursor::new(cursor_pos);
        let (_x, y) = vp.cursor_screen_position(&mut buffer, &cursor);

        // With 3 wrapped lines above (>=2 visual rows each) the cursor's
        // visual row must be at least 6. Pre-fix, this returns 3 + segment
        // (i.e. ~5) because the prior 3 lines were counted as 1 row each.
        assert!(
            y >= 6,
            "expected cursor visual row >= 6 (3 wrapped lines above × >=2 rows), got {y}"
        );
    }

    #[test]
    fn test_ensure_visible_cursor_above_viewport() {
        // Create buffer with many lines
        let mut buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20");
        let mut vp = Viewport::new(80, 10); // 10 lines visible

        // Scroll down to show lines 10-19 (top_byte at line 10)
        // scroll_to uses 1-based line numbers, so line 10 = argument 10
        vp.scroll_to(&mut buffer, 10);
        let _old_top_byte = vp.top_byte;

        // Verify we scrolled to around line 10
        let top_line = buffer.get_line_number(vp.top_byte);
        assert!(
            top_line >= 9,
            "Should have scrolled down to at least line 10"
        );

        // Now move cursor to line 5 (above the viewport)
        let mut iter = buffer.line_iterator(0, 80);
        let mut line_5_byte = 0;
        for i in 0..5 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 4 {
                    line_5_byte = line_start;
                    break;
                }
            }
        }
        let cursor = Cursor::new(line_5_byte);

        // Before fix, this should fail because ensure_visible doesn't detect cursor is above viewport
        vp.ensure_visible(&mut buffer, &cursor, &[]);

        // Verify that viewport scrolled up to make cursor visible
        // The viewport should now be positioned so cursor (line 5) is visible
        let new_top_line = buffer.get_line_number(vp.top_byte);
        let cursor_line = buffer.get_line_number(line_5_byte);
        assert!(
            cursor_line >= new_top_line,
            "Cursor line should be at or below top of viewport"
        );
        assert!(
            new_top_line < top_line,
            "Viewport should have scrolled up from line {}",
            top_line
        );

        // Verify cursor is within visible area
        let lines_from_top = cursor_line.saturating_sub(new_top_line);
        assert!(
            lines_from_top < vp.visible_line_count(),
            "Cursor should be within visible area"
        );

        // Verify cursor is placed near the scroll margin (not centered)
        // With minimal scroll, cursor above viewport is placed at scroll_offset from top
        let expected_offset = vp.scroll_offset.min(vp.visible_line_count() / 2);
        assert!(
            lines_from_top <= expected_offset + 1,
            "Cursor should be near scroll margin, expected around {}, got {}",
            expected_offset,
            lines_from_top
        );
    }

    #[test]
    fn test_ensure_visible_cursor_below_viewport_centers() {
        // Create buffer with many lines
        let mut buffer = Buffer::from_str_test("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20");
        let mut vp = Viewport::new(80, 10); // 10 lines visible

        // Start at top (line 1 visible)
        assert_eq!(vp.top_byte, 0);

        // Move cursor to line 15 (below viewport)
        let mut iter = buffer.line_iterator(0, 80);
        let mut line_15_byte = 0;
        for i in 0..15 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 14 {
                    line_15_byte = line_start;
                    break;
                }
            }
        }
        let cursor = Cursor::new(line_15_byte);

        vp.ensure_visible(&mut buffer, &cursor, &[]);

        // Verify cursor is placed near the bottom scroll margin (not centered)
        // With minimal scroll, cursor below viewport is placed at (viewport - scroll_offset) from top
        let new_top_line = buffer.get_line_number(vp.top_byte);
        let cursor_line = buffer.get_line_number(line_15_byte);
        let lines_from_top = cursor_line.saturating_sub(new_top_line);

        let viewport_lines = vp.visible_line_count();
        let expected_offset = vp.scroll_offset.min(viewport_lines / 2);
        let expected_bottom = viewport_lines.saturating_sub(expected_offset + 1);
        assert!(
            lines_from_top >= expected_bottom.saturating_sub(1),
            "Cursor should be near bottom margin when jumping down, expected around {}, got {}",
            expected_bottom,
            lines_from_top
        );
    }

    #[test]
    fn test_ensure_column_visible_resets_to_zero() {
        // Test that horizontal scroll is reset when cursor moves to column 0
        // This simulates what happens after pressing Enter on a long line
        let mut buffer = Buffer::from_str_test("a".repeat(100).as_str());
        let mut vp = Viewport::new(80, 24);
        vp.line_wrap_enabled = false;

        // First, scroll right by moving cursor to end of line
        let cursor_at_end = Cursor::new(100);
        vp.ensure_visible(&mut buffer, &cursor_at_end, &[]);

        println!("After moving to position 100:");
        println!("  left_column = {}", vp.left_column);

        // Verify we've scrolled right
        assert!(
            vp.left_column > 0,
            "Should have scrolled right, but left_column = {}",
            vp.left_column
        );

        // Now simulate pressing Enter: newline is added, cursor moves to start of new line
        // Add the newline to the buffer
        // Note: In real usage the buffer would be modified, but for this test we just
        // need to test ensure_column_visible with cursor at column 0

        // Test ensure_column_visible directly with column=0 and the current left_column
        // This simulates what should happen when cursor is at column 0 on a new line
        vp.ensure_column_visible(0, 0, &mut buffer); // column=0, line_length=0 (empty new line)

        println!("After ensure_column_visible(0, 0):");
        println!("  left_column = {}", vp.left_column);

        assert_eq!(
            vp.left_column, 0,
            "left_column should be reset to 0 when cursor is at column 0, but got {}",
            vp.left_column
        );
    }

    /// Regression for #1689 follow-up: in wrap mode with
    /// `top_view_line_offset > 0`, the early-return at the top of
    /// `ensure_visible` used to fire for *any* cursor below `top_byte`,
    /// stranding cursors that were many lines below the viewport. Verify
    /// the early-return now defers to a real scroll when the cursor is
    /// far below (more than 2x viewport height in source lines).
    #[test]
    fn test_ensure_visible_far_below_top_with_wrap_offset_does_scroll() {
        // 200 lines so we have plenty of room for "far below".
        let mut content = String::new();
        for i in 0..200 {
            content.push_str(&format!("line_{i}\n"));
        }
        let mut buffer = Buffer::from_str_test(&content);

        let mut vp = Viewport::new(80, 10); // 10 visible lines
        vp.line_wrap_enabled = true;

        // Park top_byte at line 5 and inject a non-zero wrap offset to
        // trigger the wrap-mode early-return. (Real users hit this state
        // via the wrap-aware scroll-up path, but we set it manually here.)
        let mut iter = buffer.line_iterator(0, 80);
        let mut line_5_byte = 0;
        for i in 0..5 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 4 {
                    line_5_byte = line_start;
                    break;
                }
            }
        }
        vp.top_byte = line_5_byte;
        vp.top_view_line_offset = 2; // > 0 → triggers the early-return path

        let top_before = vp.top_byte;

        // Move cursor to line 100 — way below `top_byte` (10*2=20 viewport
        // heights of 1 source line each, so cursor at line 100 is well
        // beyond `top_line + 2*viewport_height`).
        let mut iter = buffer.line_iterator(0, 80);
        let mut line_100_byte = 0;
        for i in 0..100 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 99 {
                    line_100_byte = line_start;
                    break;
                }
            }
        }
        let cursor = Cursor::new(line_100_byte);

        vp.ensure_visible(&mut buffer, &cursor, &[]);

        assert_ne!(
            vp.top_byte, top_before,
            "ensure_visible must scroll when the cursor is far below the viewport \
             top, even in wrap mode with `top_view_line_offset > 0`. Pre-fix this \
             early-returned at the top of `ensure_visible` and the viewport stalled."
        );

        // Cursor should now be inside the viewport's source-line range.
        let new_top_line = buffer.get_line_number(vp.top_byte);
        let cursor_line = buffer.get_line_number(line_100_byte);
        let viewport_height = vp.visible_line_count();
        assert!(
            cursor_line >= new_top_line && cursor_line < new_top_line + viewport_height,
            "After scrolling, cursor line {cursor_line} should be inside viewport \
             line range [{new_top_line}, {})",
            new_top_line + viewport_height
        );
    }

    /// Companion case: in the SAME wrap-mode + `top_view_line_offset > 0`
    /// state, a cursor that's only slightly below the viewport top must
    /// still trigger the early-return so we don't undo the wrap-aware
    /// scroll machinery that was added for #1574. The fix's heuristic is
    /// "skip when cursor is within 2x viewport-height of top".
    #[test]
    fn test_ensure_visible_close_below_top_with_wrap_offset_still_skips() {
        let mut content = String::new();
        for i in 0..50 {
            content.push_str(&format!("line_{i}\n"));
        }
        let mut buffer = Buffer::from_str_test(&content);

        let mut vp = Viewport::new(80, 10);
        vp.line_wrap_enabled = true;

        let mut iter = buffer.line_iterator(0, 80);
        let mut line_5_byte = 0;
        for i in 0..5 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 4 {
                    line_5_byte = line_start;
                    break;
                }
            }
        }
        vp.top_byte = line_5_byte;
        vp.top_view_line_offset = 2;

        let top_before = vp.top_byte;

        // Cursor at line 8 — only 3 lines below top, well within 2x
        // viewport height (=20). Should hit the early-return: viewport
        // unchanged, deferred to render-time `ensure_visible_in_layout`.
        let mut iter = buffer.line_iterator(0, 80);
        let mut line_8_byte = 0;
        for i in 0..8 {
            if let Some((line_start, _)) = iter.next_line() {
                if i == 7 {
                    line_8_byte = line_start;
                    break;
                }
            }
        }
        let cursor = Cursor::new(line_8_byte);

        vp.ensure_visible(&mut buffer, &cursor, &[]);

        assert_eq!(
            vp.top_byte, top_before,
            "Cursor close below top in wrap mode must still defer to \
             ensure_visible_in_layout (the #1574 invariant). Got top_byte={}, expected {}",
            vp.top_byte, top_before
        );
    }
}
