"""
test_server.py - Functional tests for the four MCP tools in server.py.

Each test injects a fresh WatchManager via monkeypatch so that tests are
fully isolated and do not touch the module-level singleton.
"""

import sys
import time
from pathlib import Path
from unittest.mock import patch

import pytest

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

import server  # noqa: E402
from server import (  # noqa: E402
    list_active_watches,
    poll_events,
    unwatch,
    watch_directory,
)
from watcher import WatchManager  # noqa: E402


# ---------------------------------------------------------------------------
# Fixture: patch the module-level manager singleton with a fresh one per test
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def isolated_manager(monkeypatch, tmp_path):
    """Replace server.manager with a fresh WatchManager for each test."""
    mgr = WatchManager()
    monkeypatch.setattr(server, "manager", mgr)
    yield mgr
    mgr.stop_all()


# ---------------------------------------------------------------------------
# watch_directory
# ---------------------------------------------------------------------------


class TestWatchDirectory:
    def test_valid_path_returns_success(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir))
        assert result["success"] is True
        assert "watch_id" in result
        assert result["recursive"] is False
        assert Path(result["path"]) == tmp_watch_dir

    def test_recursive_flag_reflected_in_response(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), recursive=True)
        assert result["success"] is True
        assert result["recursive"] is True
        assert "(recursively)" in result["message"]

    def test_message_mentions_path(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir))
        assert str(tmp_watch_dir) in result["message"]

    def test_non_existent_path_returns_failure(self, tmp_path):
        result = watch_directory(str(tmp_path / "ghost"))
        assert result["success"] is False
        assert "error" in result

    def test_file_path_returns_failure(self, tmp_path):
        f = tmp_path / "file.txt"
        f.write_text("x")
        result = watch_directory(str(f))
        assert result["success"] is False
        assert "error" in result

    def test_blocked_root_returns_failure(self):
        result = watch_directory("C:\\")
        assert result["success"] is False
        assert "error" in result

    def test_invalid_event_type_returns_failure(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), event_types=["zoomed"])
        assert result["success"] is False
        assert "Invalid event_types" in result["error"]

    def test_mixed_valid_invalid_event_types_returns_failure(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), event_types=["created", "explode"])
        assert result["success"] is False

    def test_all_valid_event_types_accepted(self, tmp_watch_dir):
        result = watch_directory(
            str(tmp_watch_dir),
            event_types=["created", "modified", "deleted", "moved"],
        )
        assert result["success"] is True

    def test_extensions_filter_accepted(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), extensions=[".py", ".js"])
        assert result["success"] is True

    def test_pattern_accepted(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), pattern="*.log")
        assert result["success"] is True

    def test_ignore_patterns_accepted(self, tmp_watch_dir):
        result = watch_directory(str(tmp_watch_dir), ignore_patterns=["*.tmp", ".git/*"])
        assert result["success"] is True

    def test_multiple_watches_on_same_dir(self, tmp_watch_dir):
        r1 = watch_directory(str(tmp_watch_dir))
        r2 = watch_directory(str(tmp_watch_dir))
        assert r1["success"] and r2["success"]
        assert r1["watch_id"] != r2["watch_id"]


# ---------------------------------------------------------------------------
# poll_events
# ---------------------------------------------------------------------------


class TestPollEvents:
    def _inject(self, isolated_manager, watch_id, count=5):
        fake = [
            {"watch_id": watch_id, "event_type": "created", "src_path": f"/f{i}",
             "dest_path": None, "is_directory": False, "timestamp": 0.0}
            for i in range(count)
        ]
        with isolated_manager._queue_lock:
            isolated_manager._queue.extend(fake)

    def test_empty_queue_returns_zero_count(self):
        result = poll_events()
        assert result["count"] == 0
        assert result["events"] == []

    def test_returns_all_events_when_no_filter(self, tmp_watch_dir, isolated_manager):
        result = watch_directory(str(tmp_watch_dir))
        wid = result["watch_id"]
        self._inject(isolated_manager, wid, count=3)
        result = poll_events()
        assert result["count"] == 3

    def test_filtered_by_watch_id_only_returns_matching(self, tmp_path, isolated_manager):
        d1 = tmp_path / "d1"; d1.mkdir()
        d2 = tmp_path / "d2"; d2.mkdir()
        wid1 = watch_directory(str(d1))["watch_id"]
        wid2 = watch_directory(str(d2))["watch_id"]
        self._inject(isolated_manager, wid1, count=2)
        self._inject(isolated_manager, wid2, count=3)

        result = poll_events(watch_id=wid1)
        assert result["count"] == 2
        assert all(e["watch_id"] == wid1 for e in result["events"])

        # wid2 events should still be in queue
        result2 = poll_events(watch_id=wid2)
        assert result2["count"] == 3

    def test_max_events_caps_result(self, tmp_watch_dir, isolated_manager):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        self._inject(isolated_manager, wid, count=10)
        result = poll_events(max_events=4)
        assert result["count"] == 4
        remaining = poll_events()
        assert remaining["count"] == 6

    def test_consume_once_semantics(self, tmp_watch_dir, isolated_manager):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        self._inject(isolated_manager, wid, count=2)
        first = poll_events()
        second = poll_events()
        assert first["count"] == 2
        assert second["count"] == 0


# ---------------------------------------------------------------------------
# list_active_watches
# ---------------------------------------------------------------------------


class TestListActiveWatches:
    def test_no_watches_returns_empty(self):
        result = list_active_watches()
        assert result["count"] == 0
        assert result["watches"] == []

    def test_single_watch_listed(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        result = list_active_watches()
        assert result["count"] == 1
        assert result["watches"][0]["watch_id"] == wid

    def test_two_watches_listed(self, tmp_path):
        d1 = tmp_path / "d1"; d1.mkdir()
        d2 = tmp_path / "d2"; d2.mkdir()
        watch_directory(str(d1))
        watch_directory(str(d2))
        result = list_active_watches()
        assert result["count"] == 2

    def test_watch_fields_present(self, tmp_watch_dir):
        watch_directory(str(tmp_watch_dir))
        entry = list_active_watches()["watches"][0]
        assert "watch_id" in entry
        assert "path" in entry
        assert "recursive" in entry
        assert "created_at" in entry
        assert isinstance(entry["created_at"], float)


# ---------------------------------------------------------------------------
# unwatch
# ---------------------------------------------------------------------------


class TestUnwatch:
    def test_valid_watch_id_returns_success(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        result = unwatch(wid)
        assert result["success"] is True

    def test_double_unwatch_returns_failure(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        unwatch(wid)
        result = unwatch(wid)
        assert result["success"] is False
        assert "error" in result

    def test_unknown_watch_id_returns_failure(self):
        result = unwatch("completely-made-up-id")
        assert result["success"] is False
        assert "error" in result

    def test_unwatch_removes_from_list(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        unwatch(wid)
        watches = list_active_watches()["watches"]
        assert all(w["watch_id"] != wid for w in watches)

    def test_unwatch_watch_id_in_success_message(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        result = unwatch(wid)
        assert wid in result["message"]


# ---------------------------------------------------------------------------
# watch_directory — OS permission error handling (MCP tool layer)
# ---------------------------------------------------------------------------


class TestWatchDirectoryPermissions:
    """
    Verify that watch_directory() converts OS-level PermissionErrors into a
    clean {"success": false, "error": "..."} response instead of crashing.

    Mocking is used so the tests are platform-independent (pass on Windows
    and Linux without needing root / chmod).
    """

    def test_permission_error_returns_failure_response(self, tmp_watch_dir):
        """watch_directory must return success=False when the OS denies access."""
        with patch(
            "server.manager.start_watch",
            side_effect=PermissionError(
                "Permission denied: cannot watch '/protected'. "
                "Check that the process has read access to the directory."
            ),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert result["success"] is False
        assert "error" in result

    def test_permission_error_message_is_surfaced(self, tmp_watch_dir):
        """The error field must contain the permission-denial context."""
        with patch(
            "server.manager.start_watch",
            side_effect=PermissionError(
                "Permission denied: cannot watch '/protected'. "
                "Check that the process has read access to the directory."
            ),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert "Permission denied" in result["error"] or "permission" in result["error"].lower()

    def test_permission_error_leaves_no_watch_registered(self, tmp_watch_dir):
        """After a permission error no watch should appear in list_active_watches."""
        with patch(
            "server.manager.start_watch",
            side_effect=PermissionError("denied"),
        ):
            watch_directory(str(tmp_watch_dir))

        result = list_active_watches()
        assert result["count"] == 0

    def test_valid_watch_succeeds_after_permission_error(self, tmp_watch_dir):
        """
        A permission error on one call must not corrupt the server state —
        a subsequent valid call must still return success=True.
        """
        with patch(
            "server.manager.start_watch",
            side_effect=PermissionError("denied"),
        ):
            watch_directory(str(tmp_watch_dir))

        result = watch_directory(str(tmp_watch_dir))
        assert result["success"] is True
        assert "watch_id" in result


# ---------------------------------------------------------------------------
# Gap 1 — poll_events with unknown watch_id (ambiguous empty response)
# ---------------------------------------------------------------------------


class TestPollEventsUnknownWatchId:
    """
    poll_events(watch_id=<unknown>) must distinguish "watch doesn't exist"
    from "no events yet" so an AI agent doesn't spin forever on a stale id.
    """

    def test_unknown_watch_id_returns_warning_field(self):
        result = poll_events(watch_id="completely-unknown-id")
        assert "warning" in result, (
            "Expected a 'warning' key when watch_id is unknown, got: " + str(result)
        )

    def test_unknown_watch_id_count_is_zero(self):
        result = poll_events(watch_id="completely-unknown-id")
        assert result["count"] == 0

    def test_unknown_watch_id_events_is_empty(self):
        result = poll_events(watch_id="completely-unknown-id")
        assert result["events"] == []

    def test_unknown_watch_id_warning_mentions_id(self):
        bad_id = "stale-watch-99"
        result = poll_events(watch_id=bad_id)
        assert bad_id in result.get("warning", ""), (
            "Warning should mention the unknown id so the agent can self-correct"
        )

    def test_known_watch_id_has_no_warning(self, tmp_watch_dir):
        wid = watch_directory(str(tmp_watch_dir))["watch_id"]
        result = poll_events(watch_id=wid)
        assert "warning" not in result, "Valid watch_id must NOT produce a warning"

    def test_omitted_watch_id_has_no_warning(self):
        """poll_events() with no watch_id drains all — no warning expected."""
        result = poll_events()
        assert "warning" not in result


# ---------------------------------------------------------------------------
# Gap 2 — Observer thread dies silently after start_watch succeeds
# ---------------------------------------------------------------------------


class TestWatchDirectoryObserverDeath:
    """
    If the watchdog Observer thread dies immediately after start, the MCP tool
    must return success=False with a clear error rather than a zombie watch entry.
    """

    def test_dead_observer_returns_failure_response(self, tmp_watch_dir):
        with patch(
            "server.manager.start_watch",
            side_effect=RuntimeError(
                "Filesystem observer failed to start for '/tmp/x'. "
                "The OS watcher backend may be unavailable."
            ),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert result["success"] is False
        assert "error" in result

    def test_dead_observer_error_is_non_empty(self, tmp_watch_dir):
        with patch(
            "server.manager.start_watch",
            side_effect=RuntimeError("observer died"),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert result.get("error", "") != ""

    def test_dead_observer_leaves_no_watch_registered(self, tmp_watch_dir):
        with patch(
            "server.manager.start_watch",
            side_effect=RuntimeError("observer died"),
        ):
            watch_directory(str(tmp_watch_dir))

        assert list_active_watches()["count"] == 0


# ---------------------------------------------------------------------------
# Gap 3 — No concurrent watch limit (resource exhaustion)
# ---------------------------------------------------------------------------


class TestWatchDirectoryLimit:
    """
    When the concurrent watch limit is reached, watch_directory must return
    success=False with a clear message rather than letting the OS run out of
    inotify watches.
    """

    def test_at_limit_returns_failure_response(self, tmp_watch_dir):
        with patch(
            "server.manager.start_watch",
            side_effect=RuntimeError(
                "Watch limit reached (50 active watches). "
                "Call unwatch() to remove an existing watch."
            ),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert result["success"] is False
        assert "error" in result

    def test_at_limit_error_mentions_limit(self, tmp_watch_dir):
        with patch(
            "server.manager.start_watch",
            side_effect=RuntimeError("Watch limit reached (50 active watches)."),
        ):
            result = watch_directory(str(tmp_watch_dir))

        assert "limit" in result["error"].lower() or "50" in result["error"]


