"""
Unit tests for the bounded usage-ranked caches.

Covers the guarantees the text rendering path depends on: that the bounds are never
exceeded, that eviction prefers the least-used entries, that aging lets a new
working set displace an old one, and that document-frequency seeding survives a
scan of unfamiliar keys.
"""

import unittest

from pyWebLayout.core.cache import (
    UsageCache,
    SizedUsageCache,
    DEFAULT_AGING_INTERVAL,
)


class TestUsageCache(unittest.TestCase):
    """Entry-count-bounded cache."""

    def test_rejects_invalid_bounds(self):
        for bad in (0, -1):
            with self.assertRaises(ValueError):
                UsageCache(bad)
        with self.assertRaises(ValueError):
            UsageCache(4, aging_interval=0)
        with self.assertRaises(ValueError):
            UsageCache(4, eviction_sample=0)

    def test_stores_and_returns_values(self):
        cache = UsageCache(4)
        cache.put('a', 1)
        self.assertEqual(cache.get('a'), 1)
        self.assertIsNone(cache.get('missing'))
        self.assertIn('a', cache)
        self.assertEqual(len(cache), 1)

    def test_never_exceeds_max_entries(self):
        cache = UsageCache(10)
        for i in range(500):
            cache.put(i, i)
            self.assertLessEqual(len(cache), 10)
        self.assertEqual(cache.stats()['entries'], 10)

    def test_evicts_least_used(self):
        # One hot key among many cold ones must survive a long cold scan. The
        # sample is smaller than the cache, so this is probabilistic in principle;
        # a hot key's count is far enough above the rest to make it reliable.
        cache = UsageCache(20, eviction_sample=8)
        cache.put('hot', 'value')
        for _ in range(200):
            cache.get('hot')
        for i in range(400):
            cache.put(f'cold{i}', i)
            cache.get('hot')
        self.assertEqual(cache.get('hot'), 'value')

    def test_repeated_put_does_not_duplicate(self):
        cache = UsageCache(10)
        for _ in range(50):
            cache.put('a', 1)
        self.assertEqual(len(cache), 1)

    def test_put_updates_existing_value(self):
        cache = UsageCache(10)
        cache.put('a', 1)
        cache.put('a', 2)
        self.assertEqual(cache.get('a'), 2)

    def test_seeded_count_outranks_fresh_entries(self):
        """A document-frequency seed must survive a scan of unseen keys."""
        cache = UsageCache(20, eviction_sample=8)
        cache.put('frequent', 'value', count=5000)
        for i in range(400):
            cache.put(f'new{i}', i)
        self.assertEqual(cache.get('frequent'), 'value')

    def test_aging_lets_a_new_working_set_take_over(self):
        """Without aging, stale high counts lock the cache permanently."""
        cache = UsageCache(20, aging_interval=50, eviction_sample=8)
        for i in range(20):
            cache.put(f'old{i}', i, count=10000)

        # A completely different working set, each key used a few times.
        for round_ in range(60):
            for i in range(10):
                key = f'new{i}'
                if cache.get(key) is None:
                    cache.put(key, i)

        survivors = sum(1 for i in range(10) if f'new{i}' in cache)
        self.assertGreater(survivors, 0,
                           "aging should let the new working set displace the old")
        self.assertGreater(cache.stats()['agings'], 0)

    def test_aging_can_be_disabled(self):
        cache = UsageCache(10, aging_interval=None)
        for i in range(100):
            cache.put(i, i)
        self.assertEqual(cache.stats()['agings'], 0)

    def test_resize_evicts_immediately(self):
        cache = UsageCache(100)
        for i in range(100):
            cache.put(i, i)
        cache.resize(10)
        self.assertEqual(len(cache), 10)
        with self.assertRaises(ValueError):
            cache.resize(0)

    def test_clear_empties_but_keeps_counters(self):
        cache = UsageCache(10)
        cache.put('a', 1)
        cache.get('a')
        cache.clear()
        self.assertEqual(len(cache), 0)
        self.assertNotIn('a', cache)
        self.assertEqual(cache.stats()['hits'], 1)

    def test_stats_track_hits_and_misses(self):
        cache = UsageCache(10)
        cache.put('a', 1)
        cache.get('a')
        cache.get('a')
        cache.get('b')
        stats = cache.stats()
        self.assertEqual(stats['hits'], 2)
        self.assertEqual(stats['misses'], 1)
        self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
        self.assertEqual(stats['max_entries'], 10)

    def test_internal_slot_list_stays_consistent(self):
        """Eviction swaps the tail into the freed slot; indices must stay valid."""
        cache = UsageCache(8)
        for i in range(300):
            cache.put(i, i)
            for key in list(cache._entries):
                self.assertEqual(cache._slots[cache._entries[key][2]], key)
        self.assertEqual(len(cache._slots), len(cache._entries))


class TestSizedUsageCache(unittest.TestCase):
    """Byte-bounded cache, as used for glyph bitmaps."""

    @staticmethod
    def sizer(value):
        return value

    def test_rejects_invalid_bounds(self):
        for bad in (0, -1):
            with self.assertRaises(ValueError):
                SizedUsageCache(bad, self.sizer)

    def test_never_exceeds_max_bytes(self):
        cache = SizedUsageCache(1000, self.sizer)
        for i in range(500):
            cache.put(i, 100)
            self.assertLessEqual(cache.total_bytes, 1000)

    def test_tracks_total_bytes(self):
        cache = SizedUsageCache(1000, self.sizer)
        cache.put('a', 100)
        cache.put('b', 250)
        self.assertEqual(cache.total_bytes, 350)

    def test_oversized_value_is_not_retained(self):
        """One huge entry must not flush everything else out."""
        cache = SizedUsageCache(1000, self.sizer)
        cache.put('small', 100)
        cache.put('huge', 5000)
        self.assertNotIn('huge', cache)
        self.assertIn('small', cache)
        self.assertEqual(cache.total_bytes, 100)

    def test_replacing_a_value_remeasures_it(self):
        cache = SizedUsageCache(1000, self.sizer)
        cache.put('a', 100)
        cache.put('a', 300)
        self.assertEqual(cache.total_bytes, 300)
        self.assertEqual(len(cache), 1)

    def test_evicts_least_used(self):
        cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
        cache.put('hot', 100)
        for _ in range(200):
            cache.get('hot')
        for i in range(400):
            cache.put(f'cold{i}', 100)
            cache.get('hot')
        self.assertIn('hot', cache)

    def test_seeded_count_outranks_fresh_entries(self):
        cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
        cache.put('frequent', 100, count=5000)
        for i in range(400):
            cache.put(f'new{i}', 100)
        self.assertIn('frequent', cache)

    def test_resize_evicts_immediately(self):
        cache = SizedUsageCache(10000, self.sizer)
        for i in range(100):
            cache.put(i, 100)
        cache.resize(500)
        self.assertLessEqual(cache.total_bytes, 500)

    def test_clear_resets_byte_accounting(self):
        cache = SizedUsageCache(1000, self.sizer)
        cache.put('a', 100)
        cache.clear()
        self.assertEqual(cache.total_bytes, 0)
        self.assertEqual(len(cache), 0)

    def test_stats_report_bounds(self):
        cache = SizedUsageCache(1000, self.sizer)
        cache.put('a', 100)
        stats = cache.stats()
        self.assertEqual(stats['total_bytes'], 100)
        self.assertEqual(stats['max_bytes'], 1000)
        self.assertEqual(stats['entries'], 1)

    def test_bookkeeping_stays_consistent_under_churn(self):
        """Byte total and slot list must not drift over many evictions."""
        cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
        for i in range(2000):
            cache.put(i, (i % 7 + 1) * 50)
            if i % 3 == 0:
                cache.get(i)
        self.assertEqual(cache.total_bytes,
                         sum(cache._sizes[k] for k in cache._entries))
        self.assertEqual(len(cache._slots), len(cache._entries))
        self.assertLessEqual(cache.total_bytes, cache.max_bytes)


class TestDefaults(unittest.TestCase):

    def test_aging_is_enabled_by_default(self):
        self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
        self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
        self.assertIsNotNone(UsageCache(4)._aging_interval)


if __name__ == '__main__':
    unittest.main()
