"""
cortex/storage/db_manager.py — Connection pool for per-user SQLite databases.

Manages an LRU cache of open SQLiteAdapter connections. Hot users stay open,
idle connections get cleaned up. Thread-safe for concurrent async access.

Usage:
    manager = DBManager(data_dir="/data", max_open=100, idle_timeout=300)
    await manager.initialize()

    # Get adapter for a specific user (opens if not cached)
    adapter = await manager.get_adapter(owner_id="user-uuid-123")

    # Use adapter normally
    result = await adapter.retrieve(...)

    # Cleanup on shutdown
    await manager.close_all()

Design:
    - OrderedDict for O(1) LRU tracking (move_to_end on access, popitem(last=False) to evict)
    - asyncio.Lock for concurrency safety across concurrent coroutines
    - Lazy idle eviction: stale entries are cleaned when get_adapter() is called
    - owner_id validated against ^[a-zA-Z0-9_-]+$ to prevent path traversal
    - Stats counters: open_count, hit_count, miss_count, eviction_count
"""
from __future__ import annotations

import asyncio
import re
import time
from collections import OrderedDict
from pathlib import Path
from typing import Dict, Optional

from cortex.storage.sqlite_adapter import SQLiteAdapter

# Allowed characters in owner_id (prevent path traversal)
_OWNER_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")


class _CacheEntry:
    """Internal cache entry holding an adapter and its last-access timestamp."""

    __slots__ = ("adapter", "last_accessed", "owner_id")

    def __init__(self, adapter: SQLiteAdapter, owner_id: str) -> None:
        self.adapter = adapter
        self.owner_id = owner_id
        self.last_accessed: float = time.monotonic()

    def touch(self) -> None:
        self.last_accessed = time.monotonic()


class DBManager:
    """
    LRU connection pool for per-user SQLite databases.

    Each user's DB lives at ``{data_dir}/{owner_id}.db``.  The manager keeps
    up to ``max_open`` connections open simultaneously; when the cache is full
    the least-recently-used connection is closed and evicted.

    Idle connections older than ``idle_timeout`` seconds are lazily evicted the
    next time get_adapter() is called — no background timer is needed.

    All public methods are coroutine-safe via a single asyncio.Lock.
    """

    def __init__(
        self,
        data_dir: str | Path = "/data",
        max_open: int = 100,
        idle_timeout: float = 300.0,
    ) -> None:
        """
        Create a DBManager.

        Args:
            data_dir:     Directory where per-user ``.db`` files live.
            max_open:     Maximum number of simultaneously open connections.
                          When exceeded, the LRU connection is evicted.
            idle_timeout: Seconds after which an idle connection is eligible
                          for lazy eviction.  0 = never evict by age.
        """
        if max_open < 1:
            raise ValueError(f"max_open must be >= 1, got {max_open}")

        self._data_dir = Path(data_dir)
        self._max_open = max_open
        self._idle_timeout = float(idle_timeout)
        self._lock = asyncio.Lock()
        # OrderedDict: owner_id → _CacheEntry, LRU order (oldest at front)
        self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()

        # Stats
        self.open_count: int = 0       # total adapters ever opened
        self.hit_count: int = 0        # cache hits
        self.miss_count: int = 0       # cache misses (new connection opened)
        self.eviction_count: int = 0   # connections evicted (LRU or idle)

    async def initialize(self) -> None:
        """
        Prepare the manager for use.

        Currently a no-op (nothing to warm up), but provided for API symmetry
        with SQLiteAdapter and for future pre-warming logic.
        """
        self._data_dir.mkdir(parents=True, exist_ok=True)

    def _validate_owner_id(self, owner_id: str) -> None:
        """Raise ValueError if owner_id contains path-traversal characters."""
        if not owner_id:
            raise ValueError("owner_id must not be empty")
        if not _OWNER_ID_RE.match(owner_id):
            raise ValueError(
                f"owner_id {owner_id!r} contains invalid characters. "
                "Only alphanumeric characters, hyphens, and underscores are allowed."
            )

    def _db_path(self, owner_id: str) -> Path:
        return self._data_dir / f"{owner_id}.db"

    async def _evict_entry(self, owner_id: str) -> None:
        """Close and remove a single cache entry (must hold _lock)."""
        entry = self._cache.pop(owner_id, None)
        if entry is not None:
            self.eviction_count += 1
            try:
                await entry.adapter.close()
            except Exception:
                pass  # best-effort; don't let close failures block eviction

    async def _evict_idle(self) -> None:
        """Lazily close entries that have been idle longer than idle_timeout."""
        if self._idle_timeout <= 0:
            return
        now = time.monotonic()
        stale = [
            owner_id
            for owner_id, entry in self._cache.items()
            if (now - entry.last_accessed) >= self._idle_timeout
        ]
        for owner_id in stale:
            await self._evict_entry(owner_id)

    async def get_adapter(self, owner_id: str) -> SQLiteAdapter:
        """
        Return the SQLiteAdapter for *owner_id*, opening it if not cached.

        Raises:
            ValueError: if owner_id fails path-traversal validation.
        """
        self._validate_owner_id(owner_id)

        async with self._lock:
            # Lazy idle eviction before checking the cache
            await self._evict_idle()

            if owner_id in self._cache:
                # Cache hit — move to end (most recently used)
                self._cache.move_to_end(owner_id)
                entry = self._cache[owner_id]
                entry.touch()
                self.hit_count += 1
                return entry.adapter

            # Cache miss — open a new adapter
            self.miss_count += 1
            adapter = SQLiteAdapter(db_path=self._db_path(owner_id))
            await adapter.initialize()

            entry = _CacheEntry(adapter=adapter, owner_id=owner_id)
            self._cache[owner_id] = entry
            self.open_count += 1

            # Evict LRU if we've exceeded max_open
            while len(self._cache) > self._max_open:
                # The first item in an OrderedDict is the least-recently-used
                lru_owner_id, _ = next(iter(self._cache.items()))
                await self._evict_entry(lru_owner_id)

            return entry.adapter

    async def close_all(self) -> None:
        """Close all open connections and clear the cache."""
        async with self._lock:
            owner_ids = list(self._cache.keys())
            for owner_id in owner_ids:
                await self._evict_entry(owner_id)

    @property
    def cached_count(self) -> int:
        """Number of currently open connections in the cache."""
        return len(self._cache)

    def stats(self) -> Dict[str, int]:
        """Return a snapshot of pool statistics."""
        return {
            "cached": self.cached_count,
            "open_count": self.open_count,
            "hit_count": self.hit_count,
            "miss_count": self.miss_count,
            "eviction_count": self.eviction_count,
        }
