"""Unit tests for watcher module — events, FileWatcher debounce/dedup, IncrementalIndexer."""
from __future__ import annotations

import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from codexlens_search.watcher.events import ChangeType, FileEvent, WatcherConfig
from codexlens_search.watcher.incremental_indexer import BatchResult, IncrementalIndexer


# ---------------------------------------------------------------------------
# ChangeType enum
# ---------------------------------------------------------------------------

class TestChangeType:
    def test_values(self):
        assert ChangeType.CREATED.value == "created"
        assert ChangeType.MODIFIED.value == "modified"
        assert ChangeType.DELETED.value == "deleted"

    def test_all_members(self):
        assert len(ChangeType) == 3


# ---------------------------------------------------------------------------
# FileEvent
# ---------------------------------------------------------------------------

class TestFileEvent:
    def test_creation(self):
        e = FileEvent(path=Path("a.py"), change_type=ChangeType.CREATED)
        assert e.path == Path("a.py")
        assert e.change_type == ChangeType.CREATED
        assert isinstance(e.timestamp, float)

    def test_custom_timestamp(self):
        e = FileEvent(path=Path("b.py"), change_type=ChangeType.DELETED, timestamp=42.0)
        assert e.timestamp == 42.0


# ---------------------------------------------------------------------------
# WatcherConfig
# ---------------------------------------------------------------------------

class TestWatcherConfig:
    def test_defaults(self):
        cfg = WatcherConfig()
        assert cfg.debounce_ms == 500
        assert ".git" in cfg.ignored_patterns
        assert "__pycache__" in cfg.ignored_patterns
        assert "node_modules" in cfg.ignored_patterns

    def test_custom(self):
        cfg = WatcherConfig(debounce_ms=1000, ignored_patterns={".custom"})
        assert cfg.debounce_ms == 1000
        assert cfg.ignored_patterns == {".custom"}


# ---------------------------------------------------------------------------
# BatchResult
# ---------------------------------------------------------------------------

class TestBatchResult:
    def test_defaults(self):
        r = BatchResult()
        assert r.files_indexed == 0
        assert r.files_removed == 0
        assert r.chunks_created == 0
        assert r.errors == []

    def test_total_processed(self):
        r = BatchResult(files_indexed=3, files_removed=2)
        assert r.total_processed == 5

    def test_has_errors(self):
        r = BatchResult()
        assert r.has_errors is False
        r.errors.append("oops")
        assert r.has_errors is True


# ---------------------------------------------------------------------------
# IncrementalIndexer — event routing
# ---------------------------------------------------------------------------

class TestIncrementalIndexer:
    @pytest.fixture
    def mock_pipeline(self):
        pipeline = MagicMock()
        pipeline.index_file.return_value = MagicMock(
            files_processed=1, chunks_created=3
        )
        return pipeline

    def test_routes_created_to_index_file(self, mock_pipeline):
        indexer = IncrementalIndexer(mock_pipeline, root=Path("/project"))
        events = [
            FileEvent(Path("/project/src/new.py"), ChangeType.CREATED),
        ]
        result = indexer.process_events(events)
        assert result.files_indexed == 1
        mock_pipeline.index_file.assert_called_once()
        # CREATED should NOT use force=True
        call_kwargs = mock_pipeline.index_file.call_args
        assert call_kwargs.kwargs.get("force", call_kwargs[1].get("force")) is False

    def test_routes_modified_to_index_file_with_force(self, mock_pipeline):
        indexer = IncrementalIndexer(mock_pipeline, root=Path("/project"))
        events = [
            FileEvent(Path("/project/src/changed.py"), ChangeType.MODIFIED),
        ]
        result = indexer.process_events(events)
        assert result.files_indexed == 1
        call_kwargs = mock_pipeline.index_file.call_args
        assert call_kwargs.kwargs.get("force", call_kwargs[1].get("force")) is True

    def test_routes_deleted_to_remove_file(self, mock_pipeline, tmp_path):
        root = tmp_path / "project"
        root.mkdir()
        indexer = IncrementalIndexer(mock_pipeline, root=root)
        events = [
            FileEvent(root / "src" / "old.py", ChangeType.DELETED),
        ]
        result = indexer.process_events(events)
        assert result.files_removed == 1
        # On Windows relative_to produces backslashes, normalize
        actual_arg = mock_pipeline.remove_file.call_args[0][0]
        assert actual_arg.replace("\\", "/") == "src/old.py"

    def test_batch_with_mixed_events(self, mock_pipeline):
        indexer = IncrementalIndexer(mock_pipeline, root=Path("/project"))
        events = [
            FileEvent(Path("/project/a.py"), ChangeType.CREATED),
            FileEvent(Path("/project/b.py"), ChangeType.MODIFIED),
            FileEvent(Path("/project/c.py"), ChangeType.DELETED),
        ]
        result = indexer.process_events(events)
        assert result.files_indexed == 2
        assert result.files_removed == 1
        assert result.total_processed == 3

    def test_error_isolation(self, mock_pipeline):
        """One file failure should not stop processing of others."""
        call_count = [0]

        def side_effect(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] == 1:
                raise RuntimeError("disk error")
            return MagicMock(files_processed=1, chunks_created=1)

        mock_pipeline.index_file.side_effect = side_effect

        indexer = IncrementalIndexer(mock_pipeline, root=Path("/project"))
        events = [
            FileEvent(Path("/project/fail.py"), ChangeType.CREATED),
            FileEvent(Path("/project/ok.py"), ChangeType.CREATED),
        ]
        result = indexer.process_events(events)

        assert result.files_indexed == 1  # second succeeded
        assert len(result.errors) == 1  # first failed
        assert "disk error" in result.errors[0]

    def test_empty_events(self, mock_pipeline):
        indexer = IncrementalIndexer(mock_pipeline)
        result = indexer.process_events([])
        assert result.total_processed == 0
        mock_pipeline.index_file.assert_not_called()
        mock_pipeline.remove_file.assert_not_called()


# ---------------------------------------------------------------------------
# FileWatcher — debounce and dedup logic (unit-level, no actual FS)
# ---------------------------------------------------------------------------

class TestFileWatcherLogic:
    """Test FileWatcher internals without starting a real watchdog Observer."""

    @pytest.fixture
    def watcher_parts(self):
        """Create a FileWatcher with mocked observer, capture callbacks."""
        # Import here since watchdog is optional
        from codexlens_search.watcher.file_watcher import FileWatcher, _EVENT_PRIORITY

        collected = []

        def on_changes(events):
            collected.extend(events)

        cfg = WatcherConfig(debounce_ms=100)
        watcher = FileWatcher(Path("."), cfg, on_changes)
        return watcher, collected, _EVENT_PRIORITY

    def test_event_priority_ordering(self, watcher_parts):
        _, _, priority = watcher_parts
        assert priority[ChangeType.DELETED] > priority[ChangeType.MODIFIED]
        assert priority[ChangeType.MODIFIED] > priority[ChangeType.CREATED]

    def test_dedup_keeps_higher_priority(self, watcher_parts, tmp_path):
        watcher, collected, _ = watcher_parts
        f = str(tmp_path / "a.py")
        watcher._on_raw_event(f, ChangeType.CREATED)
        watcher._on_raw_event(f, ChangeType.DELETED)

        watcher.flush_now()

        assert len(collected) == 1
        assert collected[0].change_type == ChangeType.DELETED

    def test_dedup_does_not_downgrade(self, watcher_parts, tmp_path):
        watcher, collected, _ = watcher_parts
        f = str(tmp_path / "b.py")
        watcher._on_raw_event(f, ChangeType.DELETED)
        watcher._on_raw_event(f, ChangeType.CREATED)

        watcher.flush_now()
        assert len(collected) == 1
        # CREATED (priority 1) < DELETED (priority 3), so DELETED stays
        assert collected[0].change_type == ChangeType.DELETED

    def test_multiple_files_kept(self, watcher_parts, tmp_path):
        watcher, collected, _ = watcher_parts
        watcher._on_raw_event(str(tmp_path / "a.py"), ChangeType.CREATED)
        watcher._on_raw_event(str(tmp_path / "b.py"), ChangeType.MODIFIED)
        watcher._on_raw_event(str(tmp_path / "c.py"), ChangeType.DELETED)

        watcher.flush_now()
        assert len(collected) == 3
        paths = {str(e.path) for e in collected}
        assert len(paths) == 3

    def test_flush_clears_pending(self, watcher_parts, tmp_path):
        watcher, collected, _ = watcher_parts
        watcher._on_raw_event(str(tmp_path / "a.py"), ChangeType.CREATED)
        watcher.flush_now()
        assert len(collected) == 1

        collected.clear()
        watcher.flush_now()
        assert len(collected) == 0

    def test_should_watch_filters_ignored(self, watcher_parts):
        watcher, _, _ = watcher_parts
        assert watcher._should_watch(Path("/project/src/main.py")) is True
        assert watcher._should_watch(Path("/project/.git/config")) is False
        assert watcher._should_watch(Path("/project/node_modules/foo.js")) is False
        assert watcher._should_watch(Path("/project/__pycache__/mod.pyc")) is False

    def test_jsonl_serialization(self):
        from codexlens_search.watcher.file_watcher import FileWatcher
        import json

        events = [
            FileEvent(Path("/tmp/a.py"), ChangeType.CREATED, 1000.0),
            FileEvent(Path("/tmp/b.py"), ChangeType.DELETED, 2000.0),
        ]
        output = FileWatcher.events_to_jsonl(events)
        lines = output.strip().split("\n")
        assert len(lines) == 2

        obj1 = json.loads(lines[0])
        assert obj1["change_type"] == "created"
        assert obj1["timestamp"] == 1000.0

        obj2 = json.loads(lines[1])
        assert obj2["change_type"] == "deleted"
