// lib/editor/layout/page_viewport.dart
//
// Continuous-single layout geometry: the PURE windowing math that decides which
// pages are mounted for a given scroll position (P0.5 step 10). Pages stack
// vertically; only the pages intersecting the viewport ± a cache band are
// mounted (windowed lazy hosting → 60fps on a 300-page doc, R1).
//
// This file is intentionally widget-free and pdfrx-free: the actual page
// mounting (a re-laid-out PdfPageView + the AnnotationLayer per page) and the
// zoom-settle DPI refresh are device-gated and live in the viewport WIDGET +
// pdf/page_tile.dart. The geometry is separated so it is unit-testable without
// a GPU or a real document.

import 'dart:math' as math;

/// An inclusive range of page indices to mount. [isEmpty] when nothing
/// intersects (e.g. an empty document or a scroll position past the end with no
/// cache band reaching back).
class PageWindow {
  const PageWindow(this.first, this.last);

  /// Sentinel empty window.
  static const PageWindow empty = PageWindow(0, -1);

  final int first;
  final int last;

  bool get isEmpty => last < first;
  int get count => isEmpty ? 0 : (last - first + 1);

  bool contains(int index) => !isEmpty && index >= first && index <= last;

  @override
  bool operator ==(Object other) =>
      other is PageWindow && other.first == first && other.last == last;

  @override
  int get hashCode => Object.hash(first, last);

  @override
  String toString() => isEmpty ? 'PageWindow.empty' : 'PageWindow($first..$last)';
}

/// Vertical stacking metrics for continuous-single layout.
///
/// Page `i` occupies the half-open band `[offsetOf(i), offsetOf(i) + heightOf(i))`
/// in content (scale-1) coordinates, with [gap] inserted between consecutive
/// pages. Cumulative tops are precomputed so [visibleRange] is O(log n) per
/// scroll frame.
class PageStackMetrics {
  PageStackMetrics({required List<double> pageHeights, this.gap = 0.0})
      : assert(gap >= 0),
        _heights = List<double>.unmodifiable(pageHeights),
        _tops = _cumulativeTops(pageHeights, gap);

  final List<double> _heights;

  /// Top edge of each page in content coordinates (length == pageCount).
  final List<double> _tops;

  /// Gap between consecutive pages in content units.
  final double gap;

  int get pageCount => _heights.length;

  /// Total scrollable content height (0 when there are no pages).
  double get totalExtent {
    if (_heights.isEmpty) return 0;
    return _tops.last + _heights.last;
  }

  double heightOf(int index) => _heights[index];

  /// Top edge (content coordinate) of page [index].
  double offsetOf(int index) => _tops[index];

  static List<double> _cumulativeTops(List<double> heights, double gap) {
    final tops = List<double>.filled(heights.length, 0);
    var acc = 0.0;
    for (var i = 0; i < heights.length; i++) {
      tops[i] = acc;
      acc += heights[i] + gap;
    }
    return List<double>.unmodifiable(tops);
  }

  /// The inclusive page range intersecting the viewport
  /// `[scrollOffset, scrollOffset + viewportExtent)` grown by [cacheExtent] on
  /// each side. Pages whose band overlaps the grown window (even partially) are
  /// included; the result is clamped to `[0, pageCount-1]`.
  ///
  /// Returns [PageWindow.empty] for an empty document or a window that does not
  /// reach any page.
  PageWindow visibleRange(
    double scrollOffset,
    double viewportExtent, {
    double cacheExtent = 0.0,
  }) {
    if (_heights.isEmpty) return PageWindow.empty;
    assert(viewportExtent >= 0);
    assert(cacheExtent >= 0);

    final double windowTop = scrollOffset - cacheExtent;
    final double windowBottom = scrollOffset + viewportExtent + cacheExtent;

    // No overlap with the content at all.
    if (windowBottom <= 0 || windowTop >= totalExtent) {
      return PageWindow.empty;
    }

    final int first = _firstIntersecting(windowTop);
    final int last = _lastIntersecting(windowBottom);
    if (last < first) return PageWindow.empty;
    return PageWindow(first, last);
  }

  /// Maximum scroll offset so the last page bottom rests at the viewport
  /// bottom (never negative — a document shorter than the viewport can't
  /// scroll).
  double maxScrollExtent(double viewportExtent) {
    final max = totalExtent - viewportExtent;
    return max > 0 ? max : 0.0;
  }

  /// Clamps [scrollOffset] into the legal `[0, maxScrollExtent]` range.
  double clampScroll(double scrollOffset, double viewportExtent) {
    final max = maxScrollExtent(viewportExtent);
    if (scrollOffset < 0) return 0.0;
    return scrollOffset > max ? max : scrollOffset;
  }

  /// The "current" page for a scroll position: the page covering the LARGEST
  /// portion of the viewport `[scrollOffset, scrollOffset + viewportExtent)`.
  /// Drives the page-number indicator + thumbnail-grid highlight (F4). Returns
  /// 0 for an empty document.
  int dominantPageAt(double scrollOffset, double viewportExtent) {
    if (_heights.isEmpty) return 0;
    final window = visibleRange(scrollOffset, viewportExtent);
    if (window.isEmpty) {
      // Past the end / before the start → clamp to nearest real page.
      return scrollOffset <= 0 ? 0 : pageCount - 1;
    }
    final viewTop = scrollOffset;
    final viewBottom = scrollOffset + viewportExtent;
    var best = window.first;
    var bestOverlap = -1.0;
    for (var i = window.first; i <= window.last; i++) {
      final top = _tops[i];
      final bottom = top + _heights[i];
      final overlap =
          math.min(bottom, viewBottom) - math.max(top, viewTop);
      if (overlap > bestOverlap) {
        bestOverlap = overlap;
        best = i;
      }
    }
    return best;
  }

  /// Lowest index whose band bottom is strictly after [y] (i.e. the first page
  /// that the window's top edge does not sit fully below).
  int _firstIntersecting(double y) {
    // Find the first page whose bottom edge (top + height) > y.
    var lo = 0;
    var hi = _heights.length - 1;
    var result = _heights.length - 1;
    while (lo <= hi) {
      final mid = (lo + hi) >> 1;
      final bottom = _tops[mid] + _heights[mid];
      if (bottom > y) {
        result = mid;
        hi = mid - 1;
      } else {
        lo = mid + 1;
      }
    }
    return math.max(0, result);
  }

  /// Highest index whose top edge is strictly before [y].
  int _lastIntersecting(double y) {
    var lo = 0;
    var hi = _heights.length - 1;
    var result = 0;
    while (lo <= hi) {
      final mid = (lo + hi) >> 1;
      if (_tops[mid] < y) {
        result = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }
    return math.min(_heights.length - 1, result);
  }
}
