"""Simple file watcher utility without external dependencies."""

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import TYPE_CHECKING

from mindroom.logging_config import get_logger

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Iterable

logger = get_logger(__name__)
_WATCH_SCAN_INTERVAL_SECONDS = 1.0
_WATCH_TREE_DEBOUNCE_SECONDS = 1.0
_IGNORED_TREE_PARTS = {"__pycache__", ".ruff_cache", ".mypy_cache", ".pytest_cache", ".git"}
_IGNORED_TREE_SUFFIXES = (".pyc", ".pyo", ".swp", ".swo", "~", ".tmp")


def _is_relevant_path(path: Path) -> bool:
    """Return whether one tree entry should participate in change snapshots."""
    if not path.is_file():
        return False
    if any(part in _IGNORED_TREE_PARTS for part in path.parts):
        return False
    name = path.name
    return not (name.endswith(_IGNORED_TREE_SUFFIXES) or name.startswith(".#"))


def paths_mtime_snapshot(paths: Iterable[Path | str]) -> dict[Path, int]:
    """Return the current mtime snapshot for one dynamic set of files.

    Missing or unreadable files snapshot as ``0`` so
    :func:`changed_watched_paths` can apply its missing-file policy.
    """
    snapshot: dict[Path, int] = {}
    for raw_path in paths:
        path = Path(raw_path)
        try:
            snapshot[path] = path.stat().st_mtime_ns
        except OSError:
            snapshot[path] = 0
    return snapshot


def changed_watched_paths(previous: dict[Path, int], current: dict[Path, int]) -> list[Path]:
    """Return the watched files that changed between two mtime snapshots, sorted.

    Paths that entered the set are baselined silently, and missing files
    (mtime ``0``) wait until they reappear, so a momentarily absent file during
    an editor's delete-and-rename save cannot fire a change.
    """
    return sorted(path for path, mtime in current.items() if mtime != 0 and previous.get(path, mtime) != mtime)


def any_paths_newly_missing(previous: dict[Path, int], current: dict[Path, int]) -> bool:
    """Return whether any watched file vanished (mtime dropped to ``0``) since the last scan.

    A vanish is burst activity for quiescence purposes: it defers a pending
    callback so a delete-and-recreate window is not read mid-rename, but it
    never fires a callback by itself, and a file that stays missing stops
    counting after one scan so a permanent deletion cannot defer forever.
    """
    return any(mtime == 0 and previous.get(path, 0) != 0 for path, mtime in current.items())


async def watch_paths(
    paths_provider: Callable[[], Iterable[Path | str]],
    callback: Callable[[], Awaitable[None]],
    stop_event: asyncio.Event | None = None,
) -> None:
    """Watch a dynamic set of files and call callback when any of them changes.

    ``paths_provider`` is re-evaluated every scan so the watched set can grow or
    shrink between calls (e.g. config ``!include`` files after a reload). Change
    detection follows :func:`changed_watched_paths`.

    The callback fires only after a quiet scan: a scan that detects changes marks
    the watcher dirty, and the callback runs on the next scan with no new changes
    and no newly vanished files. Multi-file updates (git pull, rsync) thus land
    completely before a reload reads the tree, at the cost of one extra scan
    interval of latency.

    Each scan stats every watched file, so it runs in a worker thread rather than
    on the event loop.
    """
    last_snapshot = await asyncio.to_thread(paths_mtime_snapshot, paths_provider())
    dirty = False

    while stop_event is None or not stop_event.is_set():
        await asyncio.sleep(_WATCH_SCAN_INTERVAL_SECONDS)

        try:
            current_snapshot = await asyncio.to_thread(paths_mtime_snapshot, paths_provider())
            changed = bool(changed_watched_paths(last_snapshot, current_snapshot))
            vanished = any_paths_newly_missing(last_snapshot, current_snapshot)
            last_snapshot = current_snapshot
            if changed:
                dirty = True
            elif dirty and not vanished:
                dirty = False
                await callback()
        except Exception:
            # Don't let callback errors stop the watcher
            # The callback should handle its own errors
            logger.exception("Exception during file watcher callback - continuing to watch")


def _tree_snapshot(root_path: Path) -> dict[Path, int]:
    """Return the current mtime snapshot for one directory tree."""
    if not root_path.exists():
        return {}

    snapshot: dict[Path, int] = {}
    for path in root_path.rglob("*"):
        if not _is_relevant_path(path):
            continue
        try:
            snapshot[path] = path.stat().st_mtime_ns
        except (OSError, PermissionError):
            continue
    return snapshot


def _tree_changed_paths(previous: dict[Path, int], current: dict[Path, int]) -> set[Path]:
    """Return the set of paths added, removed, or modified since the last scan."""
    changed_paths = set(previous) ^ set(current)
    for path in set(previous) & set(current):
        if previous[path] != current[path]:
            changed_paths.add(path)
    return changed_paths
