#!/usr/bin/env python3
"""
Novyx Core - Graph Index Cache
==============================
Caches graph index to avoid repeated full-directory scans.

Features:
- Per-directory caching (safe for multi-tenant)
- Automatic invalidation on file modification (mtime)
- Configurable TTL via NOVYX_GRAPH_CACHE_TTL_SECONDS
- Thread-safe singleton pattern
- Graceful fallback on errors

Usage:
    from tools.graph_cache import get_index, invalidate

    # Get cached index (rebuilds if stale)
    index = get_index(Path("memory"))

    # Force rebuild
    index = get_index(Path("memory"), force_rebuild=True)

    # Invalidate after writes
    invalidate(Path("memory"))
"""

import os
import time
from pathlib import Path
from typing import Dict, Optional, Tuple
from threading import Lock

from tools.graph import scan_artifacts, build_index


# Configuration
DEFAULT_TTL_SECONDS = 30
_TTL_SECONDS = None  # Lazily loaded from env


def _get_ttl() -> float:
    """Get TTL from environment (cached after first call)."""
    global _TTL_SECONDS
    if _TTL_SECONDS is None:
        try:
            _TTL_SECONDS = float(os.getenv("NOVYX_GRAPH_CACHE_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
        except ValueError:
            _TTL_SECONDS = DEFAULT_TTL_SECONDS
    return _TTL_SECONDS


class CachedGraphIndex:
    """
    Singleton cache for graph indexes.

    Keyed by (directory_path, tenant_id) to ensure tenant isolation.
    Invalidates when any .jsonld file in directory has changed mtime.
    """

    _instance: Optional['CachedGraphIndex'] = None
    _lock = Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._cache: Dict[Tuple[str, Optional[str]], dict] = {}
        return cls._instance

    def get(
        self,
        directory: Path,
        *,
        tenant_id: Optional[str] = None,
        force_rebuild: bool = False
    ) -> dict:
        """
        Get graph index for directory, using cache if valid.

        Args:
            directory: Path to scan for artifacts
            tenant_id: Optional tenant ID for cache key isolation
            force_rebuild: If True, bypass cache and rebuild

        Returns:
            Graph index dict with keys: id_to_file, file_to_ids, file_to_links, inbound_links
        """
        cache_key = (str(directory.resolve()), tenant_id)

        with self._lock:
            # Check if we have a valid cached entry
            if not force_rebuild and cache_key in self._cache:
                entry = self._cache[cache_key]
                if self._is_valid(entry, directory):
                    return entry['index']

            # Rebuild index
            index, file_mtimes = self._build(directory)

            # Store in cache
            self._cache[cache_key] = {
                'index': index,
                'built_at': time.time(),
                'file_mtimes': file_mtimes,
                'directory': str(directory.resolve())
            }

            return index

    def invalidate(self, directory: Optional[Path] = None) -> None:
        """
        Invalidate cache for a specific directory or all directories.

        Args:
            directory: Path to invalidate, or None to clear all
        """
        with self._lock:
            if directory is None:
                self._cache.clear()
            else:
                resolved = str(directory.resolve())
                # Remove all entries that match this directory (any tenant)
                keys_to_remove = [
                    key for key in self._cache
                    if key[0] == resolved or key[0].startswith(resolved + os.sep)
                ]
                for key in keys_to_remove:
                    del self._cache[key]

    def _is_valid(self, entry: dict, directory: Path) -> bool:
        """Check if cache entry is still valid."""
        # Check TTL
        ttl = _get_ttl()
        if time.time() - entry['built_at'] > ttl:
            return False

        # Check if any file has changed
        try:
            current_files = set(directory.rglob("*.jsonld"))
            cached_files = set(Path(p) for p in entry['file_mtimes'].keys())

            # Check for new or removed files
            if current_files != cached_files:
                return False

            # Check mtimes
            for file_path in current_files:
                current_mtime = file_path.stat().st_mtime
                cached_mtime = entry['file_mtimes'].get(str(file_path))
                if cached_mtime is None or current_mtime != cached_mtime:
                    return False

            return True
        except (OSError, IOError):
            # On any file system error, invalidate cache
            return False

    def _build(self, directory: Path) -> Tuple[dict, Dict[str, float]]:
        """Build index and collect file mtimes."""
        artifacts, _errors = scan_artifacts(directory)
        index = build_index(artifacts)

        # Collect mtimes for cache validation
        file_mtimes = {}
        try:
            for jsonld_file in directory.rglob("*.jsonld"):
                file_mtimes[str(jsonld_file)] = jsonld_file.stat().st_mtime
        except (OSError, IOError):
            pass  # Best effort mtime collection

        return index, file_mtimes


# Module-level convenience functions

_cache = None


def _get_cache() -> CachedGraphIndex:
    """Get the singleton cache instance."""
    global _cache
    if _cache is None:
        _cache = CachedGraphIndex()
    return _cache


def get_index(
    directory: Path,
    *,
    tenant_id: Optional[str] = None,
    force_rebuild: bool = False
) -> dict:
    """
    Get graph index for directory, using cache if valid.

    This is the main entry point for cached graph operations.
    Falls back to direct scan_artifacts + build_index on any cache error.

    Args:
        directory: Path to scan for artifacts
        tenant_id: Optional tenant ID for cache key isolation
        force_rebuild: If True, bypass cache and rebuild

    Returns:
        Graph index dict with keys: id_to_file, file_to_ids, file_to_links, inbound_links

    Example:
        >>> index = get_index(Path("memory"))
        >>> len(index['id_to_file'])
        42
    """
    try:
        return _get_cache().get(directory, tenant_id=tenant_id, force_rebuild=force_rebuild)
    except Exception:
        # Fallback to uncached behavior on any error
        artifacts, _errors = scan_artifacts(directory)
        return build_index(artifacts)


def invalidate(directory: Optional[Path] = None) -> None:
    """
    Invalidate cache for a specific directory or all directories.

    Call this after any operation that modifies artifacts (repair, ingest, etc.)

    Args:
        directory: Path to invalidate, or None to clear all

    Example:
        >>> invalidate(Path("memory"))  # Clear cache for memory/
        >>> invalidate()  # Clear all caches
    """
    try:
        _get_cache().invalidate(directory)
    except Exception:
        pass  # Best effort invalidation


def reset_cache() -> None:
    """
    Reset the cache singleton (primarily for testing).

    This clears all cached data and resets the TTL configuration.
    """
    global _cache, _TTL_SECONDS
    _cache = None
    _TTL_SECONDS = None
