"""Incremental Update Engine for RBSP.

Only changed files trigger re-indexing.  Uses file modification timestamps
and content hashes to detect changes.  Maintains a persistent state map
(path -> mtime, content_hash) to avoid redundant work across process restarts.

Thread-safe for concurrent updates.
"""

from __future__ import annotations

import json
import logging
import os
import threading
from pathlib import Path
from typing import Callable, Dict, List, Optional, Set, Tuple

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Change detection state
# ---------------------------------------------------------------------------

class _FileState:
    """Tracked state for a single file."""

    __slots__ = ("mtime", "size", "content_hash")

    def __init__(self, mtime: float, size: int, content_hash: str = "") -> None:
        self.mtime = mtime
        self.size = size
        self.content_hash = content_hash

    def to_dict(self) -> dict:
        return {"mtime": self.mtime, "size": self.size, "hash": self.content_hash}

    @classmethod
    def from_dict(cls, d: dict) -> "_FileState":
        return cls(mtime=d["mtime"], size=d["size"], content_hash=d.get("hash", ""))


# ---------------------------------------------------------------------------
# Incremental Engine
# ---------------------------------------------------------------------------

# Callback signature for re-index operations
IndexCallback = Callable[[str, bytes], None]
RemoveCallback = Callable[[str], None]


class IncrementalEngine:
    """Tracks file changes and triggers re-indexing only for modified content.

    The engine maintains a state map of ``(path -> mtime, size, content_hash)``
    and compares it against the filesystem to determine what needs re-indexing.
    State can be persisted to a JSON file so that incremental updates survive
    process restarts.

    Args:
        state_path: Optional path to a JSON file for persisting state.
                    If ``None``, state is kept in memory only.
        on_index:   Callback ``(path, content_bytes)`` invoked when a file
                    needs (re-)indexing.
        on_remove:  Callback ``(path)`` invoked when a file has been deleted.

    Usage::

        def index_file(path, content):
            sig = generator.generate(content)
            registry.add(sig_id, sig, source_path=path)

        engine = IncrementalEngine(
            state_path="/tmp/.rbsp_state.json",
            on_index=index_file,
            on_remove=lambda p: registry.remove_by_path(p),
        )
        engine.scan(["/path/to/src"])
    """

    def __init__(
        self,
        *,
        state_path: Optional[str] = None,
        on_index: Optional[IndexCallback] = None,
        on_remove: Optional[RemoveCallback] = None,
    ) -> None:
        self._state_path = state_path
        self._on_index = on_index
        self._on_remove = on_remove
        self._lock = threading.Lock()
        self._state: Dict[str, _FileState] = {}
        self._stats = _ScanStats()

        if state_path and os.path.exists(state_path):
            self._load_state()

    # ------------------------------------------------------------------
    # State persistence
    # ------------------------------------------------------------------

    def _load_state(self) -> None:
        """Load persisted state from disk."""
        try:
            with open(self._state_path, "r", encoding="utf-8") as f:
                data = json.load(f)
            self._state = {
                k: _FileState.from_dict(v) for k, v in data.items()
            }
            logger.info("Loaded incremental state: %d entries", len(self._state))
        except Exception:
            logger.warning("Failed to load state from %s, starting fresh", self._state_path)
            self._state = {}

    def save_state(self) -> None:
        """Persist current state to disk."""
        if self._state_path is None:
            return
        with self._lock:
            data = {k: v.to_dict() for k, v in self._state.items()}
        try:
            path = Path(self._state_path)
            path.parent.mkdir(parents=True, exist_ok=True)
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f)
            logger.debug("Saved incremental state: %d entries", len(data))
        except Exception:
            logger.exception("Failed to save incremental state")

    # ------------------------------------------------------------------
    # Change detection
    # ------------------------------------------------------------------

    def _needs_reindex(self, path: str) -> bool:
        """Check if *path* has changed since last index.

        Uses a two-stage check:
        1. Fast: compare mtime and size (O(1) stat call).
        2. If mtime/size match but content_hash is empty, assume changed
           (first run or hash not yet computed).
        """
        try:
            st = os.stat(path)
        except OSError:
            return False

        with self._lock:
            old = self._state.get(path)
            if old is None:
                return True  # New file
            if st.st_mtime != old.mtime or st.st_size != old.size:
                return True  # Changed
        return False

    def _compute_content_hash(self, content: bytes) -> str:
        """Fast non-cryptographic content hash for change detection."""
        import hashlib
        return hashlib.sha256(content).hexdigest()[:16]

    def _update_state(self, path: str, content: bytes) -> None:
        """Record the current state of *path* after indexing."""
        try:
            st = os.stat(path)
        except OSError:
            return
        content_hash = self._compute_content_hash(content)
        with self._lock:
            self._state[path] = _FileState(
                mtime=st.st_mtime,
                size=st.st_size,
                content_hash=content_hash,
            )

    def _remove_state(self, path: str) -> None:
        """Remove the tracked state for *path*."""
        with self._lock:
            self._state.pop(path, None)

    # ------------------------------------------------------------------
    # Single file operations
    # ------------------------------------------------------------------

    def process_file(self, path: str) -> bool:
        """Check and potentially re-index a single file.

        Returns:
            True if the file was (re-)indexed, False if skipped.
        """
        abs_path = os.path.abspath(path)
        if not self._needs_reindex(abs_path):
            self._stats.skipped += 1
            return False

        try:
            with open(abs_path, "rb") as f:
                content = f.read()
        except OSError:
            logger.warning("Cannot read %s, skipping", abs_path)
            self._stats.errors += 1
            return False

        if self._on_index:
            try:
                self._on_index(abs_path, content)
            except Exception:
                logger.exception("Index callback failed for %s", abs_path)
                self._stats.errors += 1
                return False

        self._update_state(abs_path, content)
        self._stats.indexed += 1
        return True

    def process_delete(self, path: str) -> None:
        """Handle deletion of a file."""
        abs_path = os.path.abspath(path)
        self._remove_state(abs_path)
        if self._on_remove:
            try:
                self._on_remove(abs_path)
            except Exception:
                logger.exception("Remove callback failed for %s", abs_path)
        self._stats.deleted += 1

    # ------------------------------------------------------------------
    # Batch scanning
    # ------------------------------------------------------------------

    def scan(
        self,
        paths: List[str],
        *,
        recursive: bool = True,
        patterns: Optional[List[str]] = None,
        progress_callback: Optional[Callable[[int, int], None]] = None,
    ) -> "_ScanStats":
        """Scan directories for changed files and trigger re-indexing.

        Args:
            paths:             Directories to scan.
            recursive:         Walk subdirectories (default True).
            patterns:          Glob patterns to filter files (e.g. ``["*.py"]``).
            progress_callback: Called with ``(processed, total)`` counts.

        Returns:
            Scan statistics.
        """
        import fnmatch

        self._stats = _ScanStats()

        # Collect all file paths
        all_files: List[str] = []
        for root in paths:
            root = os.path.abspath(root)
            if os.path.isfile(root):
                all_files.append(root)
                continue
            if not os.path.isdir(root):
                continue
            if recursive:
                for dirpath, _, filenames in os.walk(root):
                    for fname in filenames:
                        fpath = os.path.join(dirpath, fname)
                        if patterns and not any(fnmatch.fnmatch(fname, p) for p in patterns):
                            continue
                        all_files.append(fpath)
            else:
                try:
                    for entry in os.scandir(root):
                        if entry.is_file(follow_symlinks=False):
                            if patterns and not any(fnmatch.fnmatch(entry.name, p) for p in patterns):
                                continue
                            all_files.append(entry.path)
                except OSError:
                    pass

        total = len(all_files)
        self._stats.total = total
        logger.info("Incremental scan: %d files to check", total)

        # Process files
        for i, fpath in enumerate(all_files):
            self.process_file(fpath)
            if progress_callback and (i + 1) % 100 == 0:
                progress_callback(i + 1, total)

        # Detect deletions: files in state but no longer on disk
        current_files = set(all_files)
        with self._lock:
            tracked = set(self._state.keys())
        deleted = tracked - current_files
        for dpath in deleted:
            # Only consider deletions under scanned paths
            for root in paths:
                if dpath.startswith(os.path.abspath(root)):
                    self.process_delete(dpath)
                    break

        if progress_callback:
            progress_callback(total, total)

        self.save_state()
        logger.info(
            "Incremental scan complete: %d indexed, %d skipped, %d deleted, %d errors",
            self._stats.indexed, self._stats.skipped, self._stats.deleted, self._stats.errors,
        )
        return self._stats

    @property
    def stats(self) -> "_ScanStats":
        """Last scan statistics."""
        return self._stats

    @property
    def tracked_count(self) -> int:
        """Number of files currently tracked."""
        with self._lock:
            return len(self._state)

    def clear_state(self) -> None:
        """Clear all tracked state (forces full re-index on next scan)."""
        with self._lock:
            self._state.clear()
        if self._state_path and os.path.exists(self._state_path):
            try:
                os.remove(self._state_path)
            except OSError:
                pass


class _ScanStats:
    """Statistics for an incremental scan."""

    __slots__ = ("total", "indexed", "skipped", "deleted", "errors")

    def __init__(self) -> None:
        self.total: int = 0
        self.indexed: int = 0
        self.skipped: int = 0
        self.deleted: int = 0
        self.errors: int = 0

    def __repr__(self) -> str:
        return (
            f"ScanStats(total={self.total}, indexed={self.indexed}, "
            f"skipped={self.skipped}, deleted={self.deleted}, errors={self.errors})"
        )
