"""Shared async/sync bridge utilities for AiCippy.

Provides:
- run_sync: safely execute async coroutines from synchronous code
- thread_safe_singleton: decorator for thread-safe singletons
- TTLCache: thread-safe cache with TTL and LRU eviction
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import threading
import time
from collections import OrderedDict
from functools import wraps
from typing import TYPE_CHECKING, Any, TypeVar

if TYPE_CHECKING:
    from collections.abc import Coroutine

T = TypeVar("T")

# Default timeout for running coroutines from sync context (seconds)
_RUN_SYNC_TIMEOUT: int = 30


def run_sync(coro: Coroutine[Any, Any, T]) -> T:
    """Run an async coroutine from synchronous code safely.

    Handles three cases:
    1. No event loop running - creates a new one with asyncio.run()
    2. Event loop running but in a different thread - creates new loop in current thread
    3. Event loop running in current thread - uses a thread pool to avoid RuntimeError

    Args:
        coro: The coroutine to execute.

    Returns:
        The return value of the coroutine.

    Raises:
        TimeoutError: If the coroutine does not complete within 30 seconds.
        Exception: Any exception raised by the coroutine is re-raised.
    """
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        # No running loop - safe to use asyncio.run()
        return asyncio.run(coro)

    # Loop is running in this thread - run in a separate thread to avoid RuntimeError.
    # We avoid the context-manager form of ThreadPoolExecutor because its
    # __exit__ calls shutdown(wait=True), which blocks until the worker
    # finishes even if we already hit the timeout -- a deadlock scenario.
    pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
    try:
        future = pool.submit(asyncio.run, coro)
        return future.result(timeout=_RUN_SYNC_TIMEOUT)
    except concurrent.futures.TimeoutError as exc:
        # Cancel the future and shut down the pool without waiting, so
        # the caller is not blocked indefinitely.
        future.cancel()
        pool.shutdown(wait=False)
        raise TimeoutError(
            f"run_sync: coroutine did not complete within {_RUN_SYNC_TIMEOUT}s"
        ) from exc
    finally:
        # For the non-timeout path, clean up the pool without blocking
        pool.shutdown(wait=False)


def thread_safe_singleton(cls: type) -> type:
    """Decorator to make a class a thread-safe singleton.

    Uses double-checked locking to ensure only one instance is created
    even under concurrent access from multiple threads.

    The returned wrapper exposes a ``_reset()`` method to clear the
    cached instance (useful in tests).

    Args:
        cls: The class to wrap as a singleton.

    Returns:
        A wrapper that returns the same instance on every call.
    """
    _instances: dict[type, Any] = {}
    _lock = threading.Lock()

    @wraps(cls, updated=[])
    def get_instance(*args: Any, **kwargs: Any) -> Any:
        if cls not in _instances:
            with _lock:
                if cls not in _instances:
                    _instances[cls] = cls(*args, **kwargs)
        return _instances[cls]

    get_instance._reset = lambda: _instances.pop(cls, None)  # type: ignore[attr-defined]
    return get_instance  # type: ignore[return-value]


class TTLCache:
    """Thread-safe cache with TTL expiration and max-size LRU eviction.

    Each entry is stored with a timestamp. On ``get()``, entries older
    than ``ttl_seconds`` are treated as missing. When the cache exceeds
    ``max_size``, the least-recently-used entry is evicted.

    All public methods are protected by a :class:`threading.Lock` for
    safe concurrent access.

    Args:
        max_size: Maximum number of entries before LRU eviction.
        ttl_seconds: Time-to-live for each entry in seconds.
    """

    def __init__(self, max_size: int = 100, ttl_seconds: float = 300) -> None:
        if max_size < 1:
            raise ValueError("max_size must be >= 1")
        if ttl_seconds <= 0:
            raise ValueError("ttl_seconds must be > 0")

        self._max_size = max_size
        self._ttl_seconds = ttl_seconds
        # OrderedDict preserves insertion order; we move accessed keys to
        # the end so the *first* key is always the least-recently-used.
        self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
        self._lock = threading.Lock()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def get(self, key: str) -> Any | None:
        """Retrieve a value by key, or ``None`` if missing / expired.

        Accessing a valid entry moves it to the most-recently-used
        position (LRU promotion).
        """
        with self._lock:
            entry = self._store.get(key)
            if entry is None:
                return None

            ts, value = entry
            if (time.monotonic() - ts) > self._ttl_seconds:
                # Expired - remove silently
                del self._store[key]
                return None

            # LRU promotion
            self._store.move_to_end(key)
            return value

    def set(self, key: str, value: Any) -> None:
        """Store a value, evicting the LRU entry if at capacity."""
        with self._lock:
            if key in self._store:
                # Update existing - move to end
                del self._store[key]

            # Evict LRU entries if over capacity
            while len(self._store) >= self._max_size:
                self._store.popitem(last=False)

            self._store[key] = (time.monotonic(), value)

    def clear(self) -> None:
        """Remove all entries."""
        with self._lock:
            self._store.clear()

    def __contains__(self, key: str) -> bool:
        """Check if a non-expired entry exists for *key*."""
        with self._lock:
            entry = self._store.get(key)
            if entry is None:
                return False
            ts, _ = entry
            if (time.monotonic() - ts) > self._ttl_seconds:
                del self._store[key]
                return False
            return True

    def __len__(self) -> int:
        """Return the number of non-expired entries."""
        with self._lock:
            now = time.monotonic()
            expired_keys = [
                k for k, (ts, _) in self._store.items() if (now - ts) > self._ttl_seconds
            ]
            for k in expired_keys:
                del self._store[k]
            return len(self._store)
