"""
Bounded usage-ranked caches for the text rendering hot path.

Laying out and rasterising a page re-measures and re-draws the same words over and
over: a typical page issues ~2800 width measurements and ~2500 glyph rasterisations
for fewer than 1000 distinct (font, string) pairs. Caching both collapses that work,
but an unbounded cache is not an option on a memory-constrained target such as a
Raspberry Pi Zero 2, where the rasterised bitmaps reach ~19MB over a long session.

Eviction is by **usage count**, not recency. Word frequency in prose is Zipfian and
stationary -- a small set of words ("the", "and", "of") accounts for most tokens on
every page, and that set barely shifts as the reader advances -- so the words worth
keeping are exactly the ones used most.

Two design choices keep this from costing more than it saves, because `get` runs
once per word drawn (~2500 times per page):

* **Counting is O(1) with no reordering.** Each entry carries its own use counter,
  bumped in place. Ranking structures that reorder on every hit (a frequency-bucket
  LFU, or an LRU's linked-list splice) were measured 3-5ms per page slower than the
  hit rate they buy is worth.
* **Eviction samples rather than sorts.** Finding the globally least-used entry
  would need a heap kept current on every hit. Instead a small random sample is
  drawn and the least-used member of it evicted, the same approximation Redis uses
  for its LFU policy. With the default sample size the evicted entry is very
  likely to be in the bottom few percent, which is all that matters here.

Both are single-threaded by design; the rendering path holds the GIL throughout and
adding locking would cost more than it protects.
"""

from __future__ import annotations

import random
from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, TypeVar

K = TypeVar('K', bound=Hashable)
V = TypeVar('V')

# Entries examined per eviction. Larger samples approximate true least-frequently-used
# more closely at linear cost; 8 puts the victim in the bottom ~15% of entries, which
# is ample when the alternative is a rasterisation that costs ~60us either way.
DEFAULT_EVICTION_SAMPLE = 8

# Halving every entry's use count after this many insertions keeps the cache
# responsive to a change of working set. Without it, entries that were hot long ago
# retain counts a newly-hot entry cannot beat and are never evicted -- the classic
# failure of pure frequency eviction. Measured on a real access trace, a font-size
# change drove hit rate to 0% without aging and left it unchanged with it.
DEFAULT_AGING_INTERVAL = 10000

# Index of each field in an entry. Entries are plain lists rather than tuples or
# objects so the counter can be bumped in place, without rehashing the key.
_VALUE = 0
_COUNT = 1
_SLOT = 2


class _UsageRanked(Generic[K, V]):
    """
    Shared usage-count bookkeeping for the caches below.

    Entries live in a dict for lookup and, in parallel, in a flat list that makes
    uniform random sampling possible. Each entry records its own index in that list
    so removal can swap in the tail element and stay O(1).

    Subclasses supply the bound by implementing :meth:`_over_budget` and the
    accounting hooks :meth:`_record_add` / :meth:`_record_remove`.
    """

    def __init__(self,
                 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
                 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
        if aging_interval is not None and aging_interval <= 0:
            raise ValueError(f"aging_interval must be positive, got {aging_interval}")
        if eviction_sample <= 0:
            raise ValueError(f"eviction_sample must be positive, got {eviction_sample}")

        self._aging_interval = aging_interval
        self._eviction_sample = eviction_sample

        self._entries: Dict[K, List[Any]] = {}
        self._slots: List[K] = []
        self._randrange = random.randrange

        self._inserts_since_aging = 0
        self._hits = 0
        self._misses = 0
        self._evictions = 0
        self._agings = 0

    # -- subclass hooks ----------------------------------------------------

    def _over_budget(self) -> bool:
        raise NotImplementedError

    def _record_add(self, key: K, value: V):
        """Account for a value entering the cache."""

    def _record_remove(self, key: K):
        """Account for a value leaving the cache."""

    # -- core operations ---------------------------------------------------

    def get(self, key: K) -> Optional[V]:
        """Return the cached value for `key`, or None, counting the use."""
        entry = self._entries.get(key)
        if entry is None:
            self._misses += 1
            return None
        entry[_COUNT] += 1
        self._hits += 1
        return entry[_VALUE]

    def _add_new(self, key: K, value: V):
        """Insert a key not currently present."""
        # New entries start at 1 rather than 0 so that a single reuse is enough to
        # outrank an entry that has never been touched since the last aging pass.
        self._entries[key] = [value, 1, len(self._slots)]
        self._slots.append(key)
        self._record_add(key, value)

    def _remove(self, key: K):
        """Remove a key outright, keeping the sampling list dense."""
        entry = self._entries.pop(key)
        slot = entry[_SLOT]
        last = self._slots.pop()
        if last != key:
            self._slots[slot] = last
            self._entries[last][_SLOT] = slot
        self._record_remove(key)

    def _evict_one(self) -> bool:
        """Evict the least-used member of a random sample. False if empty."""
        count = len(self._slots)
        if not count:
            return False

        if count <= self._eviction_sample:
            victim = min(self._slots, key=lambda k: self._entries[k][_COUNT])
        else:
            randrange = self._randrange
            entries = self._entries
            slots = self._slots
            victim = slots[randrange(count)]
            best = entries[victim][_COUNT]
            for _ in range(self._eviction_sample - 1):
                candidate = slots[randrange(count)]
                score = entries[candidate][_COUNT]
                if score < best:
                    victim, best = candidate, score

        self._remove(victim)
        self._evictions += 1
        return True

    def _evict_to_budget(self):
        while self._over_budget():
            if not self._evict_one():
                break

    def _maybe_age(self):
        """Halve every use count once the aging interval has elapsed."""
        if self._aging_interval is None:
            return
        self._inserts_since_aging += 1
        if self._inserts_since_aging < self._aging_interval:
            return

        self._inserts_since_aging = 0
        self._agings += 1
        for entry in self._entries.values():
            entry[_COUNT] = entry[_COUNT] // 2 or 1

    def clear(self):
        """Drop all entries. Counters are preserved."""
        self._entries.clear()
        self._slots.clear()
        self._inserts_since_aging = 0

    def _base_stats(self) -> Dict[str, Any]:
        total = self._hits + self._misses
        return {
            'entries': len(self._entries),
            'hits': self._hits,
            'misses': self._misses,
            'evictions': self._evictions,
            'agings': self._agings,
            'hit_rate': (self._hits / total) if total else 0.0,
        }

    def __len__(self) -> int:
        return len(self._entries)

    def __contains__(self, key: object) -> bool:
        return key in self._entries


class UsageCache(_UsageRanked[K, V]):
    """
    Usage-ranked cache bounded by number of entries.

    Args:
        max_entries: Maximum number of entries to retain. Must be positive.
        aging_interval: Insertions between halving all use counts, or None to
            disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
        eviction_sample: Entries sampled per eviction.
    """

    def __init__(self, max_entries: int,
                 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
                 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
        if max_entries <= 0:
            raise ValueError(f"max_entries must be positive, got {max_entries}")
        super().__init__(aging_interval, eviction_sample)
        self._max_entries = max_entries

    def _over_budget(self) -> bool:
        return len(self._entries) > self._max_entries

    def put(self, key: K, value: V, count: int = 1):
        """
        Insert `value`, evicting the least-used entries past the bound.

        Args:
            count: Initial use count. Pass a document-derived frequency to rank a
                preloaded entry ahead of words that have not been seen yet.
        """
        existing = self._entries.get(key)
        if existing is not None:
            existing[_VALUE] = value
            existing[_COUNT] += 1
            return
        self._add_new(key, value)
        if count > 1:
            self._entries[key][_COUNT] = count
        self._evict_to_budget()
        self._maybe_age()

    @property
    def max_entries(self) -> int:
        return self._max_entries

    def resize(self, max_entries: int):
        """Change the bound, evicting immediately if the cache now overflows."""
        if max_entries <= 0:
            raise ValueError(f"max_entries must be positive, got {max_entries}")
        self._max_entries = max_entries
        self._evict_to_budget()

    def stats(self) -> Dict[str, Any]:
        """Hit/miss/eviction counters and current occupancy."""
        stats = self._base_stats()
        stats['max_entries'] = self._max_entries
        return stats


class SizedUsageCache(_UsageRanked[K, V]):
    """
    Usage-ranked cache bounded by the total size of its values.

    Args:
        max_bytes: Maximum total value size to retain. Must be positive.
        sizer: Returns the size in bytes of a value. Called once per insertion.
        aging_interval: Insertions between halving all use counts, or None to
            disable aging. See :data:`DEFAULT_AGING_INTERVAL`.
        eviction_sample: Entries sampled per eviction.

    A value larger than `max_bytes` on its own is returned to the caller but not
    retained, so that one oversized entry cannot flush the whole cache.
    """

    def __init__(self, max_bytes: int, sizer: Callable[[V], int],
                 aging_interval: Optional[int] = DEFAULT_AGING_INTERVAL,
                 eviction_sample: int = DEFAULT_EVICTION_SAMPLE):
        if max_bytes <= 0:
            raise ValueError(f"max_bytes must be positive, got {max_bytes}")
        super().__init__(aging_interval, eviction_sample)
        self._max_bytes = max_bytes
        self._sizer = sizer
        self._sizes: Dict[K, int] = {}
        self._total_bytes = 0

    def _over_budget(self) -> bool:
        return self._total_bytes > self._max_bytes

    def _record_add(self, key: K, value: V):
        size = self._sizer(value)
        self._sizes[key] = size
        self._total_bytes += size

    def _record_remove(self, key: K):
        self._total_bytes -= self._sizes.pop(key)

    def put(self, key: K, value: V, count: int = 1):
        """
        Insert `value`, evicting the least-used entries past the bound.

        Args:
            count: Initial use count. Pass a document-derived frequency to rank a
                preloaded entry ahead of words that have not been seen yet.
        """
        if key in self._entries:
            # Re-measure: the replacement may be a different size.
            self._remove(key)

        if self._sizer(value) > self._max_bytes:
            # Too large to ever retain; skip rather than flush everything for it.
            return

        self._add_new(key, value)
        if count > 1:
            self._entries[key][_COUNT] = count
        self._evict_to_budget()
        self._maybe_age()

    @property
    def max_bytes(self) -> int:
        return self._max_bytes

    @property
    def total_bytes(self) -> int:
        return self._total_bytes

    def resize(self, max_bytes: int):
        """Change the bound, evicting immediately if the cache now overflows."""
        if max_bytes <= 0:
            raise ValueError(f"max_bytes must be positive, got {max_bytes}")
        self._max_bytes = max_bytes
        self._evict_to_budget()

    def clear(self):
        """Drop all entries. Counters are preserved."""
        super().clear()
        self._sizes.clear()
        self._total_bytes = 0

    def stats(self) -> Dict[str, Any]:
        """Hit/miss/eviction counters and current occupancy."""
        stats = self._base_stats()
        stats['total_bytes'] = self._total_bytes
        stats['max_bytes'] = self._max_bytes
        return stats
