"""
watcher.py - Watchdog-based directory monitoring with debouncing and a thread-safe event queue.

Key design decisions
--------------------
* A single :class:`WatchManager` is the central registry.  It holds all active
  :class:`watchdog.observers.Observer` instances and their corresponding
  :class:`DebouncedEventHandler` handlers.
* Events are coalesced over a configurable debounce window (default 500 ms) so
  that rapid-fire events (e.g. a single ``vim`` save that touches multiple
  inodes) are reported only once.
* The outbound queue is a plain :class:`collections.deque` protected by a
  threading lock, keeping the implementation dependency-light.
"""

from __future__ import annotations

import fnmatch
import threading
import time
import uuid
from collections import deque
from pathlib import Path
from typing import Any

from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer

# ---------------------------------------------------------------------------
# Public type alias
# ---------------------------------------------------------------------------

WatchEvent = dict[str, Any]
"""
A serialisable snapshot of a file-system event::

    {
        "watch_id": str,
        "event_type": str,       # "created" | "modified" | "deleted" | "moved"
        "src_path": str,
        "dest_path": str | None, # only for "moved" events
        "is_directory": bool,
        "timestamp": float,      # Unix epoch (seconds)
    }
"""

# ---------------------------------------------------------------------------
# Debouncing helper
# ---------------------------------------------------------------------------

_DEBOUNCE_SECONDS: float = 0.5


class _PendingEvent:
    """Tracks the latest version of a debounced event keyed by src_path."""

    __slots__ = ("event_dict", "due_at")

    def __init__(self, event_dict: WatchEvent, due_at: float) -> None:
        self.event_dict = event_dict
        self.due_at = due_at


# ---------------------------------------------------------------------------
# Watchdog event handler
# ---------------------------------------------------------------------------


class DebouncedEventHandler(FileSystemEventHandler):
    """A watchdog handler that debounces events and pushes them to a shared queue."""

    def __init__(
        self,
        watch_id: str,
        queue: deque[WatchEvent],
        queue_lock: threading.Lock,
        *,
        extensions: list[str] | None = None,
        pattern: str | None = None,
        ignore_patterns: list[str] | None = None,
        event_types: list[str] | None = None,
        debounce_seconds: float = _DEBOUNCE_SECONDS,
    ) -> None:
        super().__init__()
        self.watch_id = watch_id
        self._queue = queue
        self._queue_lock = queue_lock

        # Normalise extension list → lower-case, with leading dot
        self._extensions: list[str] | None = (
            [e.lower() if e.startswith(".") else f".{e.lower()}" for e in extensions]
            if extensions
            else None
        )
        self._pattern = pattern
        self._ignore_patterns: list[str] | None = ignore_patterns
        self._event_types: set[str] | None = (
            {et.lower() for et in event_types} if event_types else None
        )
        self._debounce_seconds = debounce_seconds

        # Pending debounce bucket: key = (event_type, src_path)
        self._pending: dict[tuple[str, str], _PendingEvent] = {}
        self._pending_lock = threading.Lock()

        # Background flusher
        self._stop_event = threading.Event()
        self._flusher = threading.Thread(target=self._flush_loop, daemon=True)
        self._flusher.start()

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _matches(self, path: str) -> bool:
        """Return True if *path* should be reported based on filters."""
        p = Path(path)

        # Extension filter
        if self._extensions is not None:
            if p.suffix.lower() not in self._extensions:
                return False

        # Glob pattern inclusion
        if self._pattern is not None:
            if not fnmatch.fnmatch(p.name, self._pattern):
                return False

        # Glob ignore patterns
        if self._ignore_patterns:
            for ig in self._ignore_patterns:
                if fnmatch.fnmatch(p.name, ig) or fnmatch.fnmatch(str(p), ig):
                    return False

        return True

    def _enqueue_raw(self, event: FileSystemEvent) -> None:
        """Convert a watchdog event to our dict format and debounce it."""
        event_type = event.event_type  # e.g. "created", "modified", "deleted", "moved"

        # Event-type filter
        if self._event_types is not None and event_type not in self._event_types:
            return

        src = str(event.src_path)

        if not self._matches(src):
            return

        dest = str(getattr(event, "dest_path", None) or "")  # only for moved

        event_dict: WatchEvent = {
            "watch_id": self.watch_id,
            "event_type": event_type,
            "src_path": src,
            "dest_path": dest if dest else None,
            "is_directory": event.is_directory,
            "timestamp": time.time(),
        }

        key = (event_type, src)
        due_at = time.monotonic() + self._debounce_seconds

        with self._pending_lock:
            self._pending[key] = _PendingEvent(event_dict, due_at)

    def _flush_loop(self) -> None:
        """Background thread: drain debounce bucket when events are due."""
        while not self._stop_event.is_set():
            now = time.monotonic()
            to_emit: list[WatchEvent] = []

            with self._pending_lock:
                due_keys = [k for k, v in self._pending.items() if v.due_at <= now]
                for k in due_keys:
                    to_emit.append(self._pending.pop(k).event_dict)

            if to_emit:
                with self._queue_lock:
                    self._queue.extend(to_emit)

            time.sleep(0.05)  # poll at 50 ms resolution

    def stop(self) -> None:
        """Signal the background flusher thread to stop."""
        self._stop_event.set()

    # ------------------------------------------------------------------
    # watchdog overrides
    # ------------------------------------------------------------------

    def on_created(self, event: FileSystemEvent) -> None:  # type: ignore[override]
        self._enqueue_raw(event)

    def on_modified(self, event: FileSystemEvent) -> None:  # type: ignore[override]
        self._enqueue_raw(event)

    def on_deleted(self, event: FileSystemEvent) -> None:  # type: ignore[override]
        self._enqueue_raw(event)

    def on_moved(self, event: FileSystemEvent) -> None:  # type: ignore[override]
        self._enqueue_raw(event)


# ---------------------------------------------------------------------------
# Central watch registry
# ---------------------------------------------------------------------------


class _WatchEntry:
    __slots__ = ("watch_id", "path", "recursive", "observer", "handler", "created_at")

    def __init__(
        self,
        watch_id: str,
        path: Path,
        recursive: bool,
        observer: Observer,
        handler: DebouncedEventHandler,
    ) -> None:
        self.watch_id = watch_id
        self.path = path
        self.recursive = recursive
        self.observer = observer
        self.handler = handler
        self.created_at = time.time()


class WatchManager:
    """Thread-safe registry of active directory watches."""

    def __init__(self) -> None:
        self._entries: dict[str, _WatchEntry] = {}
        self._lock = threading.Lock()

        # Shared event queue (all watches push into the same queue)
        self._queue: deque[WatchEvent] = deque()
        self._queue_lock = threading.Lock()

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

    def start_watch(
        self,
        path: Path,
        recursive: bool = False,
        extensions: list[str] | None = None,
        pattern: str | None = None,
        ignore_patterns: list[str] | None = None,
        event_types: list[str] | None = None,
    ) -> str:
        """
        Start watching *path* and return a unique watch ID.

        Parameters mirror the MCP tool arguments:

        * ``extensions`` – only report events for these file extensions, e.g. ``[".py", ".js"]``.
        * ``pattern`` – glob pattern matched against file *name*, e.g. ``"*.log"``.
        * ``ignore_patterns`` – glob patterns to *exclude*, e.g. ``["*.tmp", "__pycache__/*"]``.
        * ``event_types`` – subset of ``{"created", "modified", "deleted", "moved"}``.
        """
        with self._lock:
            if len(self._entries) >= MAX_WATCHES:
                raise RuntimeError(
                    f"Watch limit reached ({MAX_WATCHES} active watches). "
                    "Call unwatch() to remove an existing watch before adding a new one."
                )

        watch_id = str(uuid.uuid4())

        handler = DebouncedEventHandler(
            watch_id=watch_id,
            queue=self._queue,
            queue_lock=self._queue_lock,
            extensions=extensions,
            pattern=pattern,
            ignore_patterns=ignore_patterns,
            event_types=event_types,
        )

        observer = Observer()
        try:
            observer.schedule(handler, str(path), recursive=recursive)
            observer.start()
        except OSError as exc:
            # Clean up resources before propagating.
            handler.stop()
            try:
                observer.stop()
                observer.join(timeout=2)
            except Exception:
                pass
            raise PermissionError(
                f"Permission denied: cannot watch '{path}'. "
                "Check that the process has read access to the directory."
            ) from exc

        # Give the observer thread a moment to initialise, then verify it is
        # still alive.  On some systems (e.g. inotify fd exhaustion) the thread
        # can start and immediately crash without raising during observer.start().
        time.sleep(0.05)
        if not observer.is_alive():
            handler.stop()
            try:
                observer.stop()
                observer.join(timeout=2)
            except Exception:
                pass
            raise RuntimeError(
                f"Filesystem observer failed to start for '{path}'. "
                "The OS watcher backend may be unavailable or resource-exhausted."
            )

        entry = _WatchEntry(
            watch_id=watch_id,
            path=path,
            recursive=recursive,
            observer=observer,
            handler=handler,
        )

        with self._lock:
            self._entries[watch_id] = entry

        return watch_id

    def stop_watch(self, watch_id: str) -> bool:
        """
        Stop and remove a watch.  Returns ``True`` if the watch existed.
        """
        with self._lock:
            entry = self._entries.pop(watch_id, None)

        if entry is None:
            return False

        entry.handler.stop()
        entry.observer.stop()
        entry.observer.join(timeout=5)
        return True

    def poll_events(self, watch_id: str | None = None, max_events: int = 100) -> list[WatchEvent]:
        """
        Drain and return up to *max_events* events.

        If *watch_id* is given, only events for that watch are returned;
        un-matched events are put back on the queue.
        """
        with self._queue_lock:
            if watch_id is None:
                # Drain everything up to the limit
                events: list[WatchEvent] = []
                while self._queue and len(events) < max_events:
                    events.append(self._queue.popleft())
                return events
            else:
                # Partition: take matching, keep the rest
                matching: list[WatchEvent] = []
                remainder: list[WatchEvent] = []
                for ev in self._queue:
                    if ev["watch_id"] == watch_id and len(matching) < max_events:
                        matching.append(ev)
                    else:
                        remainder.append(ev)
                self._queue.clear()
                self._queue.extend(remainder)
                return matching

    def list_watches(self) -> list[dict[str, Any]]:
        """Return a summary of all active watches."""
        with self._lock:
            return [
                {
                    "watch_id": e.watch_id,
                    "path": str(e.path),
                    "recursive": e.recursive,
                    "created_at": e.created_at,
                }
                for e in self._entries.values()
            ]

    def stop_all(self) -> None:
        """Stop every active watch (called on server shutdown)."""
        with self._lock:
            ids = list(self._entries.keys())
        for wid in ids:
            self.stop_watch(wid)


# ---------------------------------------------------------------------------
# Module-level singleton used by server.py
# ---------------------------------------------------------------------------

#: Maximum number of concurrently active watches.  Prevents exhausting the
#: kernel's inotify watch quota (``fs.inotify.max_user_watches``) on Linux.
MAX_WATCHES: int = 50

manager = WatchManager()
