"""watcher 模块测试 — 重点测试分批处理和去抖逻辑"""

from __future__ import annotations

import json
import os
import sqlite3
import time
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from index1.watcher import (
    _DebouncedHandler,
    FileWatcher,
    DEBOUNCE_SECONDS,
    check_corpus_stale,
    ensure_watcher_running,
    _WATCHER_PID_FILE,
    _cleanup_watcher_pid,
)


# ── _DebouncedHandler 测试 ──


class TestFlushBatch:
    """测试 _flush() 分批处理逻辑"""

    def _make_handler(self):
        reindex = MagicMock()
        delete = MagicMock()
        handler = _DebouncedHandler(reindex, delete, base_dir=Path("/tmp/test"))
        return handler, reindex, delete

    def test_small_batch_all_processed(self):
        """少于 50 个文件，全部处理"""
        handler, reindex, delete = self._make_handler()

        for i in range(30):
            handler._pending[f"/tmp/test/file_{i}.py"] = ("reindex", time.monotonic())

        handler._flush()

        assert reindex.call_count == 30

    def test_exact_limit_all_processed(self):
        """恰好 50 个文件，全部处理"""
        handler, reindex, delete = self._make_handler()

        for i in range(50):
            handler._pending[f"/tmp/test/file_{i}.py"] = ("reindex", time.monotonic())

        handler._flush()

        assert reindex.call_count == 50

    def test_300_files_all_processed(self):
        """300 个文件全部分批处理，不丢弃任何一个"""
        handler, reindex, delete = self._make_handler()

        for i in range(300):
            handler._pending[f"/tmp/test/file_{i}.py"] = ("reindex", time.monotonic())

        handler._flush()

        assert reindex.call_count == 300
        # 验证 pending 被清空
        assert len(handler._pending) == 0

    def test_500_files_all_processed(self):
        """500 个文件（模拟 git checkout）全部处理"""
        handler, reindex, delete = self._make_handler()

        for i in range(500):
            handler._pending[f"/tmp/test/file_{i}.py"] = ("reindex", time.monotonic())

        handler._flush()

        assert reindex.call_count == 500

    def test_mixed_actions(self):
        """混合 reindex 和 delete 操作，全部正确分发"""
        handler, reindex, delete = self._make_handler()

        for i in range(200):
            action = "delete" if i % 3 == 0 else "reindex"
            handler._pending[f"/tmp/test/file_{i}.py"] = (action, time.monotonic())

        handler._flush()

        expected_deletes = len([i for i in range(200) if i % 3 == 0])
        expected_reindex = 200 - expected_deletes

        assert delete.call_count == expected_deletes
        assert reindex.call_count == expected_reindex
        assert delete.call_count + reindex.call_count == 200

    def test_single_failure_does_not_block_others(self):
        """单个文件处理失败不影响其他文件"""
        call_count = 0

        def flaky_reindex(path):
            nonlocal call_count
            call_count += 1
            if "file_5" in path:
                raise RuntimeError("模拟失败")

        handler = _DebouncedHandler(flaky_reindex, MagicMock(), base_dir=Path("/tmp/test"))

        for i in range(100):
            handler._pending[f"/tmp/test/file_{i}.py"] = ("reindex", time.monotonic())

        handler._flush()

        # file_5 失败但其他 99 个仍然被调用
        assert call_count == 100

    def test_empty_batch(self):
        """空 pending 不报错"""
        handler, reindex, delete = self._make_handler()
        handler._flush()
        assert reindex.call_count == 0
        assert delete.call_count == 0


class TestDebounce:
    """测试去抖逻辑"""

    def test_schedule_sets_timer(self):
        """_schedule() 会创建定时器"""
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp"))
        handler._schedule("/tmp/test.py", "reindex")

        assert handler._timer is not None
        assert "/tmp/test.py" in handler._pending
        handler._timer.cancel()

    def test_rapid_changes_debounced(self):
        """快速连续变更只触发一次 flush"""
        flush_count = 0
        original_flush = _DebouncedHandler._flush

        def counting_flush(self):
            nonlocal flush_count
            flush_count += 1
            original_flush(self)

        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp"))
        handler._flush = lambda: counting_flush(handler)

        # 快速触发 10 次
        for i in range(10):
            handler._schedule(f"/tmp/file_{i}.py", "reindex")

        # 等待 debounce 完成
        time.sleep(DEBOUNCE_SECONDS + 0.3)

        assert flush_count == 1
        assert len(handler._pending) == 0

    def test_duplicate_path_keeps_latest(self):
        """同一文件多次变更只保留最新的 action"""
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp"))

        handler._schedule("/tmp/test.py", "reindex")
        handler._schedule("/tmp/test.py", "delete")

        assert handler._pending["/tmp/test.py"][0] == "delete"
        handler._timer.cancel()


class TestShouldHandle:
    """测试文件过滤逻辑"""

    def test_indexable_extension(self):
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp"))
        assert handler._should_handle("/tmp/test.py") is True
        assert handler._should_handle("/tmp/test.md") is True
        assert handler._should_handle("/tmp/test.rs") is True

    def test_non_indexable_extension(self):
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp"))
        assert handler._should_handle("/tmp/test.png") is False
        assert handler._should_handle("/tmp/test.exe") is False
        assert handler._should_handle("/tmp/test.zip") is False

    def test_skip_dirs(self):
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp/project"))
        assert handler._should_handle("/tmp/project/node_modules/pkg/index.js") is False
        assert handler._should_handle("/tmp/project/.git/config") is False
        assert handler._should_handle("/tmp/project/__pycache__/mod.py") is False

    def test_normal_subdir(self):
        handler = _DebouncedHandler(MagicMock(), MagicMock(), base_dir=Path("/tmp/project"))
        assert handler._should_handle("/tmp/project/src/main.py") is True


class TestStopCancelsTimer:
    """stop() 应取消 pending debounce timer"""

    def test_stop_cancels_pending_flush(self):
        """stop 后 handler timer 应为 None，pending 清空，回调不触发"""
        reindex = MagicMock()
        delete = MagicMock()
        watcher = FileWatcher(
            paths=[Path("/tmp/test")],
            reindex_callback=reindex,
            delete_callback=delete,
        )
        # 手动注入一个 pending 事件
        watcher._handler._schedule("/tmp/test/foo.py", "reindex")
        assert watcher._handler._timer is not None

        watcher.stop()

        assert watcher._handler._timer is None
        assert len(watcher._handler._pending) == 0
        # 等待确保 flush 不会触发
        time.sleep(DEBOUNCE_SECONDS + 0.3)
        assert reindex.call_count == 0


class TestCompensateOrphans:
    """补偿扫描孤儿文档检测"""

    def test_orphan_detection_calls_delete(self, tmp_path):
        """DB 有但磁盘没有的文档应被删除"""
        reindex = MagicMock()
        delete = MagicMock()
        mock_db = MagicMock()
        mock_db.list_document_paths.return_value = ["exists.py", "orphan.py"]

        # 只在磁盘上创建 exists.py
        (tmp_path / "exists.py").write_text("x")

        watcher = FileWatcher(
            paths=[tmp_path],
            reindex_callback=reindex,
            delete_callback=delete,
            base_dir=tmp_path,
            db=mock_db,
        )
        watcher._compensate_missed_events(0)

        # orphan.py 不存在 → delete 被调用
        delete.assert_called_once_with("orphan.py")

    def test_no_db_skips_orphan_check(self, tmp_path):
        """无 db 参数时跳过孤儿检测"""
        delete = MagicMock()
        watcher = FileWatcher(
            paths=[tmp_path],
            reindex_callback=MagicMock(),
            delete_callback=delete,
            base_dir=tmp_path,
        )
        watcher._compensate_missed_events(0)
        delete.assert_not_called()


# ── #255: check_corpus_stale 测试 ──


class TestCheckCorpusStale:
    """corpus 过期检测"""

    def _make_db(self, tmp_path, indexed_at=None):
        """创建带 documents 表的临时 DB"""
        db_path = str(tmp_path / "test_index.db")
        conn = sqlite3.connect(db_path)
        conn.execute(
            "CREATE TABLE documents (id INTEGER PRIMARY KEY, indexed_at TEXT)"
        )
        if indexed_at:
            conn.execute(
                "INSERT INTO documents (indexed_at) VALUES (?)", (indexed_at,)
            )
        conn.commit()
        conn.close()
        return db_path

    def test_empty_db_is_stale(self, tmp_path):
        """空数据库（无文档）→ stale=True"""
        db_path = self._make_db(tmp_path)
        result = check_corpus_stale(db_path, threshold_seconds=3600)
        assert result["stale"] is True
        assert result["last_indexed"] is None

    def test_recent_index_not_stale(self, tmp_path):
        """刚索引过 → stale=False"""
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
        db_path = self._make_db(tmp_path, indexed_at=now)
        result = check_corpus_stale(db_path, threshold_seconds=3600)
        assert result["stale"] is False
        assert result["age_seconds"] is not None
        assert result["age_seconds"] < 10  # 刚写入

    def test_old_index_is_stale(self, tmp_path):
        """2 小时前索引 + 阈值 1 小时 → stale=True"""
        from datetime import datetime, timezone, timedelta
        old = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
        db_path = self._make_db(tmp_path, indexed_at=old)
        result = check_corpus_stale(db_path, threshold_seconds=3600)
        assert result["stale"] is True
        assert result["age_seconds"] > 7000

    def test_nonexistent_db_returns_stale(self, tmp_path):
        """数据库不存在 → stale=True（降级安全）"""
        result = check_corpus_stale(
            str(tmp_path / "nonexistent.db"), threshold_seconds=3600
        )
        assert result["stale"] is True

    def test_custom_threshold(self, tmp_path):
        """自定义阈值 — 10 秒前索引 + 阈值 5 秒 → stale"""
        from datetime import datetime, timezone, timedelta
        recent = (datetime.now(timezone.utc) - timedelta(seconds=10)).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
        db_path = self._make_db(tmp_path, indexed_at=recent)
        result = check_corpus_stale(db_path, threshold_seconds=5)
        assert result["stale"] is True
        result2 = check_corpus_stale(db_path, threshold_seconds=60)
        assert result2["stale"] is False


# ── #255: ensure_watcher_running 测试 ──


class TestEnsureWatcherRunning:
    """watcher 幂等启动"""

    def test_empty_watch_paths_returns_false(self):
        """watch_paths 为空 → 不启动"""
        config = MagicMock()
        config.watch_paths = []
        assert ensure_watcher_running(config) is False

    def test_no_valid_paths_returns_false(self, tmp_path):
        """watch_paths 中路径不存在 → 不启动"""
        config = MagicMock()
        config.watch_paths = [str(tmp_path / "nonexistent")]
        assert ensure_watcher_running(config) is False

    def test_no_index1_bin_returns_false(self, tmp_path):
        """index1 不在 PATH → 不启动"""
        config = MagicMock()
        config.watch_paths = [str(tmp_path)]
        with patch("index1.watcher._read_watcher_pid", return_value=None), \
             patch("shutil.which", return_value=None):
            assert ensure_watcher_running(config) is False

    def test_already_running_returns_true(self, tmp_path):
        """PID 文件指向存活进程 → 跳过启动"""
        config = MagicMock()
        config.watch_paths = [str(tmp_path)]

        # mock PID 文件读取 — 返回当前进程 PID（肯定存活）
        with patch(
            "index1.watcher._read_watcher_pid",
            return_value={"pid": os.getpid()},
        ), patch(
            "index1.watcher._is_watcher_process_alive",
            return_value=True,
        ):
            assert ensure_watcher_running(config) is True

    def test_stale_pid_cleanup_and_start(self, tmp_path):
        """PID 文件指向死进程 → 清理 + 尝试新启动"""
        config = MagicMock()
        config.watch_paths = [str(tmp_path)]

        mock_proc = MagicMock()
        mock_proc.poll.return_value = None  # 存活
        mock_proc.pid = 99999

        with patch(
            "index1.watcher._read_watcher_pid",
            return_value={"pid": 12345},
        ), patch(
            "index1.watcher._is_watcher_process_alive",
            return_value=False,
        ), patch(
            "index1.watcher._cleanup_watcher_pid",
        ) as mock_cleanup, patch(
            "shutil.which",
            return_value="/usr/local/bin/index1",
        ), patch(
            "subprocess.Popen",
            return_value=mock_proc,
        ), patch(
            "index1.watcher._write_watcher_pid",
        ):
            result = ensure_watcher_running(config)
            mock_cleanup.assert_called_once()
            assert result is True


# ── #255: hooks 集成测试 ──


class TestCorpusStaleHooksIntegration:
    """hooks awaken/orient 中的 corpus stale 检测"""

    def _make_handler(self, tmp_path, stale=True):
        """创建 HookHandler with mock db"""
        from index1.cognitive.hooks import HookHandler

        db_cog = MagicMock()
        db_cog.get_or_create_session.return_value = {"session_count": 1}
        db_cog.get_latest_brief.return_value = None
        db_cog.get_top_facts.return_value = []
        db_cog.get_worldview_facts.return_value = []
        db_cog.get_session_context.return_value = None
        db_cog.get_previous_session_record.return_value = "fake"
        db_cog.insert_raw_event.return_value = None

        config = MagicMock()
        config.web_auto_start = False
        config.observer_worker_enabled = False
        config.watcher_auto_start = False
        config.watcher_stale_threshold = 3600
        config.watch_paths = []

        mock_db = MagicMock()
        mock_db._db_path = str(tmp_path / "index.db")

        handler = HookHandler(db_cog, config, db=mock_db)
        return handler

    def test_awaken_writes_stale_marker(self, tmp_path):
        """awaken 检测到 stale → 写 .corpus_stale marker"""
        handler = self._make_handler(tmp_path)

        stale_info = {"stale": True, "last_indexed": None, "age_seconds": None}
        with patch(
            "index1.watcher.check_corpus_stale",
            return_value=stale_info,
        ) as mock_check, patch(
            "index1.cognitive.hooks.INDEX1_HOME",
            tmp_path,
        ):
            handler.awaken({"session_id": "test-session"})
            mock_check.assert_called_once()
            marker = tmp_path / ".corpus_stale"
            assert marker.exists()

    def test_awaken_clears_stale_marker_when_fresh(self, tmp_path):
        """awaken 检测到 fresh → 清除 .corpus_stale marker"""
        handler = self._make_handler(tmp_path)

        # 预写 marker
        marker = tmp_path / ".corpus_stale"
        marker.write_text("{}")

        fresh_info = {"stale": False, "last_indexed": "2026-02-16", "age_seconds": 100}
        with patch(
            "index1.watcher.check_corpus_stale",
            return_value=fresh_info,
        ), patch(
            "index1.cognitive.hooks.INDEX1_HOME",
            tmp_path,
        ):
            handler.awaken({"session_id": "test-session"})
            assert not marker.exists()

    def test_orient_injects_stale_warning(self, tmp_path):
        """orient 读到 .corpus_stale marker → 注入告警"""
        handler = self._make_handler(tmp_path)

        # 写 marker
        marker = tmp_path / ".corpus_stale"
        marker.write_text('{"stale": true}')

        with patch(
            "index1.cognitive.hooks.INDEX1_HOME",
            tmp_path,
        ):
            result = handler.orient({"prompt": "test query", "session_id": "s1"})
            output = result.get("output", "")
            assert "Corpus stale" in output

    def test_orient_no_warning_without_marker(self, tmp_path):
        """orient 无 marker → 无告警"""
        handler = self._make_handler(tmp_path)

        with patch(
            "index1.cognitive.hooks.INDEX1_HOME",
            tmp_path,
        ):
            result = handler.orient({"prompt": "test query", "session_id": "s1"})
            output = result.get("output", "")
            assert "Corpus stale" not in output
