"""Client-side config caching with TTL support.

This module provides in-memory caching for config data (system defaults,
resource defaults, entity limits) to reduce DynamoDB reads during acquire().

See ADR-103 for design rationale.
"""

import asyncio
import threading
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, cast

#: Config source levels returned by :meth:`ConfigCache.resolve_limits`.
ConfigSource = Literal["entity", "entity_default", "resource", "system"]

if TYPE_CHECKING:
    from .models import Limit, OnUnavailableAction

# Sentinel value to distinguish "no config exists" from "not yet cached"
_NO_CONFIG: object = object()


@dataclass
class CacheEntry:
    """A cached value with expiration time."""

    value: Any
    expires_at: float


@dataclass
class CacheStats:
    """Statistics for cache performance monitoring."""

    hits: int = 0
    misses: int = 0
    size: int = 0
    ttl_seconds: int = 0

    def as_dict(self) -> dict[str, int]:
        """Return stats as a dictionary."""
        return {
            "hits": self.hits,
            "misses": self.misses,
            "size": self.size,
            "ttl": self.ttl_seconds,
        }


@dataclass
class ConfigCache:
    """
    In-memory cache for config data with TTL expiration.

    Provides thread-safe (for sync) and async-safe caching of:
    - System defaults (limits, on_unavailable)
    - Resource defaults (per resource)
    - Entity limits (per entity×resource, with negative caching)

    Thread/async safety:
    - Uses asyncio.Lock for async operations
    - Uses threading.Lock for sync operations
    - Locks are per-cache-instance (no global locking)

    Negative caching:
    - When entity config is not found, caches the "miss" to avoid repeated lookups
    - Uses sentinel _NO_CONFIG to distinguish None from "not cached"

    Args:
        ttl_seconds: Time-to-live for cached entries (0 = disabled)
    """

    ttl_seconds: int = 60
    namespace_id: str = "default"
    _enabled: bool = field(init=False, default=True)

    # Cache storage
    _system_defaults: CacheEntry | None = field(init=False, default=None)
    _resource_defaults: dict[str, CacheEntry] = field(init=False, default_factory=dict)
    _entity_limits: dict[tuple[str, str, str], CacheEntry] = field(init=False, default_factory=dict)

    # Statistics
    _hits: int = field(init=False, default=0)
    _misses: int = field(init=False, default=0)

    # Locks for thread/async safety
    _async_lock: asyncio.Lock = field(init=False, default_factory=asyncio.Lock)
    _sync_lock: threading.Lock = field(init=False, default_factory=threading.Lock)

    def __post_init__(self) -> None:
        """Initialize derived state after dataclass init."""
        self._enabled = self.ttl_seconds > 0

    def _is_expired(self, entry: CacheEntry) -> bool:
        """Check if a cache entry has expired."""
        return time.time() > entry.expires_at

    def _make_entry(self, value: Any) -> CacheEntry:
        """Create a new cache entry with TTL."""
        return CacheEntry(value=value, expires_at=time.time() + self.ttl_seconds)

    def evict_entity(self, entity_id: str, resource: str) -> None:
        """Evict a single entity+resource config from cache (issue #327).

        Called after set_limits() or delete_limits() to ensure the next
        config resolution reads fresh data from DynamoDB.

        Args:
            entity_id: Entity ID to evict
            resource: Resource name to evict
        """
        self._entity_limits.pop((self.namespace_id, entity_id, resource), None)

    # -------------------------------------------------------------------------
    # System defaults
    # -------------------------------------------------------------------------

    async def get_system_defaults(
        self,
        fetch_fn: Callable[[], Awaitable[tuple[list["Limit"], "OnUnavailableAction | None"]]],
    ) -> tuple[list["Limit"], "OnUnavailableAction | None"]:
        """
        Get system defaults, using cache if valid.

        Args:
            fetch_fn: Async function to fetch system defaults from repository

        Returns:
            Tuple of (limits, on_unavailable_str)
        """
        if not self._enabled:
            return await fetch_fn()

        async with self._async_lock:
            # Check cache
            if self._system_defaults is not None and not self._is_expired(self._system_defaults):
                self._hits += 1
                result: tuple[list[Limit], OnUnavailableAction | None] = self._system_defaults.value
                return result

            # Cache miss - fetch and store
            self._misses += 1
            value = await fetch_fn()
            self._system_defaults = self._make_entry(value)
            return value

    def get_system_defaults_sync(
        self,
        fetch_fn: Callable[[], tuple[list["Limit"], "OnUnavailableAction | None"]],
    ) -> tuple[list["Limit"], "OnUnavailableAction | None"]:
        """
        Get system defaults synchronously, using cache if valid.

        Args:
            fetch_fn: Sync function to fetch system defaults from repository

        Returns:
            Tuple of (limits, on_unavailable_str)
        """
        if not self._enabled:
            return fetch_fn()

        with self._sync_lock:
            # Check cache
            if self._system_defaults is not None and not self._is_expired(self._system_defaults):
                self._hits += 1
                result: tuple[list[Limit], OnUnavailableAction | None] = self._system_defaults.value
                return result

            # Cache miss - fetch and store
            self._misses += 1
            value = fetch_fn()
            self._system_defaults = self._make_entry(value)
            return value

    # -------------------------------------------------------------------------
    # Resource defaults
    # -------------------------------------------------------------------------

    async def get_resource_defaults(
        self,
        resource: str,
        fetch_fn: Callable[[str], Awaitable[list["Limit"]]],
    ) -> list["Limit"]:
        """
        Get resource defaults, using cache if valid.

        Args:
            resource: Resource name
            fetch_fn: Async function to fetch resource defaults from repository

        Returns:
            List of limits (empty if none configured)
        """
        if not self._enabled:
            return await fetch_fn(resource)

        async with self._async_lock:
            # Check cache
            entry = self._resource_defaults.get(resource)
            if entry is not None and not self._is_expired(entry):
                self._hits += 1
                return cast(list["Limit"], entry.value)

            # Cache miss - fetch and store
            self._misses += 1
            value = await fetch_fn(resource)
            self._resource_defaults[resource] = self._make_entry(value)
            return value

    def get_resource_defaults_sync(
        self,
        resource: str,
        fetch_fn: Callable[[str], list["Limit"]],
    ) -> list["Limit"]:
        """
        Get resource defaults synchronously, using cache if valid.

        Args:
            resource: Resource name
            fetch_fn: Sync function to fetch resource defaults from repository

        Returns:
            List of limits (empty if none configured)
        """
        if not self._enabled:
            return fetch_fn(resource)

        with self._sync_lock:
            # Check cache
            entry = self._resource_defaults.get(resource)
            if entry is not None and not self._is_expired(entry):
                self._hits += 1
                return cast(list["Limit"], entry.value)

            # Cache miss - fetch and store
            self._misses += 1
            value = fetch_fn(resource)
            self._resource_defaults[resource] = self._make_entry(value)
            return value

    # -------------------------------------------------------------------------
    # Entity limits (with negative caching)
    # -------------------------------------------------------------------------

    async def get_entity_limits(
        self,
        entity_id: str,
        resource: str,
        fetch_fn: Callable[[str, str], Awaitable[list["Limit"]]],
    ) -> list["Limit"]:
        """
        Get entity limits, using cache if valid (with negative caching).

        Negative caching: If no limits exist for this entity×resource,
        caches the empty result to avoid repeated lookups.

        Args:
            entity_id: Entity ID
            resource: Resource name
            fetch_fn: Async function to fetch entity limits from repository

        Returns:
            List of limits (empty if none configured)
        """
        if not self._enabled:
            return await fetch_fn(entity_id, resource)

        cache_key = (self.namespace_id, entity_id, resource)

        async with self._async_lock:
            # Check cache
            entry = self._entity_limits.get(cache_key)
            if entry is not None and not self._is_expired(entry):
                self._hits += 1
                # Handle negative cache
                if entry.value is _NO_CONFIG:
                    return []
                return cast(list["Limit"], entry.value)

            # Cache miss - fetch and store
            self._misses += 1
            value = await fetch_fn(entity_id, resource)

            # Store with negative caching
            if value:
                self._entity_limits[cache_key] = self._make_entry(value)
            else:
                # Negative cache: store sentinel to remember "no config"
                self._entity_limits[cache_key] = self._make_entry(_NO_CONFIG)

            return value

    def get_entity_limits_sync(
        self,
        entity_id: str,
        resource: str,
        fetch_fn: Callable[[str, str], list["Limit"]],
    ) -> list["Limit"]:
        """
        Get entity limits synchronously, using cache if valid (with negative caching).

        Args:
            entity_id: Entity ID
            resource: Resource name
            fetch_fn: Sync function to fetch entity limits from repository

        Returns:
            List of limits (empty if none configured)
        """
        if not self._enabled:
            return fetch_fn(entity_id, resource)

        cache_key = (self.namespace_id, entity_id, resource)

        with self._sync_lock:
            # Check cache
            entry = self._entity_limits.get(cache_key)
            if entry is not None and not self._is_expired(entry):
                self._hits += 1
                # Handle negative cache
                if entry.value is _NO_CONFIG:
                    return []
                return cast(list["Limit"], entry.value)

            # Cache miss - fetch and store
            self._misses += 1
            value = fetch_fn(entity_id, resource)

            # Store with negative caching
            if value:
                self._entity_limits[cache_key] = self._make_entry(value)
            else:
                # Negative cache: store sentinel to remember "no config"
                self._entity_limits[cache_key] = self._make_entry(_NO_CONFIG)

            return value

    # -------------------------------------------------------------------------
    # Batched config resolution (issue #298)
    # -------------------------------------------------------------------------

    def _check_cache_slot(
        self,
        slot_type: str,
        entity_id: str | None = None,
        resource: str | None = None,
    ) -> tuple[bool, Any]:
        """Check a single cache slot for a valid entry.

        Args:
            slot_type: One of "entity", "entity_default", "resource", "system"
            entity_id: Entity ID (for entity/entity_default slots)
            resource: Resource name (for entity/resource slots)

        Returns:
            (is_cached, value) where is_cached indicates if a valid entry exists.
            For entity slots, _NO_CONFIG sentinel means "confirmed no config" (negative cache).
        """
        if slot_type == "entity":
            assert entity_id is not None and resource is not None
            entry = self._entity_limits.get((self.namespace_id, entity_id, resource))
        elif slot_type == "entity_default":
            assert entity_id is not None
            entry = self._entity_limits.get((self.namespace_id, entity_id, "_default_"))
        elif slot_type == "resource":
            assert resource is not None
            entry = self._resource_defaults.get(resource)
        elif slot_type == "system":
            entry = self._system_defaults
        else:
            return False, None

        if entry is not None and not self._is_expired(entry):
            return True, entry.value
        return False, None

    async def resolve_limits(
        self,
        entity_id: str,
        resource: str,
        batch_fetch_fn: Callable[
            [list[tuple[str, str]]],
            Awaitable["dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]"],
        ],
    ) -> "tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]":
        """
        Resolve limits using batched config fetch with cache awareness.

        Checks all 4 cache slots (entity, entity_default, resource, system),
        collects misses, fetches them in a single BatchGetItem call, populates
        the cache, and returns the highest-precedence match.

        Args:
            entity_id: Entity to resolve limits for
            resource: Resource being accessed
            batch_fetch_fn: Async function to batch-fetch config items by (PK, SK).
                Returns dict mapping (PK, SK) to (limits, on_unavailable) tuples.

        Returns:
            Tuple of (limits, on_unavailable, config_source) where:
            - limits: Resolved limits or None if nothing found
            - on_unavailable: on_unavailable value from system config (if fetched)
            - config_source: "entity", "entity_default", "resource", "system", or None
        """
        if not self._enabled:
            return await self._resolve_limits_uncached(entity_id, resource, batch_fetch_fn)

        async with self._async_lock:
            return await self._resolve_limits_inner_async(entity_id, resource, batch_fetch_fn)

    async def _resolve_limits_inner_async(
        self,
        entity_id: str,
        resource: str,
        batch_fetch_fn: Callable[
            [list[tuple[str, str]]],
            Awaitable["dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]"],
        ],
    ) -> "tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]":
        """Async inner implementation for batched config resolution."""
        from . import schema

        levels, cached_results, miss_keys = self._build_levels_and_check_cache(
            entity_id, resource, schema
        )

        # Phase 2: Batch fetch misses
        fetched_results: dict[str, Any] = {}
        on_unavailable: OnUnavailableAction | None = None

        if miss_keys:
            fetch_keys = [(pk, sk) for _, pk, sk in miss_keys]
            items = await batch_fetch_fn(fetch_keys)
            fetched_results, on_unavailable = self._process_fetched_items(
                miss_keys, items, entity_id, resource
            )

        # Phase 3: Evaluate hierarchy
        return self._evaluate_hierarchy(levels, cached_results, fetched_results, on_unavailable)

    def _build_levels_and_check_cache(
        self,
        entity_id: str,
        resource: str,
        schema: Any,
    ) -> tuple[
        list[tuple[ConfigSource, str, str]],
        dict[str, Any],
        list[tuple[ConfigSource, str, str]],
    ]:
        """Build config levels and check cache for each, returning misses.

        Returns:
            (levels, cached_results, miss_keys)
        """
        include_entity_default = resource != "_default_"
        ns = self.namespace_id
        levels: list[tuple[ConfigSource, str, str]] = [
            ("entity", schema.pk_entity(ns, entity_id), schema.sk_config(resource)),
        ]
        if include_entity_default:
            levels.append(
                ("entity_default", schema.pk_entity(ns, entity_id), schema.sk_config("_default_")),
            )
        levels.extend(
            [
                ("resource", schema.pk_resource(ns, resource), schema.sk_config()),
                ("system", schema.pk_system(ns), schema.sk_config()),
            ]
        )

        cached_results: dict[str, Any] = {}
        miss_keys: list[tuple[ConfigSource, str, str]] = []

        for slot_type, pk, sk in levels:
            is_cached, value = self._check_cache_slot(
                slot_type, entity_id=entity_id, resource=resource
            )
            if is_cached:
                self._hits += 1
                cached_results[slot_type] = value
            else:
                self._misses += 1
                miss_keys.append((slot_type, pk, sk))

        return levels, cached_results, miss_keys

    def _process_fetched_items(
        self,
        miss_keys: list[tuple[ConfigSource, str, str]],
        items: "dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]",
        entity_id: str,
        resource: str,
    ) -> "tuple[dict[str, Any], OnUnavailableAction | None]":
        """Process fetched items: populate cache and return results.

        Returns:
            (fetched_results, on_unavailable)
        """
        fetched_results: dict[str, Any] = {}
        on_unavailable: OnUnavailableAction | None = None

        for slot_type, pk, sk in miss_keys:
            entry = items.get((pk, sk))
            if entry is not None:
                limits, ou_val = entry
                if slot_type == "system":
                    self._system_defaults = self._make_entry((limits, ou_val))
                    fetched_results[slot_type] = limits
                    on_unavailable = ou_val
                elif slot_type == "resource":
                    self._resource_defaults[resource] = self._make_entry(limits)
                    fetched_results[slot_type] = limits
                elif slot_type == "entity":
                    cache_key = (self.namespace_id, entity_id, resource)
                    if limits:
                        self._entity_limits[cache_key] = self._make_entry(limits)
                    else:
                        self._entity_limits[cache_key] = self._make_entry(_NO_CONFIG)
                    fetched_results[slot_type] = limits
                elif slot_type == "entity_default":
                    cache_key_d = (self.namespace_id, entity_id, "_default_")
                    if limits:
                        self._entity_limits[cache_key_d] = self._make_entry(limits)
                    else:
                        self._entity_limits[cache_key_d] = self._make_entry(_NO_CONFIG)
                    fetched_results[slot_type] = limits
            else:
                ns = self.namespace_id
                if slot_type == "entity":
                    self._entity_limits[(ns, entity_id, resource)] = self._make_entry(_NO_CONFIG)
                    fetched_results[slot_type] = _NO_CONFIG
                elif slot_type == "entity_default":
                    self._entity_limits[(ns, entity_id, "_default_")] = self._make_entry(_NO_CONFIG)
                    fetched_results[slot_type] = _NO_CONFIG
                elif slot_type == "resource":
                    self._resource_defaults[resource] = self._make_entry([])
                    fetched_results[slot_type] = []
                elif slot_type == "system":
                    self._system_defaults = self._make_entry(([], None))
                    fetched_results[slot_type] = []
                    on_unavailable = None

        return fetched_results, on_unavailable

    def _evaluate_hierarchy(
        self,
        levels: list[tuple[ConfigSource, str, str]],
        cached_results: dict[str, Any],
        fetched_results: dict[str, Any],
        on_unavailable: "OnUnavailableAction | None",
    ) -> "tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]":
        """Evaluate config hierarchy from cached + fetched results.

        Returns:
            (limits, on_unavailable, config_source)
        """
        # Extract on_unavailable from cached system defaults if not fetched
        if "system" not in fetched_results and "system" in cached_results:
            system_value = cached_results["system"]
            if isinstance(system_value, tuple) and len(system_value) == 2:
                on_unavailable = system_value[1]

        for slot_type, _, _ in levels:
            value = cached_results.get(slot_type) or fetched_results.get(slot_type)
            if value is _NO_CONFIG or value is None:
                continue

            # For system cache, value is (limits, on_unavailable) tuple
            if slot_type == "system" and isinstance(value, tuple):
                limits_val = value[0]
                if limits_val:
                    return limits_val, on_unavailable, slot_type
                continue

            if isinstance(value, list) and value:
                return value, on_unavailable, slot_type

        return None, on_unavailable, None

    async def _resolve_limits_uncached(
        self,
        entity_id: str,
        resource: str,
        batch_fetch_fn: Callable[
            [list[tuple[str, str]]],
            Awaitable["dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]"],
        ],
    ) -> "tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]":
        """Resolve limits without caching (TTL=0): batch fetch all 4 levels."""
        from . import schema

        levels, _, _ = self._build_levels_and_check_cache(entity_id, resource, schema)
        fetch_keys = [(pk, sk) for _, pk, sk in levels]
        items = await batch_fetch_fn(fetch_keys)
        return self._evaluate_uncached(levels, items)

    def _evaluate_uncached(
        self,
        levels: list[tuple[ConfigSource, str, str]],
        items: "dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]",
    ) -> "tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]":
        """Evaluate hierarchy from batch results without caching."""
        on_unavailable: OnUnavailableAction | None = None

        # Extract on_unavailable from system entry if present
        for slot_type, pk, sk in levels:
            if slot_type == "system":
                entry = items.get((pk, sk))
                if entry is not None:
                    on_unavailable = entry[1]
                break

        for slot_type, pk, sk in levels:
            entry = items.get((pk, sk))
            if entry is None:
                continue
            limits = entry[0]
            if limits:
                return limits, on_unavailable, slot_type

        return None, on_unavailable, None

    # -------------------------------------------------------------------------
    # Cache management
    # -------------------------------------------------------------------------

    def invalidate(self) -> None:
        """
        Invalidate all cached entries.

        Thread-safe. Call this after config changes to force refresh.
        """
        with self._sync_lock:
            self._system_defaults = None
            self._resource_defaults.clear()
            self._entity_limits.clear()

    async def invalidate_async(self) -> None:
        """
        Invalidate all cached entries (async version).

        Async-safe. Call this after config changes to force refresh.
        """
        async with self._async_lock:
            self._system_defaults = None
            self._resource_defaults.clear()
            self._entity_limits.clear()

    def get_stats(self) -> CacheStats:
        """
        Get cache performance statistics.

        Returns:
            CacheStats with hits, misses, size, and TTL
        """
        with self._sync_lock:
            size = (
                (1 if self._system_defaults is not None else 0)
                + len(self._resource_defaults)
                + len(self._entity_limits)
            )
            return CacheStats(
                hits=self._hits,
                misses=self._misses,
                size=size,
                ttl_seconds=self.ttl_seconds,
            )

    @property
    def enabled(self) -> bool:
        """Whether caching is enabled (TTL > 0)."""
        return self._enabled
