// Unit tests for the DPI-bucketed page-tile cache (P0.5 step 10, automatable
// slice). Covers LRU eviction, MRU promotion, per-key replacement, host
// eviction, dpiBucketFor snapping, and post-frame disposal of evicted images.
//
// The pdfrx tile RENDERING (page_tile.dart) is device-gated and not tested
// here; this is the pure cache data structure.

import 'dart:ui' as ui;

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:badnote/editor/pdf/page_tile_cache.dart';

Future<ui.Image> _img() async {
  final recorder = ui.PictureRecorder();
  Canvas(recorder).drawRect(
    const Rect.fromLTWH(0, 0, 2, 2),
    Paint()..color = const Color(0xFF000000),
  );
  final picture = recorder.endRecording();
  final image = await picture.toImage(2, 2);
  picture.dispose();
  return image;
}

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  group('dpiBucketFor', () {
    test('snaps continuous scale to a coarse bucket (ceil by step)', () {
      expect(dpiBucketFor(1.0, step: 0.5), 2); // 1.0/0.5 = 2
      expect(dpiBucketFor(1.1, step: 0.5), 3); // ceil(2.2)
      expect(dpiBucketFor(0.4, step: 0.5), 1); // ceil(0.8) = 1
    });

    test('is monotonic non-decreasing in scale', () {
      var prev = 0;
      for (final s in [0.3, 0.6, 1.0, 1.6, 2.0, 2.9]) {
        final b = dpiBucketFor(s, step: 0.5, maxBucket: 100);
        expect(b, greaterThanOrEqualTo(prev));
        prev = b;
      }
    });

    test('caps at maxBucket (bounds retained-DPI memory)', () {
      expect(dpiBucketFor(99.0, step: 0.5, maxBucket: 6), 6);
    });

    test('guards invalid scale', () {
      expect(dpiBucketFor(0), 1);
      expect(dpiBucketFor(-3), 1);
      expect(dpiBucketFor(double.nan), 1);
    });
  });

  group('PageTileCache LRU', () {
    test('get returns null on miss, the image on hit', () async {
      final cache = PageTileCache(maxTiles: 4);
      const key = TileKey('p0', 2);
      expect(cache.get(key), isNull);
      final img = await _img();
      cache.put(key, img);
      expect(identical(cache.get(key), img), isTrue);
      cache.dispose();
    });

    test('TileKey equality is by (hostId, dpiBucket)', () {
      expect(const TileKey('p0', 2), const TileKey('p0', 2));
      expect(const TileKey('p0', 2), isNot(const TileKey('p0', 3)));
      expect(const TileKey('p0', 2), isNot(const TileKey('p1', 2)));
      expect(const TileKey('p0', 2).hashCode, const TileKey('p0', 2).hashCode);
    });

    test('evicts the least-recently-used beyond the cap', () async {
      final cache = PageTileCache(maxTiles: 2);
      cache.put(const TileKey('a', 1), await _img());
      cache.put(const TileKey('b', 1), await _img());
      cache.put(const TileKey('c', 1), await _img()); // evicts 'a'
      expect(cache.length, 2);
      expect(cache.keys, isNot(contains(const TileKey('a', 1))));
      expect(cache.get(const TileKey('a', 1)), isNull);
      expect(cache.get(const TileKey('b', 1)), isNotNull);
      cache.dispose();
    });

    test('get promotes MRU so the OTHER entry is evicted next', () async {
      final cache = PageTileCache(maxTiles: 2);
      cache.put(const TileKey('a', 1), await _img());
      cache.put(const TileKey('b', 1), await _img());
      cache.get(const TileKey('a', 1)); // 'a' now MRU → 'b' is LRU
      cache.put(const TileKey('c', 1), await _img()); // evicts 'b'
      expect(cache.get(const TileKey('a', 1)), isNotNull);
      expect(cache.get(const TileKey('b', 1)), isNull);
      cache.dispose();
    });

    test('evictHostsExcept drops other hosts, keeps live ones', () async {
      final cache = PageTileCache(maxTiles: 8);
      cache.put(const TileKey('p0', 1), await _img());
      cache.put(const TileKey('p0', 2), await _img());
      cache.put(const TileKey('p1', 1), await _img());
      cache.put(const TileKey('p2', 1), await _img());
      cache.evictHostsExcept({'p0', 'p1'});
      expect(cache.keys.map((k) => k.hostId).toSet(), {'p0', 'p1'});
      expect(cache.length, 3);
      cache.dispose();
    });
  });

  group('PageTileCache disposal (post-frame)', () {
    testWidgets('an evicted tile is disposed after the frame', (tester) async {
      final cache = PageTileCache(maxTiles: 1);
      final first = await _img();
      cache.put(const TileKey('a', 1), first);
      cache.put(const TileKey('b', 1), await _img()); // evicts 'a' → defers
      expect(first.debugDisposed, isFalse, reason: 'deferred, not yet');
      await tester.pump(); // run the post-frame callback
      expect(first.debugDisposed, isTrue);
      cache.dispose();
      await tester.pump();
    });

    testWidgets('re-putting a different image for a key disposes the old one',
        (tester) async {
      final cache = PageTileCache(maxTiles: 4);
      final old = await _img();
      cache.put(const TileKey('a', 1), old);
      cache.put(const TileKey('a', 1), await _img());
      await tester.pump();
      expect(old.debugDisposed, isTrue);
      expect(cache.length, 1);
      cache.dispose();
      await tester.pump();
    });

    testWidgets('dispose() frees all retained tiles', (tester) async {
      final cache = PageTileCache(maxTiles: 8);
      final a = await _img();
      final b = await _img();
      cache.put(const TileKey('a', 1), a);
      cache.put(const TileKey('b', 1), b);
      cache.dispose();
      await tester.pump();
      expect(a.debugDisposed, isTrue);
      expect(b.debugDisposed, isTrue);
      expect(cache.length, 0);
    });
  });
}
