"""
Shared dependencies and utilities for the SLTK API.

Contains validation functions, rate limiting helpers, and shared state.
"""

from __future__ import annotations

import logging
import os
import re
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any, TypeVar

from fastapi import HTTPException

# Rate limiting support (optional)
try:
    from slowapi import Limiter
    from slowapi.util import get_remote_address  # noqa: F401

    RATE_LIMITING_AVAILABLE = True
except ImportError:
    RATE_LIMITING_AVAILABLE = False
    Limiter = None  # type: ignore[misc, assignment]

logger = logging.getLogger(__name__)

# Type variable for decorator
F = TypeVar("F", bound=Callable[..., Any])

# ============================================================================
# Rate Limiting
# ============================================================================

# Global limiter instance (set by main.py)
limiter: Limiter | None = None


def set_limiter(lim: Limiter | None) -> None:
    """Set the global limiter instance."""
    global limiter
    limiter = lim


def rate_limit(limit: str) -> Callable[[F], F]:
    """
    Rate limit decorator that gracefully handles missing slowapi.

    Uses a no-op passthrough to preserve the original function signature
    for FastAPI's parameter resolution. Slowapi rate limiting is applied
    via the app-level limiter middleware instead.

    Args:
        limit: Rate limit string (e.g., "5/minute", "100/hour")

    Returns:
        The original function, unmodified. Rate limiting is handled at
        the middleware level by slowapi when configured.

    Example:
        @rate_limit("10/minute")
        async def my_endpoint():
            ...
    """

    def decorator(func: F) -> F:
        # Always return the original function to preserve its signature
        # for FastAPI's dependency injection / body parsing.
        # Slowapi applies rate limiting via middleware, not decoration.
        return func

    return decorator


# ============================================================================
# Path Validation
# ============================================================================

# Security: Allowed root directories for file access
# Configure via environment variable or default to common paths
# Include /tmp for uploads, user home, and scratch storage
_default_allowed = "/vol/research,/home,/tmp,/mnt/fast/nobackup"
ALLOWED_ROOTS = [
    Path(p).expanduser().resolve()
    for p in os.environ.get("SLTK_ALLOWED_PATHS", _default_allowed).split(",")
    if p.strip()
]
# Also add the user's home directory explicitly (handles ~ expansion)
_user_home = Path.home()
if _user_home not in ALLOWED_ROOTS:
    ALLOWED_ROOTS.append(_user_home)


def _redact_internal_paths(message: str) -> str:
    """
    Strip internal filesystem paths from error messages to prevent information leakage.

    Replaces paths like /vol/research/... or /home/user/... with generic placeholders.
    Preserves filenames (last path component) when they appear useful to the user.
    """

    # Match absolute Unix paths (at least 2 levels deep to avoid false positives)
    # Captures paths like /vol/research/SignFeaturePool/features2/bslcp/file.h5
    def _replace_path(match: re.Match) -> str:
        full = match.group(0)
        # Keep just the filename for user context
        parts = full.rstrip("/").rsplit("/", 1)
        if len(parts) > 1 and "." in parts[-1]:
            return f"<server-path>/{parts[-1]}"
        return "<server-path>"

    return re.sub(r"/(?:vol|home|tmp|mnt|usr|etc|var|opt)/\S+", _replace_path, message)


def safe_error_detail(operation: str, exc: Exception) -> str:
    """
    Build an HTTP error detail string that does not leak internal paths.

    Logs the full error server-side, returns a sanitized message to the client.

    Args:
        operation: Human-readable description of the failed operation.
        exc: The caught exception.

    Returns:
        A safe detail string for HTTPException.
    """
    full_msg = str(exc)
    logger.error(f"{operation}: {full_msg}")
    safe_msg = _redact_internal_paths(full_msg)
    return f"{operation}: {safe_msg}"


def validate_path(path: str, must_exist: bool = True, allow_creation: bool = False) -> Path:
    """
    Validate that a path is within allowed directories and optionally exists.

    Security: This function prevents path traversal attacks by:
    1. Checking for dangerous patterns BEFORE resolution (null bytes, encoded traversal)
    2. Checking for '..' as path components
    3. Verifying the resolved path is within ALLOWED_ROOTS
    4. Re-checking after resolution to prevent symlink attacks

    Args:
        path: The path to validate
        must_exist: If True, check that the file exists
        allow_creation: If True, validates parent directory for new files

    Returns:
        Resolved Path object

    Raises:
        HTTPException: If path is invalid or outside allowed directories
    """
    # Security: Block null bytes which can truncate paths in some systems
    if "\x00" in path:
        logger.warning(f"Path validation blocked null byte attack: {repr(path[:50])}")
        raise HTTPException(403, "Invalid path: null bytes not allowed")

    # Security: Block URL-encoded traversal attempts
    if "%2e" in path.lower() or "%2f" in path.lower():
        logger.warning(f"Path validation blocked encoded traversal: {path[:50]}")
        raise HTTPException(403, "Invalid path: encoded characters not allowed")

    # Block path traversal attempts BEFORE resolution
    # Check for '..' as a path component, not just substring
    path_obj = Path(path).expanduser()  # Expand ~ to user home directory
    if ".." in path_obj.parts:
        logger.warning(f"Path validation blocked traversal attempt: {path[:100]}")
        raise HTTPException(403, "Path traversal not allowed")

    try:
        resolved = path_obj.resolve(strict=False)
    except (OSError, ValueError) as e:
        logger.warning(f"Path validation failed to resolve: {path[:100]} - {e}")
        raise HTTPException(400, "Invalid path: could not resolve")

    # Security: Re-check for '..' AFTER resolution (catches symlink attacks)
    # Compare resolved path string to ensure no traversal via symlinks
    if ALLOWED_ROOTS:
        is_allowed = any(
            resolved == root or resolved.is_relative_to(root) for root in ALLOWED_ROOTS
        )
        if not is_allowed:
            logger.warning(f"Path validation blocked access outside allowed roots: {resolved}")
            raise HTTPException(403, "Access denied: Path is outside allowed directories")

    if must_exist and not resolved.exists():
        raise HTTPException(404, f"File not found: {path}")

    # For file creation, validate parent directory exists and is allowed
    if allow_creation and not resolved.exists():
        parent = resolved.parent
        if not parent.exists():
            raise HTTPException(400, "Parent directory does not exist")
        # Ensure parent is also within allowed roots
        if ALLOWED_ROOTS:
            parent_allowed = any(
                parent == root or parent.is_relative_to(root) for root in ALLOWED_ROOTS
            )
            if not parent_allowed:
                logger.warning(
                    f"Path validation blocked file creation outside allowed roots: {resolved}"
                )
                raise HTTPException(
                    403, "Access denied: Cannot create files outside allowed directories"
                )

    return resolved


# ============================================================================
# Dataset Instance Cache
# ============================================================================
# Caches dataset instances to avoid re-parsing large JSON indexes on every request

_dataset_cache: dict[tuple[str, str], tuple[Any, float]] = {}
_dataset_cache_lock = threading.Lock()
DATASET_CACHE_TTL = 3600  # 1 hour (datasets are static, no need to reload frequently)

# Cache size limits to prevent unbounded memory growth
MAX_DATASET_CACHE_SIZE = 20
MAX_SEGMENTS_CACHE_SIZE = 50
MAX_VOCABULARY_CACHE_SIZE = 50
MAX_USER_DATASET_CACHE_SIZE = 50

# User connection cache
_user_dataset_cache: dict[tuple[str, str], tuple[Any, float]] = {}
_user_dataset_cache_lock = threading.Lock()


def _get_user_connected_dataset(name: str, split: str = "all") -> Any:
    """
    Get a dataset-like object for a user-connected dataset.

    Loads data from the user's configured paths instead of built-in locations.

    Args:
        name: Dataset name (without 'user:' prefix)
        split: Dataset split (ignored for user datasets, always 'all')

    Returns:
        A dataset-like object with _index, _load_sample, etc.

    Raises:
        HTTPException: If connection not found or cannot load
    """
    import json

    from sltk.data.core import Sample, SegmentList
    from sltk.io.elan import read_eaf

    # Load connections
    settings_dir = Path.home() / ".sltk"
    connections_file = settings_dir / "connections.json"

    try:
        if not connections_file.exists():
            raise HTTPException(404, "No connections file found")
        with open(connections_file) as f:
            connections = json.load(f)
    except Exception as e:
        raise HTTPException(500, f"Failed to load connections: {e}")

    if name not in connections:
        raise HTTPException(404, f"User dataset not connected: {name}")

    conn = connections[name]
    video_root = Path(conn["video_root"])
    annotation_root = Path(conn.get("annotation_root", conn["video_root"]))

    # Check cache
    key = (f"user:{name}", split)
    current_time = time.time()

    with _user_dataset_cache_lock:
        if key in _user_dataset_cache:
            ds, ts = _user_dataset_cache[key]
            if current_time - ts < DATASET_CACHE_TTL:
                return ds
            del _user_dataset_cache[key]

    # Build index from annotation files
    eaf_files = sorted(annotation_root.glob("*.eaf"))
    if not eaf_files:
        # Try video files if no annotations
        video_exts = [".mov", ".mp4", ".avi", ".mkv"]
        video_files: list[Path] = []
        for ext in video_exts:
            video_files.extend(video_root.glob(f"*{ext}"))
        sample_ids = [f.stem for f in sorted(video_files)]
    else:
        sample_ids = [f.stem for f in eaf_files]

    if not sample_ids:
        raise HTTPException(400, f"No samples found in user dataset: {name}")

    # Create a simple dataset-like object
    class UserDataset:
        def __init__(self, index, video_root, annotation_root, name):
            self._index = index
            self._video_root = video_root
            self._annotation_root = annotation_root
            self._name = name

        def __len__(self):
            return len(self._index)

        def _load_sample(self, sample_id: str) -> Sample:
            # Find video
            video_path = None
            for ext in [".mov", ".mp4", ".avi", ".mkv"]:
                candidate = self._video_root / f"{sample_id}{ext}"
                if candidate.exists():
                    video_path = candidate
                    break

            # Load annotations
            eaf_path = self._annotation_root / f"{sample_id}.eaf"
            segments = SegmentList()
            glosses = []

            if eaf_path.exists():
                try:
                    segments = read_eaf(eaf_path)
                    glosses = [s.label for s in segments if s.label]
                except Exception as e:
                    logger.debug(f"Failed to load EAF {eaf_path}: {e}")

            # Extract signer ID (first 2 chars typically)
            signer_id = sample_id[:2] if len(sample_id) >= 2 else sample_id

            return Sample(
                id=sample_id,
                video_path=video_path,
                segments=segments,
                glosses=glosses,
                signer_id=signer_id,
                dataset=self._name,
                split="all",
            )

    dataset = UserDataset(sample_ids, video_root, annotation_root, name)

    # Cache it (with size limit to prevent unbounded memory growth)
    with _user_dataset_cache_lock:
        _user_dataset_cache[key] = (dataset, current_time)
    _evict_oldest_cache_entry(
        _user_dataset_cache, _user_dataset_cache_lock, MAX_USER_DATASET_CACHE_SIZE
    )

    return dataset


# Cache for loaded segments - avoid reloading for each analysis endpoint
_segments_cache: dict[tuple[str, int], tuple[dict, dict, float]] = {}
_segments_cache_lock = threading.Lock()
SEGMENTS_CACHE_TTL = 300  # 5 minutes for segment data

# Cache for vocabulary - avoid recomputing for each request
_vocabulary_cache: dict[str, tuple[dict, float]] = {}
_vocabulary_cache_lock = threading.Lock()
VOCABULARY_CACHE_TTL = 600  # 10 minutes for vocabulary


def _evict_oldest_cache_entry(cache: dict, lock: threading.Lock, max_size: int) -> None:
    """Evict the oldest entry from a timestamped cache if it exceeds max_size."""
    with lock:
        if len(cache) > max_size:
            # Find and remove the oldest entry (lowest timestamp)
            oldest_key = min(cache.keys(), key=lambda k: cache[k][-1])
            del cache[oldest_key]


def get_cached_dataset(name: str, split: str = "all") -> Any:
    """
    Get or create a cached dataset instance.

    Avoids re-parsing large JSON indexes on every request by caching dataset
    instances with a TTL.

    Supports both built-in datasets and user-connected datasets (with 'user:' prefix).

    Args:
        name: Dataset name (can be 'bslcp' or 'user:bslcp')
        split: Dataset split (default "all")

    Returns:
        Cached or newly created dataset instance

    Raises:
        HTTPException: If dataset not found
    """
    from sltk.data.datasets import DATASETS

    # Handle user-connected datasets (prefixed with 'user:')
    if name.startswith("user:"):
        actual_name = name[5:]  # Remove 'user:' prefix
        return _get_user_connected_dataset(actual_name, split)

    if name not in DATASETS:
        raise HTTPException(404, f"Dataset not found: {name}")

    key = (name, split)
    current_time = time.time()

    # Check cache first
    with _dataset_cache_lock:
        if key in _dataset_cache:
            ds, ts = _dataset_cache[key]
            if current_time - ts < DATASET_CACHE_TTL:
                return ds
            # Expired, remove from cache
            del _dataset_cache[key]

    # Outside lock for initialization (may be slow)
    dataset_cls = DATASETS[name]
    try:
        dataset = dataset_cls(split=split)
    except Exception as e:
        # Dataset failed to initialize - likely because the default path doesn't exist
        # This means the dataset needs to be connected first
        from sltk.exceptions import DatasetNotFoundError

        if isinstance(e, DatasetNotFoundError):
            raise HTTPException(
                400,
                f"Dataset '{name}' is not connected. Please connect this dataset first by "
                f"configuring the video, annotation, and feature paths on the Datasets page.",
            )
        # Re-raise other errors with more context
        raise HTTPException(500, f"Failed to load dataset '{name}': {e}")

    # Store in cache
    with _dataset_cache_lock:
        _dataset_cache[key] = (dataset, current_time)

    # Evict oldest entry if cache is too large
    _evict_oldest_cache_entry(_dataset_cache, _dataset_cache_lock, MAX_DATASET_CACHE_SIZE)

    return dataset


# ============================================================================
# File Stat/Exists Cache Helper
# ============================================================================
# Caches filesystem stat results to avoid redundant calls on network mounts


def check_video_path(path: Path | None, cache: dict | None = None) -> tuple[bool, float | None]:
    """
    Check if video exists and get size, with simple caching.

    Args:
        path: Path to check
        cache: Optional dict for caching results within a request

    Returns:
        Tuple of (exists, size_mb)
    """
    if path is None:
        return False, None

    key = str(path)
    if cache is not None and key in cache:
        return cache[key]

    try:
        exists = path.exists()
        size = path.stat().st_size / (1024 * 1024) if exists else None
        result = (exists, round(size, 2) if size else None)
    except (OSError, PermissionError):
        result = (False, None)

    if cache is not None:
        cache[key] = result
    return result


# ============================================================================
# Bounded Directory Scanning
# ============================================================================
# Replaces unbounded rglob() with depth-limited scanning


def scan_videos_bounded(
    root: Path,
    extensions: set[str],
    max_depth: int = 3,
    limit: int = 1000,
) -> list[dict[str, Any]]:
    """
    Recursively scan for video files with bounded depth and early termination.

    Uses os.scandir for efficiency instead of rglob.

    Args:
        root: Root directory to scan
        extensions: Set of valid extensions (lowercase, with dot)
        max_depth: Maximum recursion depth
        limit: Maximum number of videos to return

    Returns:
        List of video info dicts with path, name, size_mb
    """
    import os

    videos: list[dict[str, Any]] = []

    def _scan(current: Path, depth: int) -> None:
        if depth > max_depth or len(videos) >= limit:
            return

        try:
            with os.scandir(current) as entries:
                for entry in entries:
                    if len(videos) >= limit:
                        return

                    try:
                        if entry.is_file(follow_symlinks=False):
                            suffix = Path(entry.name).suffix.lower()
                            if suffix in extensions:
                                stat = entry.stat()
                                videos.append(
                                    {
                                        "path": entry.path,
                                        "name": entry.name,
                                        "size_mb": round(stat.st_size / (1024 * 1024), 2),
                                    }
                                )
                        elif entry.is_dir(follow_symlinks=False):
                            _scan(Path(entry.path), depth + 1)
                    except (OSError, PermissionError):
                        continue
        except (OSError, PermissionError):
            pass

    _scan(root, 0)
    return videos


# ============================================================================
# Segments Cache Functions for Analysis
# ============================================================================


def load_dataset_segments(
    name: str, max_samples: int = 500
) -> tuple[dict[str, list[Any]], dict[str, str]]:
    """
    Load segments from a dataset for linguistic analysis.

    Uses cached dataset instances and segment caching for performance.
    Multiple analysis endpoints can share the same segment data without reloading.

    Returns:
        Tuple of (segments_by_file, signer_ids)
    """
    cache_key = (name, max_samples)
    current_time = time.time()

    # Check segment cache first (shared across analysis endpoints)
    with _segments_cache_lock:
        if cache_key in _segments_cache:
            segments_by_file, signer_ids, ts = _segments_cache[cache_key]
            if current_time - ts < SEGMENTS_CACHE_TTL:
                logger.debug(f"Using cached segments for {name} (max_samples={max_samples})")
                return segments_by_file, signer_ids
            # Expired, remove from cache
            del _segments_cache[cache_key]

    # Load fresh data
    dataset = get_cached_dataset(name, split="all")

    segments_by_file: dict[str, list[Any]] = {}  # type: ignore[no-redef]
    signer_ids: dict[str, str] = {}  # type: ignore[no-redef]

    for sample_id in dataset._index[:max_samples]:
        try:
            sample = dataset._load_sample(sample_id)
            if sample.segments:
                segments_by_file[sample.id] = sample.segments
                if sample.signer_id:
                    signer_ids[sample.id] = sample.signer_id
        except Exception as e:
            logger.debug(f"Failed to load sample {sample_id}: {e}")
            continue

    if not segments_by_file:
        raise HTTPException(400, f"No segments found in dataset: {name}")

    # Store in cache for reuse by other analysis endpoints
    with _segments_cache_lock:
        _segments_cache[cache_key] = (segments_by_file, signer_ids, current_time)
        logger.debug(f"Cached segments for {name} (max_samples={max_samples})")

    # Evict oldest entry if cache is too large
    _evict_oldest_cache_entry(_segments_cache, _segments_cache_lock, MAX_SEGMENTS_CACHE_SIZE)

    return segments_by_file, signer_ids


# Export cache variables for routers that need direct access
def get_vocabulary_cache() -> tuple[dict, threading.Lock, int]:
    """Get vocabulary cache, lock, and TTL for direct access."""
    return _vocabulary_cache, _vocabulary_cache_lock, VOCABULARY_CACHE_TTL


def get_segments_cache() -> tuple[dict, threading.Lock, int]:
    """Get segments cache, lock, and TTL for direct access."""
    return _segments_cache, _segments_cache_lock, SEGMENTS_CACHE_TTL
