"""
test_watcher.py - Tests for DebouncedEventHandler and WatchManager.

Handler unit tests use a short debounce window (50 ms) and synthetic watchdog
events so they run fast without a real filesystem Observer.

WatchManager integration tests spin up real Observer threads against tmp_path
directories.
"""

import sys
import threading
import time
from collections import deque
from pathlib import Path
from unittest.mock import MagicMock

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from watcher import DebouncedEventHandler, WatchManager  # noqa: E402

# Short debounce for fast tests
_FAST_DEBOUNCE = 0.05
# How long to wait for events to flush after triggering
_FLUSH_WAIT = _FAST_DEBOUNCE * 6  # 300 ms


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_handler(queue, lock, *, debounce=_FAST_DEBOUNCE, **kwargs) -> DebouncedEventHandler:
    return DebouncedEventHandler(
        watch_id="test-id",
        queue=queue,
        queue_lock=lock,
        debounce_seconds=debounce,
        **kwargs,
    )


def _fake_event(event_type: str, src_path: str, is_directory: bool = False):
    """Return a mock that quacks like a watchdog FileSystemEvent."""
    ev = MagicMock()
    ev.event_type = event_type
    ev.src_path = src_path
    ev.is_directory = is_directory
    ev.dest_path = None
    return ev


def _fake_moved_event(src: str, dest: str):
    ev = MagicMock()
    ev.event_type = "moved"
    ev.src_path = src
    ev.dest_path = dest
    ev.is_directory = False
    return ev


def _drain(queue, lock) -> list:
    with lock:
        items = list(queue)
        queue.clear()
    return items


def _wait_for(queue, lock, expect: int, timeout: float = 2.0) -> list:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        with lock:
            if len(queue) >= expect:
                items = list(queue)
                queue.clear()
                return items
        time.sleep(0.02)
    return _drain(queue, lock)


# ---------------------------------------------------------------------------
# DebouncedEventHandler._matches — filter unit tests (no Observer/filesystem)
# ---------------------------------------------------------------------------


class TestMatchesFilter:
    def test_no_filters_matches_anything(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        assert h._matches("/some/any/file.xyz")
        h.stop()

    def test_extension_filter_includes_matching(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=[".py"])
        assert h._matches("/path/to/script.py")
        h.stop()

    def test_extension_filter_excludes_nonmatching(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=[".py"])
        assert not h._matches("/path/to/readme.log")
        h.stop()

    def test_extension_normalises_missing_dot(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=["py"])  # no leading dot
        assert h._matches("/path/script.py")
        h.stop()

    def test_extension_case_insensitive(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=[".PY"])
        assert h._matches("/path/script.py")
        h.stop()

    def test_pattern_includes_matching(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, pattern="*.log")
        assert h._matches("/var/log/app.log")
        h.stop()

    def test_pattern_excludes_nonmatching(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, pattern="*.log")
        assert not h._matches("/var/log/app.py")
        h.stop()

    def test_ignore_pattern_excludes(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, ignore_patterns=["*.tmp"])
        assert not h._matches("/tmp/cache.tmp")
        h.stop()

    def test_ignore_pattern_allows_nonmatching(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, ignore_patterns=["*.tmp"])
        assert h._matches("/tmp/cache.py")
        h.stop()

    def test_extension_and_pattern_both_must_match(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=[".log"], pattern="app*")
        assert h._matches("/var/log/app.log")
        assert not h._matches("/var/log/error.log")  # pattern fails
        assert not h._matches("/var/log/app.py")  # extension fails
        h.stop()

    def test_ignore_overrides_extension(self, fresh_queue):
        """ignore_patterns exclude even when extension filter would include."""
        q, lock = fresh_queue
        h = _make_handler(q, lock, extensions=[".log"], ignore_patterns=["*.log"])
        assert not h._matches("/var/log/app.log")
        h.stop()


# ---------------------------------------------------------------------------
# DebouncedEventHandler — debounce / coalescing behaviour
# ---------------------------------------------------------------------------


class TestDebounceLogic:
    def test_single_event_appears_after_window(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h._enqueue_raw(_fake_event("created", "/tmp/a.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 1
        assert events[0]["event_type"] == "created"
        assert events[0]["src_path"] == "/tmp/a.txt"

    def test_rapid_same_key_coalesces_to_one(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        for _ in range(5):
            h._enqueue_raw(_fake_event("modified", "/tmp/b.txt"))
            time.sleep(0.005)
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 1

    def test_different_paths_both_appear(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h._enqueue_raw(_fake_event("created", "/tmp/x.txt"))
        h._enqueue_raw(_fake_event("created", "/tmp/y.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 2

    def test_different_event_types_both_appear(self, fresh_queue):
        """created + modified for the same path are distinct keys → both emitted."""
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h._enqueue_raw(_fake_event("created", "/tmp/z.txt"))
        h._enqueue_raw(_fake_event("modified", "/tmp/z.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 2

    def test_event_type_filter_drops_unwanted(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock, event_types=["created"])
        h._enqueue_raw(_fake_event("modified", "/tmp/m.txt"))
        h._enqueue_raw(_fake_event("created", "/tmp/m.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 1
        assert events[0]["event_type"] == "created"

    def test_moved_event_has_dest_path(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h._enqueue_raw(_fake_moved_event("/tmp/old.txt", "/tmp/new.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert len(events) == 1
        assert events[0]["dest_path"] == "/tmp/new.txt"

    def test_event_dict_has_required_fields(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h._enqueue_raw(_fake_event("created", "/tmp/fields.txt"))
        time.sleep(_FLUSH_WAIT)
        events = _drain(q, lock)
        h.stop()
        assert events, "expected at least one event"
        ev = events[0]
        for field in ("watch_id", "event_type", "src_path", "is_directory", "timestamp"):
            assert field in ev, f"missing field: {field}"

    def test_stop_prevents_further_flushing(self, fresh_queue):
        q, lock = fresh_queue
        h = _make_handler(q, lock)
        h.stop()
        h._enqueue_raw(_fake_event("created", "/tmp/stopped.txt"))
        time.sleep(_FLUSH_WAIT)
        # After stop the flusher thread exits; no events should be emitted
        events = _drain(q, lock)
        # (events *may* have been flushed before stop took effect in edge cases,
        #  so we just verify no crash; the stop() test is mostly for thread-safety)
        assert isinstance(events, list)


# ---------------------------------------------------------------------------
# WatchManager — integration tests (real filesystem)
# ---------------------------------------------------------------------------


class TestWatchManager:

    def test_start_watch_returns_uuid(self, tmp_watch_dir, fresh_manager):
        import uuid
        wid = fresh_manager.start_watch(tmp_watch_dir)
        assert uuid.UUID(wid)  # raises if not valid UUID

    def test_list_watches_empty_on_start(self, fresh_manager):
        assert fresh_manager.list_watches() == []

    def test_list_watches_shows_active_watch(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir, recursive=True)
        watches = fresh_manager.list_watches()
        assert len(watches) == 1
        assert watches[0]["watch_id"] == wid
        assert watches[0]["recursive"] is True
        assert Path(watches[0]["path"]) == tmp_watch_dir
        assert isinstance(watches[0]["created_at"], float)

    def test_stop_watch_returns_true_for_valid_id(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir)
        assert fresh_manager.stop_watch(wid) is True

    def test_stop_watch_returns_false_for_unknown_id(self, fresh_manager):
        assert fresh_manager.stop_watch("non-existent-id") is False

    def test_stop_watch_removes_from_list(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir)
        fresh_manager.stop_watch(wid)
        assert fresh_manager.list_watches() == []

    def test_stop_all_clears_registry(self, tmp_path, fresh_manager):
        d1 = tmp_path / "d1"; d1.mkdir()
        d2 = tmp_path / "d2"; d2.mkdir()
        fresh_manager.start_watch(d1)
        fresh_manager.start_watch(d2)
        fresh_manager.stop_all()
        assert fresh_manager.list_watches() == []

    def test_poll_events_empty_when_nothing_happened(self, tmp_watch_dir, fresh_manager):
        fresh_manager.start_watch(tmp_watch_dir)
        time.sleep(0.1)
        result = fresh_manager.poll_events()
        assert result == []

    def test_poll_events_receives_created_event(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir)
        time.sleep(0.2)
        (tmp_watch_dir / "hello.txt").write_text("hi")
        deadline = time.monotonic() + 3.0
        events = []
        while time.monotonic() < deadline:
            events = fresh_manager.poll_events(watch_id=wid)
            if events:
                break
            time.sleep(0.1)
        assert any(e["event_type"] == "created" for e in events)

    def test_poll_events_filtered_by_watch_id(self, tmp_path, fresh_manager):
        d1 = tmp_path / "d1"; d1.mkdir()
        d2 = tmp_path / "d2"; d2.mkdir()
        wid1 = fresh_manager.start_watch(d1)
        wid2 = fresh_manager.start_watch(d2)
        time.sleep(0.2)
        (d1 / "a.txt").write_text("a")
        (d2 / "b.txt").write_text("b")
        deadline = time.monotonic() + 3.0
        ev1, ev2 = [], []
        while time.monotonic() < deadline:
            ev1 = fresh_manager.poll_events(watch_id=wid1)
            ev2 = fresh_manager.poll_events(watch_id=wid2)
            if ev1 and ev2:
                break
            # put back so we can try again
            with fresh_manager._queue_lock:
                fresh_manager._queue.extend(ev1)
                fresh_manager._queue.extend(ev2)
            ev1, ev2 = [], []
            time.sleep(0.1)
        assert all(e["watch_id"] == wid1 for e in ev1)
        assert all(e["watch_id"] == wid2 for e in ev2)

    def test_poll_events_max_events_caps_result(self, tmp_watch_dir, fresh_manager):
        # Inject events directly into the queue
        wid = fresh_manager.start_watch(tmp_watch_dir)
        fake = [
            {"watch_id": wid, "event_type": "created", "src_path": f"/f{i}",
             "dest_path": None, "is_directory": False, "timestamp": 0.0}
            for i in range(10)
        ]
        with fresh_manager._queue_lock:
            fresh_manager._queue.extend(fake)
        result = fresh_manager.poll_events(watch_id=wid, max_events=3)
        assert len(result) == 3
        # Remaining 7 should still be in the queue
        remaining = fresh_manager.poll_events(watch_id=wid)
        assert len(remaining) == 7

    def test_recursive_watch_captures_subdir_events(self, tmp_watch_dir, fresh_manager):
        subdir = tmp_watch_dir / "sub"
        subdir.mkdir()
        wid = fresh_manager.start_watch(tmp_watch_dir, recursive=True)
        time.sleep(0.2)
        (subdir / "deep.txt").write_text("deep")
        deadline = time.monotonic() + 3.0
        events = []
        while time.monotonic() < deadline:
            events = fresh_manager.poll_events(watch_id=wid)
            if events:
                break
            time.sleep(0.1)
        assert len(events) >= 1

    def test_non_recursive_watch_misses_subdir(self, tmp_watch_dir, fresh_manager):
        subdir = tmp_watch_dir / "sub"
        subdir.mkdir()
        wid = fresh_manager.start_watch(tmp_watch_dir, recursive=False)
        time.sleep(0.2)
        (subdir / "deep.txt").write_text("deep")
        time.sleep(1.0)  # wait long enough to be confident
        events = fresh_manager.poll_events(watch_id=wid)
        sub_events = [e for e in events if "deep.txt" in e["src_path"]]
        assert sub_events == []

    def test_extension_filter_end_to_end(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir, extensions=[".py"])
        time.sleep(0.2)
        (tmp_watch_dir / "script.py").write_text("pass")
        (tmp_watch_dir / "readme.txt").write_text("nope")
        deadline = time.monotonic() + 3.0
        all_events = []
        while time.monotonic() < deadline:
            batch = fresh_manager.poll_events(watch_id=wid)
            all_events.extend(batch)
            if any(".py" in e["src_path"] for e in all_events):
                break
            time.sleep(0.1)
        assert any(".py" in e["src_path"] for e in all_events)
        assert not any(".txt" in e["src_path"] for e in all_events)

    def test_ignore_pattern_suppresses_tmp_files(self, tmp_watch_dir, fresh_manager):
        wid = fresh_manager.start_watch(tmp_watch_dir, ignore_patterns=["*.tmp"])
        time.sleep(0.2)
        (tmp_watch_dir / "ignored.tmp").write_text("ignore me")
        (tmp_watch_dir / "kept.txt").write_text("keep me")
        deadline = time.monotonic() + 3.0
        all_events = []
        while time.monotonic() < deadline:
            batch = fresh_manager.poll_events(watch_id=wid)
            all_events.extend(batch)
            if any(".txt" in e["src_path"] for e in all_events):
                break
            time.sleep(0.1)
        assert not any(".tmp" in e["src_path"] for e in all_events)
        assert any(".txt" in e["src_path"] for e in all_events)


# ---------------------------------------------------------------------------
# WatchManager — permission / OS-error handling
# ---------------------------------------------------------------------------


class TestWatchManagerPermissions:
    """
    Verify that OS-level permission errors from watchdog are surfaced correctly
    by WatchManager.start_watch().

    These tests use mocking so they run identically on Windows and Linux
    without requiring root, sudo, or chmod.
    """

    def test_start_watch_raises_permission_error_on_os_error(
        self, tmp_watch_dir, fresh_manager
    ):
        """
        When watchdog raises OSError (e.g. EACCES on Linux), start_watch must
        propagate a PermissionError with a human-readable message.
        """
        from unittest.mock import patch

        with patch(
            "watcher.Observer.start",
            side_effect=OSError("Permission denied"),
        ):
            with pytest.raises(PermissionError, match="Permission denied"):
                fresh_manager.start_watch(tmp_watch_dir)

    def test_failed_watch_not_registered(self, tmp_watch_dir, fresh_manager):
        """
        After a permission error the watch must NOT appear in list_watches().
        The registry must remain clean.
        """
        from unittest.mock import patch

        with patch(
            "watcher.Observer.start",
            side_effect=OSError("Permission denied"),
        ):
            with pytest.raises(PermissionError):
                fresh_manager.start_watch(tmp_watch_dir)

        assert fresh_manager.list_watches() == []

    def test_permission_error_message_contains_path(self, tmp_watch_dir, fresh_manager):
        """The raised PermissionError message should include the watched path."""
        from unittest.mock import patch

        with patch(
            "watcher.Observer.start",
            side_effect=OSError(13, "Permission denied"),
        ):
            with pytest.raises(PermissionError) as exc_info:
                fresh_manager.start_watch(tmp_watch_dir)

        assert str(tmp_watch_dir) in str(exc_info.value)

    def test_subsequent_watches_succeed_after_permission_error(
        self, tmp_watch_dir, fresh_manager
    ):
        """
        A permission error on one attempt must not corrupt the manager state —
        a subsequent valid call must still succeed.
        """
        from unittest.mock import patch

        # First call fails
        with patch(
            "watcher.Observer.start",
            side_effect=OSError("Permission denied"),
        ):
            with pytest.raises(PermissionError):
                fresh_manager.start_watch(tmp_watch_dir)

        # Second call (no mock) must succeed
        wid = fresh_manager.start_watch(tmp_watch_dir)
        assert wid
        assert len(fresh_manager.list_watches()) == 1


# ---------------------------------------------------------------------------
# Gap 2 — Observer thread dies silently after start
# ---------------------------------------------------------------------------


class TestObserverSilentDeath:
    """
    If the watchdog Observer thread starts successfully but dies immediately
    (e.g. inotify backend crash), start_watch must detect this and raise
    RuntimeError rather than returning a zombie watch_id.
    """

    def test_dead_observer_raises_runtime_error(self, tmp_watch_dir, fresh_manager):
        """Patch is_alive → False; start_watch must raise RuntimeError."""
        from unittest.mock import patch

        with patch("watcher.Observer.is_alive", return_value=False):
            with pytest.raises(RuntimeError, match="failed to start"):
                fresh_manager.start_watch(tmp_watch_dir)

    def test_dead_observer_not_registered(self, tmp_watch_dir, fresh_manager):
        """After detecting a dead observer the watch must NOT be in list_watches()."""
        from unittest.mock import patch

        with patch("watcher.Observer.is_alive", return_value=False):
            with pytest.raises(RuntimeError):
                fresh_manager.start_watch(tmp_watch_dir)

        assert fresh_manager.list_watches() == []

    def test_dead_observer_error_message_contains_path(
        self, tmp_watch_dir, fresh_manager
    ):
        """RuntimeError message must include the watched path for debuggability."""
        from unittest.mock import patch

        with patch("watcher.Observer.is_alive", return_value=False):
            with pytest.raises(RuntimeError) as exc_info:
                fresh_manager.start_watch(tmp_watch_dir)

        assert str(tmp_watch_dir) in str(exc_info.value)

    def test_alive_observer_success_unaffected(self, tmp_watch_dir, fresh_manager):
        """Sanity: when observer IS alive, start_watch still succeeds normally."""
        wid = fresh_manager.start_watch(tmp_watch_dir)
        assert wid
        assert len(fresh_manager.list_watches()) == 1


# ---------------------------------------------------------------------------
# Gap 3 — Concurrent watch limit
# ---------------------------------------------------------------------------


class TestWatchManagerLimit:
    """
    WatchManager must enforce a MAX_WATCHES cap and raise RuntimeError when
    exceeded, preventing inotify quota exhaustion on Linux.
    """

    def test_exceeding_limit_raises_runtime_error(self, tmp_path, fresh_manager):
        """Creating MAX_WATCHES+1 watches must raise RuntimeError on the last one."""
        from watcher import MAX_WATCHES

        # Fill up to the limit using a single real directory (saves OS resources)
        watch_dir = tmp_path / "watched"
        watch_dir.mkdir()
        for _ in range(MAX_WATCHES):
            fresh_manager.start_watch(watch_dir)

        with pytest.raises(RuntimeError, match="limit"):
            fresh_manager.start_watch(watch_dir)

    def test_at_limit_error_message_contains_limit_number(
        self, tmp_path, fresh_manager
    ):
        """Error must state the numeric limit so the agent can act on it."""
        from watcher import MAX_WATCHES

        watch_dir = tmp_path / "watched"
        watch_dir.mkdir()
        for _ in range(MAX_WATCHES):
            fresh_manager.start_watch(watch_dir)

        with pytest.raises(RuntimeError) as exc_info:
            fresh_manager.start_watch(watch_dir)

        assert str(MAX_WATCHES) in str(exc_info.value)

    def test_after_unwatch_new_watch_succeeds(self, tmp_path, fresh_manager):
        """After removing one watch below the limit, a new one must be accepted."""
        from watcher import MAX_WATCHES

        watch_dir = tmp_path / "watched"
        watch_dir.mkdir()

        wids = [fresh_manager.start_watch(watch_dir) for _ in range(MAX_WATCHES)]

        # Remove one — should now be under the limit
        fresh_manager.stop_watch(wids[0])

        # This must succeed without error
        new_wid = fresh_manager.start_watch(watch_dir)
        assert new_wid
        assert len(fresh_manager.list_watches()) == MAX_WATCHES
