"""Resource file watcher for real-time index synchronization.

Monitors filesystem paths associated with indexed resources using watchdog.
When file modifications are detected, the watcher debounces rapid changes
and triggers callbacks for incremental re-indexing.

Spec reference: Architecture > ACMS > Real-time Index Synchronization
  - Index Lifecycle: "Code changed → File modification detected →
    Immediate incremental update → Indices reflect latest state"
  - watchdog >= 4.0.0 (specification.md line ~41680)
  - ``index.auto-reindex`` config key (specification.md line ~28711)
"""

from __future__ import annotations

import threading
from collections.abc import Callable
from enum import StrEnum
from pathlib import Path
from typing import Any

import structlog
from watchdog.events import (
    FileCreatedEvent,
    FileDeletedEvent,
    FileModifiedEvent,
    FileMovedEvent,
    FileSystemEvent,
    FileSystemEventHandler,
)
from watchdog.observers import Observer  # type: ignore[import-untyped]
from watchdog.observers.polling import PollingObserver  # type: ignore[import-untyped]

from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.protocol import EventBus
from cleveragents.infrastructure.events.types import EventType

_logger = structlog.get_logger(__name__)


def _sanitize_log_value(value: str) -> str:
    """Replace control characters in *value* to prevent log injection."""
    return value.replace("\n", "\\n").replace("\r", "\\r")


class FileChangeType(StrEnum):
    """Type of file system change detected by the watcher."""

    CREATED = "created"
    MODIFIED = "modified"
    DELETED = "deleted"
    MOVED = "moved"


DEFAULT_DEBOUNCE_SECONDS: float = 0.5
"""Default debounce interval in seconds."""


class ResourceFileWatcher:
    """Watches filesystem paths for resource changes and triggers callbacks.

    Uses watchdog to monitor files associated with indexed resources.
    When a modification is detected, the watcher debounces rapid changes
    and invokes the registered callback with the affected resource details.
    Optionally emits ``RESOURCE_MODIFIED`` domain events via an EventBus.

    Parameters
    ----------
    on_change:
        Callback invoked when a watched resource file changes.
        Receives ``(resource_id, project, change_type)`` arguments.
    event_bus:
        Optional EventBus for emitting ``RESOURCE_MODIFIED`` events.
    debounce_seconds:
        Minimum interval between callback invocations for the same path.
        Rapid changes within this window are coalesced into a single
        callback.  Default 0.5 seconds.
    """

    def __init__(
        self,
        *,
        on_change: Callable[[str, str, FileChangeType], None] | None = None,
        event_bus: EventBus | None = None,
        debounce_seconds: float = DEFAULT_DEBOUNCE_SECONDS,
        auto_reindex: bool = True,
    ) -> None:
        if debounce_seconds < 0:
            msg = f"debounce_seconds must be >= 0, got {debounce_seconds}"
            raise ValueError(msg)
        self._on_change = on_change
        self._event_bus = event_bus
        self._debounce_seconds = debounce_seconds
        self._auto_reindex = auto_reindex
        self._observer: Observer | None = None  # type: ignore[valid-type]
        self._lock = threading.Lock()
        # resolved path (str) -> (resource_id, project)
        self._watched_paths: dict[str, tuple[str, str]] = {}
        # parent directory (str) -> watchdog ObservedWatch handle
        self._dir_watches: dict[str, Any] = {}
        # resolved path (str) -> pending debounce Timer
        self._pending_timers: dict[str, threading.Timer] = {}
        self._running = False
        self._using_polling = False

        _logger.debug(
            "file_watcher.initialized",
            debounce_seconds=debounce_seconds,
            auto_reindex=auto_reindex,
            has_callback=on_change is not None,
            has_event_bus=event_bus is not None,
        )

    # ------------------------------------------------------------------
    # Path management
    # ------------------------------------------------------------------

    def watch(self, path: Path, *, resource_id: str, project: str) -> None:
        """Register a file path for monitoring.

        Parameters
        ----------
        path:
            Filesystem path to watch.  Must exist and be a regular file.
        resource_id:
            The resource ID associated with this file.
        project:
            The project scope for this resource.

        Raises
        ------
        ValueError:
            If the path does not exist, is not a file, or arguments are
            empty.
        """
        if not resource_id or not resource_id.strip():
            msg = "resource_id must be non-empty"
            raise ValueError(msg)
        if not project or not project.strip():
            msg = "project must be non-empty"
            raise ValueError(msg)

        resolved = path.resolve()
        if not resolved.exists():
            msg = f"Path does not exist: {resolved}"
            raise ValueError(msg)
        if not resolved.is_file():
            msg = f"Path is not a file: {resolved}"
            raise ValueError(msg)

        resolved_str = str(resolved)
        parent_str = str(resolved.parent)

        with self._lock:
            self._watched_paths[resolved_str] = (resource_id, project)
            if (
                self._running
                and self._observer is not None
                and parent_str not in self._dir_watches
            ):
                handler = _ResourceChangeHandler(self)
                try:
                    watch_handle = self._observer.schedule(
                        handler,
                        parent_str,
                        recursive=False,
                    )
                except OSError as exc:
                    if not self._using_polling:
                        _logger.warning(
                            "file_watcher.watch_schedule_fallback",
                            error_type=type(exc).__name__,
                            error_message=str(exc),
                            fallback="polling_observer",
                        )
                        self._switch_to_polling_observer_locked()
                        watch_handle = self._observer.schedule(  # type: ignore[union-attr]
                            handler,
                            parent_str,
                            recursive=False,
                        )
                    else:
                        raise
                self._dir_watches[parent_str] = watch_handle

        _logger.debug(
            "file_watcher.path_registered",
            path=_sanitize_log_value(resolved_str),
            resource_id=_sanitize_log_value(resource_id),
            project=_sanitize_log_value(project),
        )

    def unwatch(self, path: Path) -> None:
        """Stop monitoring a file path.

        Parameters
        ----------
        path:
            The filesystem path to stop watching.
        """
        resolved = path.resolve()
        resolved_str = str(resolved)
        parent_str = str(resolved.parent)

        with self._lock:
            self._watched_paths.pop(resolved_str, None)
            timer = self._pending_timers.pop(resolved_str, None)
            if timer is not None:
                timer.cancel()
            # Unschedule directory if no more files in it are watched
            still_watching = any(
                str(Path(p).parent) == parent_str for p in self._watched_paths
            )
            if not still_watching and parent_str in self._dir_watches:
                if self._observer is not None:
                    self._observer.unschedule(self._dir_watches[parent_str])
                del self._dir_watches[parent_str]

        _logger.debug(
            "file_watcher.path_unregistered",
            path=_sanitize_log_value(resolved_str),
        )

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start the watchdog observer.

        If ``auto_reindex`` was set to ``False`` at construction
        (corresponding to ``index.auto-reindex = false`` in config),
        the observer is **not** started and a debug log is emitted.

        Raises
        ------
        RuntimeError:
            If the watcher is already running.
        """
        if not self._auto_reindex:
            _logger.info(
                "file_watcher.disabled",
                reason="index.auto-reindex is false",
            )
            return

        with self._lock:
            if self._running:
                msg = "File watcher is already running"
                raise RuntimeError(msg)
            observer = self._build_observer(use_polling=False)
            try:
                observer.start()
                self._using_polling = False
            except OSError as exc:
                _logger.warning(
                    "file_watcher.inotify_unavailable",
                    error_type=type(exc).__name__,
                    error_message=str(exc),
                    fallback="polling_observer",
                )
                observer = self._build_observer(use_polling=True)
                observer.start()
                self._using_polling = True
            self._observer = observer
            self._running = True

        _logger.info(
            "file_watcher.started",
            watched_paths=len(self._watched_paths),
            watched_dirs=len(self._dir_watches),
        )

    def _build_observer(self, *, use_polling: bool) -> Observer:  # type: ignore[valid-type]
        observer: Observer = (  # type: ignore[valid-type]
            PollingObserver() if use_polling else Observer()
        )
        observer.daemon = True
        self._dir_watches.clear()
        handler = _ResourceChangeHandler(self)
        seen_dirs: set[str] = set()
        for path_str in self._watched_paths:
            parent_str = str(Path(path_str).parent)
            if parent_str in seen_dirs:
                continue
            watch_handle = observer.schedule(
                handler,
                parent_str,
                recursive=False,
            )
            self._dir_watches[parent_str] = watch_handle
            seen_dirs.add(parent_str)
        return observer

    def _switch_to_polling_observer_locked(self) -> None:
        """Swap current observer to polling backend while lock is held."""
        old_observer = self._observer
        if old_observer is not None:
            old_observer.stop()
            old_observer.join(timeout=5.0)
        polling_observer = self._build_observer(use_polling=True)
        polling_observer.start()
        self._observer = polling_observer
        self._using_polling = True

    def stop(self) -> None:
        """Stop the watchdog observer and cancel pending timers."""
        observer: Observer | None = None  # type: ignore[valid-type]
        with self._lock:
            if not self._running:
                return
            for timer in self._pending_timers.values():
                timer.cancel()
            self._pending_timers.clear()
            observer = self._observer
            self._observer = None
            self._dir_watches.clear()
            self._running = False
            self._using_polling = False

        # Join outside lock to avoid deadlock with timer callbacks.
        if observer is not None:
            observer.stop()
            observer.join(timeout=5.0)
            if observer.is_alive():
                _logger.warning(
                    "file_watcher.observer_join_timeout",
                    hint="Observer thread did not stop within 5 seconds",
                )

        _logger.info("file_watcher.stopped")

    @property
    def is_running(self) -> bool:
        """Whether the watcher is currently active."""
        return self._running

    @property
    def watched_paths(self) -> dict[str, tuple[str, str]]:
        """Snapshot of watched paths mapped to ``(resource_id, project)``."""
        with self._lock:
            return dict(self._watched_paths)

    @property
    def debounce_seconds(self) -> float:
        """Current debounce interval."""
        return self._debounce_seconds

    @property
    def auto_reindex(self) -> bool:
        """Whether automatic re-indexing on file changes is enabled.

        Reflects the ``index.auto-reindex`` configuration key passed
        at construction time.
        """
        return self._auto_reindex

    # ------------------------------------------------------------------
    # Internal event handling
    # ------------------------------------------------------------------

    def _handle_fs_event(self, event: FileSystemEvent) -> None:
        """Process a watchdog filesystem event (called from observer thread)."""
        if event.is_directory:
            return

        src_path = str(Path(str(event.src_path)).resolve())

        if isinstance(event, FileCreatedEvent):
            change_type = FileChangeType.CREATED
        elif isinstance(event, FileModifiedEvent):
            change_type = FileChangeType.MODIFIED
        elif isinstance(event, FileDeletedEvent):
            change_type = FileChangeType.DELETED
        elif isinstance(event, FileMovedEvent):
            change_type = FileChangeType.MOVED
        else:
            return

        # Single lock acquisition covers lookup, path-move update, and
        # timer scheduling atomically — prevents races with unwatch().
        with self._lock:
            entry = self._watched_paths.get(src_path)
            if entry is None:
                return
            resource_id, project = entry

            # On move, update watched-paths so subsequent events on the
            # new location are still tracked for this resource.
            # Also schedule the destination's parent directory if it
            # isn't already being watched (fixes missed events when a
            # file moves to a new directory).
            dest_path: str | None = None
            if isinstance(event, FileMovedEvent):
                dest_path = str(Path(str(event.dest_path)).resolve())
                self._watched_paths.pop(src_path, None)
                self._watched_paths[dest_path] = (resource_id, project)
                dest_parent = str(Path(dest_path).parent)
                if (
                    self._running
                    and self._observer is not None
                    and dest_parent not in self._dir_watches
                ):
                    handler = _ResourceChangeHandler(self)
                    watch_handle = self._observer.schedule(
                        handler,
                        dest_parent,
                        recursive=False,
                    )
                    self._dir_watches[dest_parent] = watch_handle

            # Use dest_path as the timer key after a move so that
            # subsequent events on the new path can cancel/coalesce
            # the pending timer correctly.
            timer_key = dest_path if dest_path is not None else src_path
            existing = self._pending_timers.pop(src_path, None)
            if existing is not None:
                existing.cancel()
            # Also cancel any prior timer for dest_path (e.g. from a
            # rapid create-then-move sequence).
            if dest_path is not None:
                existing_dest = self._pending_timers.pop(dest_path, None)
                if existing_dest is not None:
                    existing_dest.cancel()
            timer = threading.Timer(
                self._debounce_seconds,
                self._fire_change,
                args=(timer_key, resource_id, project, change_type, dest_path),
            )
            timer.daemon = True
            self._pending_timers[timer_key] = timer
            timer.start()

        _logger.debug(
            "file_watcher.change_detected",
            path=_sanitize_log_value(src_path),
            resource_id=_sanitize_log_value(resource_id),
            change_type=str(change_type),
        )
        if dest_path is not None:
            _logger.debug(
                "file_watcher.path_moved",
                old_path=_sanitize_log_value(src_path),
                new_path=_sanitize_log_value(dest_path),
                resource_id=_sanitize_log_value(resource_id),
            )

    def _fire_change(
        self,
        path: str,
        resource_id: str,
        project: str,
        change_type: FileChangeType,
        dest_path: str | None = None,
    ) -> None:
        """Fire the change callback and/or EventBus event after debounce."""
        with self._lock:
            self._pending_timers.pop(path, None)
            if not self._running:
                return

        _logger.info(
            "file_watcher.change_fired",
            path=_sanitize_log_value(path),
            resource_id=_sanitize_log_value(resource_id),
            project=_sanitize_log_value(project),
            change_type=str(change_type),
        )

        if self._on_change is not None:
            try:
                self._on_change(resource_id, project, change_type)
            except Exception:
                _logger.exception(
                    "file_watcher.callback_error",
                    resource_id=_sanitize_log_value(resource_id),
                    project=_sanitize_log_value(project),
                )

        if self._event_bus is not None:
            try:
                # EventType has no RESOURCE_DELETED variant (out of PR
                # scope — infrastructure enum).  We emit
                # RESOURCE_MODIFIED for *all* change types and include
                # ``change_type`` in ``details`` so subscribers can
                # distinguish created/modified/deleted/moved events.
                details: dict[str, str] = {
                    "resource_id": resource_id,
                    "path": path,
                    "change_type": str(change_type),
                }
                if dest_path is not None:
                    details["dest_path"] = dest_path
                domain_event = DomainEvent(
                    event_type=EventType.RESOURCE_MODIFIED,
                    project_name=project,
                    details=details,
                )
                self._event_bus.emit(domain_event)
            except Exception:
                _logger.exception(
                    "file_watcher.event_bus_error",
                    resource_id=_sanitize_log_value(resource_id),
                )


class _ResourceChangeHandler(FileSystemEventHandler):
    """Watchdog handler that routes filesystem events to the watcher."""

    def __init__(self, watcher: ResourceFileWatcher) -> None:
        super().__init__()
        self._watcher = watcher

    def on_created(self, event: FileSystemEvent) -> None:
        """Handle file creation."""
        self._watcher._handle_fs_event(event)

    def on_modified(self, event: FileSystemEvent) -> None:
        """Handle file modification."""
        self._watcher._handle_fs_event(event)

    def on_deleted(self, event: FileSystemEvent) -> None:
        """Handle file deletion."""
        self._watcher._handle_fs_event(event)

    def on_moved(self, event: FileSystemEvent) -> None:
        """Handle file move."""
        self._watcher._handle_fs_event(event)


__all__ = [
    "DEFAULT_DEBOUNCE_SECONDS",
    "FileChangeType",
    "ResourceFileWatcher",
]
