//! Fixed-height row virtualization math.
//!
//! Pure and GUI-free. Given a scroll offset, a viewport height, and a total row
//! count, compute the contiguous slice of rows that must be materialized — the
//! strictly-visible rows plus a small over-materialization buffer above and
//! below — together with the filler heights that keep that slice at its true
//! position inside a full-height scroll region.
//!
//! This is the invariant the whole design rests on:
//! **the count of materialized rows is a function of the viewport, never of the
//! total row count.** [`RowVirtualizer::visible`] makes that checkable — see the
//! `bounded_independent_of_total` test.
//!
//! ## Fixed height is a deliberate, reversible default
//!
//! Every row is `row_height` logical pixels tall (a deliberate, reversible
//! default — variable heights would slot in as a sibling type).
//! That makes offset↔index conversion exact (`offset = index * row_height`) and
//! sidesteps the estimate/measure/reconcile dance of variable heights. A future
//! variable-height viewport would be a *sibling* type in this module; nothing in
//! the returned [`VisibleRows`] bakes the fixed-height assumption into callers —
//! they consume `start`/`end`/`space_before`/`space_after`, which a variable
//! implementation can produce too.

use core::ops::Range;

/// Rows materialized beyond the strictly-visible range, on *each* side.
///
/// Absorbs partial edge rows and the one-frame lag between a scroll event and
/// the re-render that consumes it, so no blank row flashes in during a fast
/// scroll. Four is a conservative default; tune via [`RowVirtualizer::with_buffer`].
pub const DEFAULT_BUFFER_ROWS: usize = 4;

/// The largest integer an `f32` represents exactly is `2^24`. Once a scroll
/// region grows past this many logical pixels, `offset <-> index` conversion
/// loses row-level precision — rows become unaddressable and scrolling shimmers.
///
/// This is a toolkit numeric limit (iced lays out in `f32`) leaking into the
/// core's addressable data-range envelope. v1's resident grids stay far under
/// it; the out-of-core envelope does not. A standing test guards the
/// boundary.
pub const F32_EXACT_INTEGER_LIMIT: f32 = 16_777_216.0; // 2^24

/// An anchor-based scroll position — the position-of-truth that stays precise
/// for arbitrarily large datasets.
///
/// A single flat `f32` pixel offset loses row-level precision past
/// [`F32_EXACT_INTEGER_LIMIT`]. An anchor instead names a **row index** plus the
/// sub-row pixel offset of the viewport's top edge below that row's top, so the
/// visible range is computed in *row space* (integer arithmetic) and stays exact
/// up to [`usize::MAX`] rows. This is what scroll restoration re-anchors to
/// ("re-anchor to a stable row") and what out-of-core scrolling uses.
///
/// Build one from a flat offset with [`RowVirtualizer::anchor_at`]; go back with
/// [`RowVirtualizer::flat_offset_of`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScrollAnchor {
    /// Index of the anchor row — the first row the viewport's top edge touches.
    pub row: usize,
    /// Pixels the viewport's top edge sits below the top of [`row`](Self::row);
    /// canonically in `[0, row_height)`, though larger values are tolerated.
    pub offset_px: f32,
}

impl ScrollAnchor {
    /// The anchor at the very top of the dataset.
    pub const TOP: Self = Self {
        row: 0,
        offset_px: 0.0,
    };
}

/// A fixed-height row viewport calculator.
///
/// Cheap to copy; hold one in view state and call [`visible`](Self::visible)
/// each frame with the current scroll offset and measured viewport height.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RowVirtualizer {
    row_height: f32,
    buffer_rows: usize,
}

impl RowVirtualizer {
    /// Creates a virtualizer for rows of `row_height` logical pixels, using
    /// [`DEFAULT_BUFFER_ROWS`].
    ///
    /// # Panics
    /// Panics if `row_height` is not finite and strictly positive.
    #[must_use]
    pub fn new(row_height: f32) -> Self {
        Self::with_buffer(row_height, DEFAULT_BUFFER_ROWS)
    }

    /// Creates a virtualizer with an explicit per-side buffer.
    ///
    /// # Panics
    /// Panics if `row_height` is not finite and strictly positive.
    #[must_use]
    pub fn with_buffer(row_height: f32, buffer_rows: usize) -> Self {
        assert!(
            row_height.is_finite() && row_height > 0.0,
            "row_height must be finite and > 0, got {row_height}"
        );
        Self {
            row_height,
            buffer_rows,
        }
    }

    /// The configured row height in logical pixels.
    #[must_use]
    pub fn row_height(&self) -> f32 {
        self.row_height
    }

    /// The configured per-side buffer, in rows.
    #[must_use]
    pub fn buffer_rows(&self) -> usize {
        self.buffer_rows
    }

    /// Total pixel height of `total_rows` rows.
    ///
    /// This is the height the scroll region must claim so its scrollbar reflects
    /// the full dataset even though only a slice is ever materialized.
    #[must_use]
    pub fn total_height(&self, total_rows: usize) -> f32 {
        self.row_height * total_rows as f32
    }

    /// The largest `total_rows` the **anchor** addressing path
    /// ([`visible_from_anchor`](Self::visible_from_anchor)) can resolve exactly.
    ///
    /// Anchor addressing is integer row-space arithmetic (see [`ScrollAnchor`]),
    /// so it is bounded by the row-index type, not the `f32` pixel ceiling —
    /// i.e. [`usize::MAX`]. The flat [`visible`](Self::visible) path and its
    /// full-dataset pixel filler stay `f32`-scale; that ceiling is
    /// [`max_flat_addressable_rows`](Self::max_flat_addressable_rows).
    #[must_use]
    pub fn max_addressable_rows(&self) -> usize {
        usize::MAX
    }

    /// The largest `total_rows` the **flat** [`visible`](Self::visible) path can
    /// address before its full-dataset pixel filler exceeds
    /// [`F32_EXACT_INTEGER_LIMIT`] and row positioning loses precision.
    ///
    /// This is the resident rendering technique's ceiling. Larger datasets
    /// use the anchor path instead — see [`ScrollAnchor`].
    #[must_use]
    pub fn max_flat_addressable_rows(&self) -> usize {
        (F32_EXACT_INTEGER_LIMIT / self.row_height) as usize
    }

    /// Rows a page-up/page-down step moves: the number of **fully visible**
    /// rows (a partial final row does not count — jumping by it would skip
    /// content), and at least 1 so a degenerate viewport still
    /// navigates. Non-finite/negative heights are treated as `0.0`.
    #[must_use]
    pub fn page_len(&self, viewport_height: f32) -> usize {
        let height = if viewport_height.is_finite() {
            viewport_height.max(0.0)
        } else {
            0.0
        };
        (((height / self.row_height).floor() as usize).max(1)) as usize
    }

    /// Converts a flat pixel `scroll_offset` into a [`ScrollAnchor`].
    ///
    /// Exact while `scroll_offset` is within the `f32` exact-integer range
    /// (resident grids); this is how the resident/flat integration — an iced
    /// `scrollable` reporting a flat offset — feeds the anchor path. Non-finite
    /// or negative offsets clamp to [`ScrollAnchor::TOP`].
    #[must_use]
    pub fn anchor_at(&self, scroll_offset: f32) -> ScrollAnchor {
        let offset = if scroll_offset.is_finite() {
            scroll_offset.max(0.0)
        } else {
            0.0
        };
        let row = (offset / self.row_height).floor() as usize;
        ScrollAnchor {
            row,
            offset_px: offset - row as f32 * self.row_height,
        }
    }

    /// The flat pixel offset equivalent to `anchor` — the inverse of
    /// [`anchor_at`](Self::anchor_at).
    ///
    /// Only precise while `anchor.row` is within the `f32` exact-integer
    /// envelope; beyond it the flat offset is lossy, which is *why* the anchor
    /// (not this) is the position of truth. Intended for driving the resident
    /// flat integration and scroll restoration on resident grids.
    #[must_use]
    pub fn flat_offset_of(&self, anchor: ScrollAnchor) -> f32 {
        anchor.row as f32 * self.row_height + anchor.offset_px
    }

    /// Rows a viewport of `viewport_height` px can span when it starts at an
    /// arbitrary sub-row offset: the partially-visible row at each edge
    /// counts, hence ceil + 1 (always at least 1). The integrator's window
    /// walks — re-anchor candidates, animation capture and scheduling — all
    /// share this bound instead of hand-computing it.
    #[must_use]
    pub fn rows_spanned(&self, viewport_height: f32) -> usize {
        let height = if viewport_height.is_finite() {
            viewport_height.max(0.0)
        } else {
            0.0
        };
        ((height / self.row_height).ceil() as usize).saturating_add(1)
    }

    /// A row's y position in viewport (screen) space at `scroll_offset` —
    /// the row-space → screen-space transform. Screen-space animation
    /// capture is built on this (flights animate what visibly moved, not
    /// what re-indexed).
    #[must_use]
    pub fn screen_y(&self, row: usize, scroll_offset: f32) -> f32 {
        row as f32 * self.row_height - scroll_offset
    }

    /// The minimal scroll needed to bring `row` fully into a
    /// `viewport_height`-tall view at `scroll_offset`: `Some(new_offset)`
    /// scrolling just enough (row above → align its top; row below → align
    /// its bottom), `None` when it is already fully visible. Shared by
    /// keep-focused-row-in-view and keyboard-nav reveal (previously
    /// duplicated integrator-side).
    #[must_use]
    pub fn reveal_offset(
        &self,
        scroll_offset: f32,
        viewport_height: f32,
        row: usize,
    ) -> Option<f32> {
        let viewport = viewport_height.max(self.row_height);
        let top = self.flat_offset_of(ScrollAnchor { row, offset_px: 0.0 });
        let bottom = top + self.row_height;
        if top < scroll_offset {
            Some(top)
        } else if bottom > scroll_offset + viewport {
            Some(bottom - viewport)
        } else {
            None
        }
    }

    /// Computes the visible row range from a [`ScrollAnchor`], in **row space**.
    ///
    /// Exact for any `total_rows` up to [`usize::MAX`], independent of the `f32`
    /// pixel ceiling: the arithmetic is integer row indices plus the bounded
    /// sub-row `anchor.offset_px`, never a full-dataset pixel accumulation.
    ///
    /// Returns the half-open `start..end` slice to materialize (visible rows +
    /// buffer). The integrator positions the slice by shifting its first row up
    /// by `anchor.offset_px` (plus the buffer rows' heights) inside a
    /// viewport-sized container — so no `f32`-bounded full-dataset filler is
    /// needed. Non-finite/negative `offset_px` and `viewport_height` clamp to 0.
    #[must_use]
    pub fn visible_from_anchor(
        &self,
        anchor: ScrollAnchor,
        viewport_height: f32,
        total_rows: usize,
    ) -> Range<usize> {
        if total_rows == 0 {
            return 0..0;
        }
        let sub = if anchor.offset_px.is_finite() {
            anchor.offset_px.max(0.0)
        } else {
            0.0
        };
        let height = if viewport_height.is_finite() {
            viewport_height.max(0.0)
        } else {
            0.0
        };

        let first_visible = anchor.row.min(total_rows);
        // Rows the viewport spans, starting `sub` px into the anchor row.
        let rows_in_view = ((sub + height) / self.row_height).ceil() as usize;
        let last_visible = first_visible.saturating_add(rows_in_view).min(total_rows);

        let start = first_visible.saturating_sub(self.buffer_rows);
        let end = last_visible.saturating_add(self.buffer_rows).min(total_rows);
        start..end
    }

    /// Computes the slice of rows to materialize for the given scroll state.
    ///
    /// - `scroll_offset` — vertical distance (px) from the top of the full
    ///   scroll region to the top of the viewport. Non-finite or negative values
    ///   are clamped to `0.0`.
    /// - `viewport_height` — visible height (px). Non-finite or negative values
    ///   are treated as `0.0`.
    ///
    /// The returned range is `start..end` (half-open) into `0..total_rows`, and
    /// `space_before` / `space_after` are the filler heights above and below the
    /// slice such that `space_before + len*row_height + space_after ==
    /// total_height(total_rows)`.
    #[must_use]
    pub fn visible(
        &self,
        scroll_offset: f32,
        viewport_height: f32,
        total_rows: usize,
    ) -> VisibleRows {
        if total_rows == 0 {
            return VisibleRows::EMPTY;
        }

        let offset = if scroll_offset.is_finite() {
            scroll_offset.max(0.0)
        } else {
            0.0
        };
        let height = if viewport_height.is_finite() {
            viewport_height.max(0.0)
        } else {
            0.0
        };

        // Strictly-visible range: the first row the viewport's top edge touches
        // (`floor`) up to and including any row its bottom edge merely peeks into
        // (`ceil`). Float→int casts saturate in Rust, and both operands are >= 0,
        // so an over-scrolled offset saturates high and is caught by the clamps.
        let first_visible = ((offset / self.row_height).floor() as usize).min(total_rows);
        let last_visible = (((offset + height) / self.row_height).ceil() as usize)
            .min(total_rows)
            .max(first_visible);

        // Pad by the buffer on each side, clamped to the data bounds.
        let start = first_visible.saturating_sub(self.buffer_rows);
        let end = last_visible.saturating_add(self.buffer_rows).min(total_rows);

        VisibleRows {
            start,
            end,
            space_before: self.row_height * start as f32,
            space_after: self.row_height * (total_rows - end) as f32,
        }
    }
}

/// The slice of rows to materialize for one frame, plus the filler that keeps it
/// positioned inside the full-height scroll region.
///
/// Materialize exactly the rows in [`range`](Self::range); place `space_before`
/// pixels of filler above them and `space_after` below, all inside the scroll
/// region so the scrollbar still spans the whole dataset.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleRows {
    /// First materialized row index (inclusive).
    pub start: usize,
    /// One past the last materialized row index (exclusive).
    pub end: usize,
    /// Filler height (px) above the materialized slice.
    pub space_before: f32,
    /// Filler height (px) below the materialized slice.
    pub space_after: f32,
}

impl VisibleRows {
    /// An empty window (no rows, no filler).
    pub const EMPTY: Self = Self {
        start: 0,
        end: 0,
        space_before: 0.0,
        space_after: 0.0,
    };

    /// The half-open index range to materialize.
    #[must_use]
    pub fn range(&self) -> Range<usize> {
        self.start..self.end
    }

    /// Number of rows in the window — i.e. the count of row widgets the
    /// integrator will materialize this frame.
    #[must_use]
    pub fn len(&self) -> usize {
        self.end - self.start
    }

    /// Whether the window materializes no rows.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.start == self.end
    }
}

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

    const ROW: f32 = 24.0;
    const VIEW: f32 = 640.0;

    /// The headline invariant: for the same scroll state, the number of
    /// materialized rows does not depend on the total row count. This is the
    /// foundational acceptance check, provable without any GUI in the loop.
    #[test]
    fn bounded_independent_of_total() {
        let v = RowVirtualizer::with_buffer(ROW, 4);
        let offset = 10_000.0;

        let small = v.visible(offset, VIEW, 100_000);
        let huge = v.visible(offset, VIEW, 100_000_000);

        assert_eq!(small.len(), huge.len());
        assert_eq!(small.range(), huge.range());

        // Concretely: ceil(640/24)=27 strictly-visible at worst +1 for a
        // misaligned top edge, +2*4 buffer = 36. Never grows with `total`.
        let upper_bound = (VIEW / ROW).ceil() as usize + 1 + 2 * v.buffer_rows();
        assert!(small.len() <= upper_bound, "{} <= {upper_bound}", small.len());
    }

    #[test]
    fn filler_plus_slice_equals_total_height() {
        let v = RowVirtualizer::new(ROW);
        for &total in &[1usize, 37, 1000, 100_000] {
            for &offset in &[0.0, 240.0, 12_345.0, 1e9] {
                let w = v.visible(offset, VIEW, total);
                let reconstructed = w.space_before + w.len() as f32 * ROW + w.space_after;
                assert!(
                    (reconstructed - v.total_height(total)).abs() < 0.5,
                    "total={total} offset={offset}: {reconstructed} vs {}",
                    v.total_height(total)
                );
            }
        }
    }

    #[test]
    fn top_of_list_has_no_leading_filler_and_no_underflow() {
        let v = RowVirtualizer::with_buffer(ROW, 4);
        let w = v.visible(0.0, VIEW, 100_000);
        assert_eq!(w.start, 0, "buffer must not underflow below 0");
        assert_eq!(w.space_before, 0.0);
        assert!(w.end > 0);
    }

    #[test]
    fn partial_bottom_row_is_included() {
        // Viewport not a multiple of the row height: the row the bottom edge
        // only partially reveals must still be materialized (ceil, not floor).
        let v = RowVirtualizer::with_buffer(30.0, 0); // buffer 0 to isolate the edge
        let w = v.visible(0.0, 100.0, 1000);
        // ceil(100/30) = 4 -> indices 0,1,2,3 materialized.
        assert_eq!(w.range(), 0..4);
    }

    #[test]
    fn negative_and_nonfinite_offset_clamp_to_top() {
        let v = RowVirtualizer::new(ROW);
        let top = v.visible(0.0, VIEW, 100_000);
        // Every non-finite or negative offset is treated as a degenerate input
        // and clamped to the top — safe and predictable (real iced scroll
        // offsets are always finite and >= 0).
        assert_eq!(v.visible(-500.0, VIEW, 100_000), top);
        assert_eq!(v.visible(f32::NAN, VIEW, 100_000), top);
        assert_eq!(v.visible(f32::INFINITY, VIEW, 100_000), top);
    }

    #[test]
    fn buffer_is_clamped_at_the_bottom_edge() {
        let v = RowVirtualizer::with_buffer(ROW, 4);
        let total = 1000;
        // Scroll to the very bottom.
        let w = v.visible(v.total_height(total) - VIEW, VIEW, total);
        assert_eq!(w.end, total, "end must not exceed total");
        assert_eq!(w.space_after, 0.0);
    }

    #[test]
    fn empty_dataset_yields_empty_window() {
        let v = RowVirtualizer::new(ROW);
        let w = v.visible(0.0, VIEW, 0);
        assert!(w.is_empty());
        assert_eq!(w, VisibleRows::EMPTY);
    }

    #[test]
    #[should_panic(expected = "row_height must be finite and > 0")]
    fn zero_row_height_panics() {
        let _ = RowVirtualizer::new(0.0);
    }

    #[test]
    #[should_panic(expected = "row_height must be finite and > 0")]
    fn nan_row_height_panics() {
        let _ = RowVirtualizer::new(f32::NAN);
    }

    // --- Standing tripwires: boundaries that must fail loudly if moved ---

    /// Standing invariant. The **resident
    /// flat** rendering path must stay within the `f32` exact-integer range.
    /// Green now; goes RED the instant v1's row ceiling or row height is bumped
    /// into the danger zone — at which point the resident grid must move to the
    /// anchor path. (The anchor path is exact in row space; this guards the
    /// flat rendering technique the resident path still uses.)
    #[test]
    fn resident_flat_scroll_region_within_f32_exact_range() {
        // The SUPPORTED resident envelope: 300k rows at the tallest shipped
        // theme
        // density (Material, 48 px). The showcase grows unboundedly at
        // ~1.5 rows/s — reaching 300k takes ~2.3 days of continuous run;
        // past max_flat_addressable_rows the flat technique degrades to
        // sub-pixel jitter (Mode C's door is the anchor path). Grow either
        // constant past the ceiling and this fires.
        const RESIDENT_MAX_ROWS: usize = 300_000;
        const MAX_THEME_ROW_HEIGHT: f32 = 48.0;

        let v = RowVirtualizer::new(MAX_THEME_ROW_HEIGHT);
        assert!(
            (RESIDENT_MAX_ROWS as f32) * MAX_THEME_ROW_HEIGHT < F32_EXACT_INTEGER_LIMIT,
            "resident flat scroll region {} px >= f32 exact ceiling {F32_EXACT_INTEGER_LIMIT} \
             px — move the resident grid to the anchor path before growing the envelope.",
            (RESIDENT_MAX_ROWS as f32) * MAX_THEME_ROW_HEIGHT
        );
        // The flat path must cover the whole supported envelope...
        assert!(v.max_flat_addressable_rows() > RESIDENT_MAX_ROWS);
        // ...and the anchor path is effectively unbounded.
        assert!(v.max_addressable_rows() > RESIDENT_MAX_ROWS);
    }

    /// The core addresses arbitrarily large datasets via the anchor path,
    /// exact in row space and independent of the `f32` pixel ceiling.
    #[test]
    fn anchor_path_addresses_beyond_f32_ceiling() {
        let v = RowVirtualizer::with_buffer(24.0, 4);

        // A billion rows: 1e9 * 24 px ≈ 2.4e10 px, ~1400x past the f32 ceiling.
        // A flat offset could not name a row here; the anchor computes exactly.
        let total = 1_000_000_000usize;
        let anchor = ScrollAnchor {
            row: 900_000_000,
            offset_px: 5.0,
        };
        let range = v.visible_from_anchor(anchor, 640.0, total);

        // rows_in_view = ceil((5 + 640) / 24) = 27; buffer 4 each side.
        assert_eq!(range.start, 900_000_000 - 4);
        assert_eq!(range.end, 900_000_000 + 27 + 4);
        // Count is still bounded by the viewport, not the (billion-row) total.
        let len = range.end - range.start;
        assert!(len <= (640.0_f32 / 24.0).ceil() as usize + 1 + 2 * v.buffer_rows());

        // And the advertised capability is honest.
        assert!(v.max_addressable_rows() > 100_000_000);
    }

    /// The anchor path and the flat path must agree wherever both are valid
    /// (resident scale) — the anchor is a lossless refinement, not a behavior
    /// change for existing grids.
    #[test]
    fn anchor_path_matches_flat_path_at_resident_scale() {
        let v = RowVirtualizer::with_buffer(24.0, 4);
        let total = 100_000;
        for &offset in &[0.0, 24.0, 250.0, 12_345.0] {
            let flat = v.visible(offset, 640.0, total).range();
            let anchor = v.visible_from_anchor(v.anchor_at(offset), 640.0, total);
            assert_eq!(flat, anchor, "offset {offset}");
        }
    }

    #[test]
    fn flat_offset_roundtrips_through_anchor_at_resident_scale() {
        let v = RowVirtualizer::new(24.0);
        for &offset in &[0.0, 24.0, 250.5, 99_999.0] {
            let rt = v.flat_offset_of(v.anchor_at(offset));
            assert!((rt - offset).abs() < 0.01, "offset {offset} -> {rt}");
        }
    }

    #[test]
    fn page_len_counts_only_fully_visible_rows() {
        let v = RowVirtualizer::new(24.0);
        // 640 / 24 = 26.67 -> a partial 27th row is visible but a page step
        // moves by 26 (jumping by the partial row would skip content).
        assert_eq!(v.page_len(640.0), 26);
        assert_eq!(v.page_len(24.0 * 10.0), 10);
        // Degenerate viewports still navigate one row at a time.
        assert_eq!(v.page_len(0.0), 1);
        assert_eq!(v.page_len(10.0), 1);
        assert_eq!(v.page_len(f32::NAN), 1);
    }

    #[test]
    fn anchor_top_and_empty_dataset() {
        let v = RowVirtualizer::new(24.0);
        assert_eq!(v.anchor_at(0.0), ScrollAnchor::TOP);
        let r = v.visible_from_anchor(ScrollAnchor::TOP, 640.0, 0);
        assert_eq!(r.start, r.end);
    }
}
