// lib/editor/pdf/page_tile_cache.dart
//
// Bounded LRU cache of rasterized PAGE tiles (ui.Image), DPI-bucketed.
//
// This is the "heavy" cache (a single A4 page at 3× DPI is ~18 MB) and is
// DELIBERATELY SEPARATE from the resolution-independent ink Picture cache
// (render/ink_picture_cache.dart): ink is vector and valid at any zoom, but a
// page bitmap is only crisp at the DPI it was rasterized for, so its key
// carries a DPI bucket (R11 / MF2). On zoom-settle the page_tile renderer
// re-rasterizes at the new bucket and put()s it here; matrix-upscale of a lower
// bucket is the accepted transient until the new tile lands.
//
// Tiles are rendered ASYNCHRONOUSLY (pdfrx PdfPage.render / a re-laid-out
// PdfPageView), so the cache is get()/put() — NOT getOrBuild — and the caller
// owns the async render. Evicted images are disposed via a post-frame callback
// so Flutter's raster thread is never asked to free a ui.Image it may still be
// sampling this frame.

import 'dart:collection';
import 'dart:ui' as ui;

import 'package:flutter/widgets.dart';

/// Identity of a cached page tile: which host (page) and which DPI bucket.
///
/// The DPI bucket (an integer, e.g. round(scale × base-DPI) snapped to a step)
/// keeps the key space small so a smooth pinch doesn't spawn a distinct tile
/// per frame — only per bucket.
@immutable
class TileKey {
  const TileKey(this.hostId, this.dpiBucket);

  final String hostId;
  final int dpiBucket;

  @override
  bool operator ==(Object other) =>
      other is TileKey &&
      other.hostId == hostId &&
      other.dpiBucket == dpiBucket;

  @override
  int get hashCode => Object.hash(hostId, dpiBucket);

  @override
  String toString() => 'TileKey($hostId @dpi$dpiBucket)';
}

/// Bounded LRU cache of page-tile [ui.Image]s keyed by [TileKey].
///
/// Capacity is a TILE COUNT (not bytes); size the window to the device memory
/// budget — full-DPI tiles for visible ±1 pages, off-window pages downgraded to
/// a 1× tier elsewhere (see the plan's R10 resolution). Eviction disposes the
/// image post-frame.
class PageTileCache {
  PageTileCache({int maxTiles = 6})
      : assert(maxTiles > 0),
        _maxTiles = maxTiles;

  final int _maxTiles;

  // Insertion-ordered; accessed entries are moved to the back so the front is
  // always the least-recently-used.
  final LinkedHashMap<TileKey, ui.Image> _cache =
      LinkedHashMap<TileKey, ui.Image>();

  /// Number of tiles currently retained.
  int get length => _cache.length;

  /// The keys currently retained, most-recently-used LAST.
  Iterable<TileKey> get keys => _cache.keys;

  /// Returns the cached image for [key] (promoting it to most-recently-used),
  /// or null on a miss. The caller renders + [put]s on a miss.
  ui.Image? get(TileKey key) {
    final image = _cache.remove(key);
    if (image == null) return null;
    _cache[key] = image; // promote to MRU
    return image;
  }

  /// Inserts [image] for [key], evicting the least-recently-used tiles beyond
  /// the cap. If a DIFFERENT image was already stored for [key], the old one is
  /// disposed (post-frame). Re-putting the identical image is a no-op promote.
  void put(TileKey key, ui.Image image) {
    final existing = _cache.remove(key);
    if (existing != null && !identical(existing, image)) {
      _disposeDeferred(existing);
    }
    _cache[key] = image;

    while (_cache.length > _maxTiles) {
      final lruKey = _cache.keys.first;
      _disposeDeferred(_cache.remove(lruKey)!);
    }
  }

  /// Evicts every tile whose host is NOT in [liveHostIds] (e.g. pages that
  /// scrolled out of the mounted window). Disposed post-frame.
  void evictHostsExcept(Set<String> liveHostIds) {
    final doomed = _cache.keys
        .where((k) => !liveHostIds.contains(k.hostId))
        .toList(growable: false);
    for (final key in doomed) {
      _disposeDeferred(_cache.remove(key)!);
    }
  }

  /// Disposes all retained tiles (post-frame). Call from the owner's dispose.
  void dispose() {
    final images = List<ui.Image>.from(_cache.values);
    _cache.clear();
    for (final image in images) {
      _disposeDeferred(image);
    }
  }

  static void _disposeDeferred(ui.Image image) {
    // Defer to after the current frame so the raster thread is done with it.
    // If no binding/frame is scheduled (e.g. a unit test that never pumps),
    // fall back to disposing on the next microtask so images aren't leaked.
    final binding = WidgetsBinding.instance;
    binding.addPostFrameCallback((_) => image.dispose());
    binding.scheduleFrame();
  }
}

/// Snaps a continuous render scale to a coarse DPI bucket so a smooth pinch
/// re-uses tiles instead of spawning one per frame. [step] is the bucket
/// granularity in the same units as [scale] (e.g. 0.5). The result is capped at
/// [maxBucket] to bound retained-tile memory (the plan's ~3× cap, R11).
int dpiBucketFor(double scale, {double step = 0.5, int maxBucket = 6}) {
  if (!scale.isFinite || scale <= 0) return 1;
  final bucket = (scale / step).ceil();
  if (bucket < 1) return 1;
  return bucket > maxBucket ? maxBucket : bucket;
}
