from __future__ import annotations

import fnmatch
import os
import queue
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Iterator, Literal
from ._exceptions import WatcherStartupError, WatcherError

EventType = Literal["created", "modified", "deleted", "moved"]

try:
    from watchdog.events import FileSystemEventHandler
    from watchdog.observers import Observer
    _WATCHDOG_AVAILABLE = True
except Exception:
    FileSystemEventHandler = object  # type: ignore[assignment]
    Observer = None  # type: ignore[assignment]
    _WATCHDOG_AVAILABLE = False


@dataclass(frozen=True, slots=True)
class WatchEvent:
    type: EventType
    path: str
    is_directory: bool
    timestamp: float
    src_path: str | None = None
    dest_path: str | None = None


@dataclass(slots=True)
class WatcherConfig:
    recursive: bool = True
    debounce_seconds: float = 0.25
    batch_window_seconds: float = 0.15
    include_patterns: tuple[str, ...] = ("*",)
    exclude_patterns: tuple[str, ...] = ()
    ignore_directories: tuple[str, ...] = (
        ".git",
        "__pycache__",
        ".pytest_cache",
        "node_modules",
        ".venv",
        "venv",
    )
    emit_directories: bool = False
    allow_polling_fallback: bool = True
    polling_interval: float = 0.75
    queue_maxsize: int = 0

    def validate(self) -> None:
        if self.debounce_seconds < 0:
            raise ValueError("debounce_seconds must be >= 0")
        if self.batch_window_seconds < 0:
            raise ValueError("batch_window_seconds must be >= 0")
        if self.polling_interval <= 0:
            raise ValueError("polling_interval must be > 0")
        if not self.include_patterns:
            raise ValueError("include_patterns must not be empty")

class _EventFilter:
    def __init__(self, config: WatcherConfig) -> None:
        self.config = config

    def _matches_patterns(self, path: str) -> bool:
        name = os.path.basename(path)
        include_ok = any(fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(path, pat) for pat in self.config.include_patterns)
        if not include_ok:
            return False
        if any(fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(path, pat) for pat in self.config.exclude_patterns):
            return False
        return True

    def _has_ignored_dir(self, path: str) -> bool:
        parts = Path(path).parts
        ignored = set(self.config.ignore_directories)
        return any(part in ignored for part in parts)

    def accept(self, event: WatchEvent) -> bool:
        if self._has_ignored_dir(event.path):
            return False
        if event.is_directory and not self.config.emit_directories:
            return False
        return self._matches_patterns(event.path)


class _DebounceBatcher:
    def __init__(self, config: WatcherConfig) -> None:
        self.config = config
        self._last_seen: dict[tuple[str, EventType], float] = {}
        self._batch: list[WatchEvent] = []
        self._lock = threading.Lock()
        self._last_batch_flush = time.monotonic()

    def add(self, event: WatchEvent) -> None:
        now = time.monotonic()
        key = (event.path, event.type)

        with self._lock:
            last = self._last_seen.get(key)
            if last is not None and (now - last) < self.config.debounce_seconds:
                return

            self._last_seen[key] = now
            self._batch.append(event)

    def pop_ready_batch(self) -> list[WatchEvent]:
        with self._lock:
            if not self._batch:
                return []

            now = time.monotonic()
            if (now - self._last_batch_flush) < self.config.batch_window_seconds:
                return []

            batch = list(self._batch)
            self._batch.clear()
            self._last_batch_flush = now
            return batch

    def force_flush(self) -> list[WatchEvent]:
        with self._lock:
            batch = list(self._batch)
            self._batch.clear()
            self._last_batch_flush = time.monotonic()
            return batch


class _PollingSnapshot:
    def __init__(self, root: str, config: WatcherConfig) -> None:
        self.root = os.path.abspath(root)
        self.config = config

    def capture(self) -> dict[str, tuple[bool, float, int]]:
        state: dict[str, tuple[bool, float, int]] = {}

        for current_root, dirs, files in os.walk(self.root):
            dirs[:] = [d for d in dirs if d not in self.config.ignore_directories]

            if self.config.emit_directories:
                for d in dirs:
                    p = os.path.join(current_root, d)
                    try:
                        st = os.stat(p)
                        state[p] = (True, st.st_mtime, 0)
                    except OSError:
                        continue

            for name in files:
                p = os.path.join(current_root, name)
                try:
                    st = os.stat(p)
                    state[p] = (False, st.st_mtime, st.st_size)
                except OSError:
                    continue

            if not self.config.recursive:
                break

        return state


class _WatchdogHandler(FileSystemEventHandler):  # type: ignore[misc]
    def __init__(self, watcher: "FileWatcher") -> None:
        super().__init__()
        self.watcher = watcher

    def on_created(self, event) -> None:
        self.watcher._submit_event(
            WatchEvent(
                type="created",
                path=os.path.abspath(event.src_path),
                is_directory=bool(event.is_directory),
                timestamp=time.time(),
            )
        )

    def on_modified(self, event) -> None:
        self.watcher._submit_event(
            WatchEvent(
                type="modified",
                path=os.path.abspath(event.src_path),
                is_directory=bool(event.is_directory),
                timestamp=time.time(),
            )
        )

    def on_deleted(self, event) -> None:
        self.watcher._submit_event(
            WatchEvent(
                type="deleted",
                path=os.path.abspath(event.src_path),
                is_directory=bool(event.is_directory),
                timestamp=time.time(),
            )
        )

    def on_moved(self, event) -> None:
        self.watcher._submit_event(
            WatchEvent(
                type="moved",
                path=os.path.abspath(event.dest_path),
                is_directory=bool(event.is_directory),
                timestamp=time.time(),
                src_path=os.path.abspath(event.src_path),
                dest_path=os.path.abspath(event.dest_path),
            )
        )


class FileWatcher:
    def __init__(
        self,
        path: str | os.PathLike[str],
        *,
        on_events: Callable[[list[WatchEvent]], None] | None = None,
        config: WatcherConfig | None = None,
    ) -> None:
        self.path = os.path.abspath(os.fspath(path))
        self.on_events = on_events
        self.config = config or WatcherConfig()
        self.config.validate()

        if not os.path.exists(self.path):
            raise FileNotFoundError(self.path)

        self._filter = _EventFilter(self.config)
        self._batcher = _DebounceBatcher(self.config)
        self._queue: queue.Queue[WatchEvent] = queue.Queue(maxsize=self.config.queue_maxsize)
        self._stop_event = threading.Event()
        self._dispatch_thread: threading.Thread | None = None
        self._poll_thread: threading.Thread | None = None
        self._observer = None
        self._mode: Literal["kernel", "polling", "stopped"] = "stopped"
        self._started = False

    @property
    def mode(self) -> str:
        return self._mode

    def _submit_event(self, event: WatchEvent) -> None:
        if not self._filter.accept(event):
            return

        self._batcher.add(event)

        try:
            self._queue.put_nowait(event)
        except queue.Full:
            pass

    def _dispatch_loop(self) -> None:
        while not self._stop_event.is_set():
            batch = self._batcher.pop_ready_batch()
            if batch and self.on_events is not None:
                try:
                    self.on_events(batch)
                except Exception:
                    pass
            time.sleep(0.02)

        final_batch = self._batcher.force_flush()
        if final_batch and self.on_events is not None:
            try:
                self.on_events(final_batch)
            except Exception:
                pass

    def _start_kernel_mode(self) -> None:
        if not _WATCHDOG_AVAILABLE or Observer is None:
            raise WatcherStartupError("watchdog is not available")

        handler = _WatchdogHandler(self)
        observer = Observer()
        observer.schedule(handler, self.path, recursive=self.config.recursive)
        observer.start()

        self._observer = observer
        self._mode = "kernel"

    def _start_polling_mode(self) -> None:
        self._mode = "polling"
        self._poll_thread = threading.Thread(target=self._poll_loop, name="FileWatcherPolling", daemon=True)
        self._poll_thread.start()

    def _poll_loop(self) -> None:
        snapper = _PollingSnapshot(self.path, self.config)
        previous = snapper.capture()

        while not self._stop_event.is_set():
            time.sleep(self.config.polling_interval)
            current = snapper.capture()

            prev_paths = set(previous)
            curr_paths = set(current)

            created = curr_paths - prev_paths
            deleted = prev_paths - curr_paths
            common = prev_paths & curr_paths

            for path in created:
                is_dir, _, _ = current[path]
                self._submit_event(
                    WatchEvent(
                        type="created",
                        path=path,
                        is_directory=is_dir,
                        timestamp=time.time(),
                    )
                )

            for path in deleted:
                is_dir, _, _ = previous[path]
                self._submit_event(
                    WatchEvent(
                        type="deleted",
                        path=path,
                        is_directory=is_dir,
                        timestamp=time.time(),
                    )
                )

            for path in common:
                prev_is_dir, prev_mtime, prev_size = previous[path]
                cur_is_dir, cur_mtime, cur_size = current[path]

                if prev_is_dir != cur_is_dir:
                    self._submit_event(
                        WatchEvent(
                            type="modified",
                            path=path,
                            is_directory=cur_is_dir,
                            timestamp=time.time(),
                        )
                    )
                    continue

                if (prev_mtime != cur_mtime) or (prev_size != cur_size):
                    self._submit_event(
                        WatchEvent(
                            type="modified",
                            path=path,
                            is_directory=cur_is_dir,
                            timestamp=time.time(),
                        )
                    )

            previous = current

    def start(self) -> None:
        if self._started:
            return

        self._stop_event.clear()

        kernel_error: Exception | None = None
        if _WATCHDOG_AVAILABLE:
            try:
                self._start_kernel_mode()
            except Exception as e:
                kernel_error = e

        if self._mode == "stopped":
            if self.config.allow_polling_fallback:
                self._start_polling_mode()
            else:
                raise WatcherStartupError(
                    f"Kernel watcher startup failed and polling fallback is disabled: {kernel_error}"
                )

        self._dispatch_thread = threading.Thread(target=self._dispatch_loop, name="FileWatcherDispatch", daemon=True)
        self._dispatch_thread.start()
        self._started = True

    def stop(self, *, timeout: float = 5.0) -> None:
        if not self._started:
            return

        self._stop_event.set()

        if self._observer is not None:
            try:
                self._observer.stop()
                self._observer.join(timeout=timeout)
            except Exception:
                pass
            finally:
                self._observer = None

        if self._poll_thread is not None:
            self._poll_thread.join(timeout=timeout)
            self._poll_thread = None

        if self._dispatch_thread is not None:
            self._dispatch_thread.join(timeout=timeout)
            self._dispatch_thread = None

        self._mode = "stopped"
        self._started = False

    def __enter__(self) -> "FileWatcher":
        self.start()
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        self.stop()

    def get_event(self, *, timeout: float | None = None) -> WatchEvent:
        return self._queue.get(timeout=timeout)

    def iter_events(self, *, timeout: float = 0.5) -> Iterator[WatchEvent]:
        while self._started and not self._stop_event.is_set():
            try:
                yield self._queue.get(timeout=timeout)
            except queue.Empty:
                continue

def watch(
    path: str | os.PathLike[str],
    *,
    on_events: Callable[[list[WatchEvent]], None],
    config: WatcherConfig | None = None,
) -> FileWatcher:
    watcher = FileWatcher(path, on_events=on_events, config=config)
    watcher.start()
    return watcher