"""Cross-Platform File System Watcher for RBSP.

Monitors directories for file create, modify, and delete events and triggers
callbacks.  Uses platform-native APIs where possible:

- **Windows**: ``ReadDirectoryChangesW`` via ctypes
- **Linux**: ``inotify`` via ctypes
- **macOS / fallback**: Polling-based watcher using ``os.stat``

Runs in a background daemon thread and supports configurable watch paths,
file filters (glob patterns), and recursive monitoring.
"""

from __future__ import annotations

import fnmatch
import logging
import os
import sys
import threading
import time
from enum import Enum, auto
from pathlib import Path
from typing import Callable, Dict, List, Optional, Set

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------

class FileEvent(Enum):
    """Type of file system event."""
    CREATED = auto()
    MODIFIED = auto()
    DELETED = auto()


# Callback signature: (event, absolute_path)
EventCallback = Callable[[FileEvent, str], None]


# ---------------------------------------------------------------------------
# Abstract watcher base
# ---------------------------------------------------------------------------

class _BaseWatcher:
    """Internal base for platform-specific watchers."""

    def __init__(
        self,
        paths: List[str],
        callback: EventCallback,
        patterns: Optional[List[str]] = None,
        recursive: bool = True,
        poll_interval: float = 1.0,
    ) -> None:
        self._paths = [os.path.abspath(p) for p in paths]
        self._callback = callback
        self._patterns = patterns or ["*"]
        self._recursive = recursive
        self._poll_interval = poll_interval
        self._stop_event = threading.Event()

    def _matches_filter(self, filepath: str) -> bool:
        """Check if *filepath* matches any configured glob pattern."""
        name = os.path.basename(filepath)
        return any(fnmatch.fnmatch(name, p) for p in self._patterns)

    def _emit(self, event: FileEvent, filepath: str) -> None:
        if self._matches_filter(filepath):
            try:
                self._callback(event, filepath)
            except Exception:
                logger.exception("Watcher callback error for %s on %s", event, filepath)

    def start(self) -> None:
        raise NotImplementedError

    def stop(self) -> None:
        self._stop_event.set()


# ---------------------------------------------------------------------------
# Polling watcher (cross-platform fallback)
# ---------------------------------------------------------------------------

class _PollingWatcher(_BaseWatcher):
    """Polling-based file watcher using os.scandir and stat timestamps.

    Works on all platforms.  Detects create, modify, delete by comparing
    snapshots of (path, mtime, size) between polling cycles.
    """

    def __init__(self, **kwargs) -> None:
        super().__init__(**kwargs)
        self._snapshot: Dict[str, float] = {}

    def _take_snapshot(self) -> Dict[str, float]:
        """Scan all watched paths and record (path -> mtime)."""
        snap: Dict[str, float] = {}
        for root in self._paths:
            if not os.path.isdir(root):
                continue
            if self._recursive:
                for dirpath, dirnames, filenames in os.walk(root):
                    for fname in filenames:
                        fpath = os.path.join(dirpath, fname)
                        try:
                            snap[fpath] = os.path.getmtime(fpath)
                        except OSError:
                            pass
            else:
                try:
                    for entry in os.scandir(root):
                        if entry.is_file(follow_symlinks=False):
                            try:
                                snap[entry.path] = entry.stat().st_mtime
                            except OSError:
                                pass
                except OSError:
                    pass
        return snap

    def start(self) -> None:
        """Begin polling loop (blocks until :meth:`stop` is called)."""
        self._snapshot = self._take_snapshot()
        while not self._stop_event.is_set():
            self._stop_event.wait(self._poll_interval)
            if self._stop_event.is_set():
                break
            new_snap = self._take_snapshot()
            # Detect changes
            old_keys = set(self._snapshot.keys())
            new_keys = set(new_snap.keys())
            # Created
            for p in new_keys - old_keys:
                self._emit(FileEvent.CREATED, p)
            # Deleted
            for p in old_keys - new_keys:
                self._emit(FileEvent.DELETED, p)
            # Modified
            for p in old_keys & new_keys:
                if new_snap[p] != self._snapshot[p]:
                    self._emit(FileEvent.MODIFIED, p)
            self._snapshot = new_snap


# ---------------------------------------------------------------------------
# Windows watcher (ReadDirectoryChangesW)
# ---------------------------------------------------------------------------

class _WindowsWatcher(_BaseWatcher):
    """Native Windows watcher using ReadDirectoryChangesW via ctypes."""

    # Action constants from Win32 API
    FILE_ACTION_ADDED = 0x00000001
    FILE_ACTION_REMOVED = 0x00000002
    FILE_ACTION_MODIFIED = 0x00000003
    FILE_ACTION_RENAMED_OLD = 0x00000004
    FILE_ACTION_RENAMED_NEW = 0x00000005

    _ACTION_MAP = {
        FILE_ACTION_ADDED: FileEvent.CREATED,
        FILE_ACTION_REMOVED: FileEvent.DELETED,
        FILE_ACTION_MODIFIED: FileEvent.MODIFIED,
        FILE_ACTION_RENAMED_NEW: FileEvent.CREATED,
        FILE_ACTION_RENAMED_OLD: FileEvent.DELETED,
    }

    def start(self) -> None:
        """Watch directories using ReadDirectoryChangesW."""
        import ctypes
        import ctypes.wintypes as wt
        import struct as st

        kernel32 = ctypes.windll.kernel32

        FILE_LIST_DIRECTORY = 0x0001
        OPEN_EXISTING = 3
        FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
        FILE_NOTIFY_CHANGE = (
            0x01 | 0x02 | 0x04 | 0x08 | 0x10  # name|dir|attr|size|write
        )
        INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value

        handles: List[int] = []
        for path in self._paths:
            h = kernel32.CreateFileW(
                path,
                FILE_LIST_DIRECTORY,
                0x01 | 0x02 | 0x04,  # share read|write|delete
                None,
                OPEN_EXISTING,
                FILE_FLAG_BACKUP_SEMANTICS,
                None,
            )
            if h == INVALID_HANDLE_VALUE:
                logger.warning("Cannot watch %s: invalid handle", path)
                continue
            handles.append((h, path))

        if not handles:
            logger.warning("No valid watch handles, falling back to polling")
            _PollingWatcher(
                paths=self._paths,
                callback=self._callback,
                patterns=self._patterns,
                recursive=self._recursive,
                poll_interval=self._poll_interval,
            ).start()
            return

        buf_size = 8192
        buf = ctypes.create_string_buffer(buf_size)
        bytes_returned = wt.DWORD()

        while not self._stop_event.is_set():
            for handle, root in handles:
                result = kernel32.ReadDirectoryChangesW(
                    handle,
                    buf,
                    buf_size,
                    self._recursive,
                    FILE_NOTIFY_CHANGE,
                    ctypes.byref(bytes_returned),
                    None,
                    None,
                )
                if not result:
                    continue

                offset = 0
                raw = buf.raw[: bytes_returned.value]
                while offset < len(raw):
                    next_off, action, name_len = st.unpack_from("<III", raw, offset)
                    name_start = offset + 12
                    name_bytes = raw[name_start : name_start + name_len]
                    try:
                        filename = name_bytes.decode("utf-16-le").rstrip("\x00")
                    except UnicodeDecodeError:
                        filename = ""
                    if filename:
                        full_path = os.path.join(root, filename)
                        event = self._ACTION_MAP.get(action)
                        if event:
                            self._emit(event, full_path)
                    if next_off == 0:
                        break
                    offset += next_off

        # Cleanup handles
        for handle, _ in handles:
            kernel32.CloseHandle(handle)


# ---------------------------------------------------------------------------
# Linux watcher (inotify)
# ---------------------------------------------------------------------------

class _LinuxWatcher(_BaseWatcher):
    """Native Linux watcher using inotify via ctypes."""

    # inotify event masks
    IN_CREATE = 0x00000100
    IN_DELETE = 0x00000200
    IN_MODIFY = 0x00000002
    IN_MOVED_TO = 0x00000080
    IN_MOVED_FROM = 0x00000040
    IN_ISDIR = 0x40000000

    _MASK_MAP = {
        IN_CREATE: FileEvent.CREATED,
        IN_MODIFY: FileEvent.MODIFIED,
        IN_DELETE: FileEvent.DELETED,
        IN_MOVED_TO: FileEvent.CREATED,
        IN_MOVED_FROM: FileEvent.DELETED,
    }

    def start(self) -> None:
        """Watch directories using inotify."""
        import ctypes
        import ctypes.util
        import struct as st
        import select

        libc_name = ctypes.util.find_library("c")
        if libc_name is None:
            logger.warning("libc not found, falling back to polling")
            _PollingWatcher(
                paths=self._paths,
                callback=self._callback,
                patterns=self._patterns,
                recursive=self._recursive,
                poll_interval=self._poll_interval,
            ).start()
            return

        libc = ctypes.CDLL(libc_name, use_errno=True)
        fd = libc.inotify_init1(0x00000800)  # IN_NONBLOCK
        if fd < 0:
            logger.warning("inotify_init1 failed, falling back to polling")
            _PollingWatcher(
                paths=self._paths,
                callback=self._callback,
                patterns=self._patterns,
                recursive=self._recursive,
                poll_interval=self._poll_interval,
            ).start()
            return

        mask = self.IN_CREATE | self.IN_DELETE | self.IN_MODIFY | self.IN_MOVED_TO | self.IN_MOVED_FROM
        wd_to_path: Dict[int, str] = {}

        def _add_watch(dirpath: str) -> None:
            wd = libc.inotify_add_watch(fd, dirpath.encode("utf-8"), mask)
            if wd >= 0:
                wd_to_path[wd] = dirpath

        for root in self._paths:
            if os.path.isdir(root):
                _add_watch(root)
                if self._recursive:
                    for dirpath, dirnames, _ in os.walk(root):
                        for d in dirnames:
                            _add_watch(os.path.join(dirpath, d))

        EVENT_SIZE = st.calcsize("iIII")
        buf_size = 4096

        try:
            while not self._stop_event.is_set():
                ready, _, _ = select.select([fd], [], [], self._poll_interval)
                if not ready:
                    continue
                try:
                    data = os.read(fd, buf_size)
                except OSError:
                    break
                offset = 0
                while offset + EVENT_SIZE <= len(data):
                    wd, emask, cookie, name_len = st.unpack_from("iIII", data, offset)
                    name_start = offset + EVENT_SIZE
                    name = data[name_start : name_start + name_len].rstrip(b"\x00").decode("utf-8", errors="replace")
                    offset = name_start + name_len

                    parent = wd_to_path.get(wd, "")
                    full_path = os.path.join(parent, name) if name else parent

                    # If a new directory is created and we're recursive, watch it
                    if (emask & self.IN_CREATE) and (emask & self.IN_ISDIR) and self._recursive:
                        _add_watch(full_path)

                    for flag, event in self._MASK_MAP.items():
                        if emask & flag:
                            self._emit(event, full_path)
                            break
        finally:
            os.close(fd)


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

class FileSystemWatcher:
    """Cross-platform file system watcher.

    Monitors one or more directory paths for file create, modify, and
    delete events.  Runs in a background daemon thread.

    Automatically selects the best backend for the current platform:

    - Windows: ``ReadDirectoryChangesW``
    - Linux: ``inotify``
    - Other: Polling fallback

    Args:
        paths:         List of directory paths to watch.
        callback:      Function called for each event: ``(FileEvent, path)``.
        patterns:      Glob patterns for filtering (e.g. ``["*.py", "*.txt"]``).
                       Default watches all files.
        recursive:     Watch subdirectories recursively (default True).
        poll_interval: Seconds between poll cycles for polling backend (default 1.0).

    Usage::

        def on_change(event, path):
            print(f"{event.name}: {path}")

        watcher = FileSystemWatcher(
            paths=["/src"],
            callback=on_change,
            patterns=["*.py"],
        )
        watcher.start()
        # ... later ...
        watcher.stop()
    """

    def __init__(
        self,
        paths: List[str],
        callback: EventCallback,
        *,
        patterns: Optional[List[str]] = None,
        recursive: bool = True,
        poll_interval: float = 1.0,
    ) -> None:
        self._paths = paths
        self._callback = callback
        self._patterns = patterns
        self._recursive = recursive
        self._poll_interval = poll_interval
        self._thread: Optional[threading.Thread] = None
        self._watcher: Optional[_BaseWatcher] = None

    def _create_backend(self) -> _BaseWatcher:
        kwargs = dict(
            paths=self._paths,
            callback=self._callback,
            patterns=self._patterns,
            recursive=self._recursive,
            poll_interval=self._poll_interval,
        )
        platform = sys.platform
        if platform == "win32":
            return _WindowsWatcher(**kwargs)
        elif platform == "linux":
            return _LinuxWatcher(**kwargs)
        else:
            return _PollingWatcher(**kwargs)

    def start(self) -> None:
        """Start watching in a background daemon thread."""
        if self._thread is not None and self._thread.is_alive():
            return
        self._watcher = self._create_backend()
        self._thread = threading.Thread(
            target=self._watcher.start,
            name="rbsp-file-watcher",
            daemon=True,
        )
        self._thread.start()
        logger.info(
            "FileSystemWatcher started (%s backend) watching %s",
            type(self._watcher).__name__,
            self._paths,
        )

    def stop(self, timeout: float = 5.0) -> None:
        """Stop the watcher and wait for the background thread to finish.

        Args:
            timeout: Maximum seconds to wait for thread shutdown.
        """
        if self._watcher is not None:
            self._watcher.stop()
        if self._thread is not None and self._thread.is_alive():
            self._thread.join(timeout=timeout)
        self._thread = None
        self._watcher = None
        logger.info("FileSystemWatcher stopped")

    @property
    def is_running(self) -> bool:
        """Whether the watcher thread is alive."""
        return self._thread is not None and self._thread.is_alive()
