"""Observer Worker 测试（#247）

覆盖:
- HTTP 端点（POST /observe, GET /health, POST /shutdown）
- 并行消费（N consumer 线程 + 活动追踪 + 空闲关闭）
- 队列处理（成功/失败）
- 幂等启动（ensure_observer_running）
- hooks.py 三级降级（Worker / API key / 通知）
"""

from __future__ import annotations

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

import pytest


# ── HTTP 端点测试 ──────────────────────────────────────────────────

class TestObserverWorkerApp:
    """Flask app 端点测试"""

    @pytest.fixture
    def app(self, tmp_path):
        """创建 test app（mock observer + mock SessionManager）"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer") as mock_create, \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
                observer_worker_port=6889,
            )

            mock_provider = MagicMock()
            mock_provider.name = "test_provider"
            mock_observer = MagicMock()
            mock_observer.providers = [mock_provider]
            mock_create.return_value = mock_observer

            # CogDatabase mock — 返回可 JSON 序列化的值
            mock_db = MockDB.return_value
            mock_db.count_pending_observations.return_value = 0
            mock_db.enqueue_observation.return_value = 1

            # SessionManager mock — 防止测试 spawn 真实 claude 进程
            mock_sm_instance = MagicMock()
            mock_sm_instance.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm_instance

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            app.config["TESTING"] = True
            yield app

    @pytest.fixture
    def client(self, app):
        return app.test_client()

    def test_health_endpoint(self, client):
        """GET /health 返回状态（含并发 + 空闲信息）"""
        resp = client.get("/health")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "ok"
        assert "queue_depth" in data
        assert "processed" in data
        assert "providers" in data
        # 新增：并发 + 空闲字段
        assert "consumers" in data
        assert data["consumers"] > 0
        assert "active_sessions" in data
        assert "idle_seconds" in data
        assert "idle_timeout" in data

    def test_observe_valid_payload(self, client):
        """POST /observe 正常 payload → 202"""
        payload = {
            "tool_name": "Edit",
            "tool_input": {"file_path": "test.py", "old_string": "a", "new_string": "b"},
            "tool_output": "File updated",
            "collection": "test-project",
            "session_id": "sess-123",
        }
        resp = client.post("/observe", json=payload)
        assert resp.status_code == 202
        data = resp.get_json()
        assert data["status"] == "queued"

    def test_observe_missing_field(self, client):
        """POST /observe 缺字段 → 400"""
        payload = {"tool_name": "Edit"}  # 缺 tool_input 等
        resp = client.post("/observe", json=payload)
        assert resp.status_code == 400

    def test_observe_invalid_json(self, client):
        """POST /observe 非 JSON → 400"""
        resp = client.post("/observe", data="not json", content_type="text/plain")
        assert resp.status_code == 400

    def test_health_no_observer(self, tmp_path):
        """observer=None 时 health 返回 no_observer"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer", return_value=None), \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            MockDB.return_value.count_pending_observations.return_value = 0
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
            )
            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            app.config["TESTING"] = True
            client = app.test_client()

            resp = client.get("/health")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "no_observer"

    def test_observe_tracks_session(self, client):
        """POST /observe 追踪 session_id → /health 可见"""
        payload = {
            "tool_name": "Edit",
            "tool_input": {"file_path": "test.py"},
            "tool_output": "ok",
            "collection": "test-project",
            "session_id": "sess-abc-123",
        }
        resp = client.post("/observe", json=payload)
        assert resp.status_code == 202

        health = client.get("/health").get_json()
        assert health["active_sessions"] >= 1

    def test_observe_updates_last_activity(self, client):
        """POST /observe 更新 last_activity → idle_seconds 重置"""
        # 先检查当前 idle
        h1 = client.get("/health").get_json()
        idle_before = h1["idle_seconds"]

        # 发送 observe
        payload = {
            "tool_name": "Edit",
            "tool_input": {},
            "tool_output": "ok",
            "collection": "test",
        }
        client.post("/observe", json=payload)

        # idle 应该被重置到接近 0
        h2 = client.get("/health").get_json()
        assert h2["idle_seconds"] <= 1.0

    def test_observe_no_observer_returns_503(self, tmp_path):
        """observer=None 时 POST /observe → 503"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer", return_value=None), \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            MockDB.return_value.count_pending_observations.return_value = 0
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
            )
            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            app.config["TESTING"] = True
            client = app.test_client()

            payload = {
                "tool_name": "Edit",
                "tool_input": {},
                "tool_output": "ok",
                "collection": "test",
            }
            resp = client.post("/observe", json=payload)
            assert resp.status_code == 503


# ── 并行消费 + 空闲关闭测试 ────────────────────────────────────────

class TestParallelConsumers:
    """并行 consumer 线程 + 空闲自动关闭"""

    def test_multiple_consumers_started(self, tmp_path):
        """多个 consumer 线程被启动"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer") as mock_create, \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            MockDB.return_value.count_pending_observations.return_value = 0
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
                observer_worker_port=6889,
            )

            mock_provider = MagicMock()
            mock_provider.name = "test_provider"
            mock_observer = MagicMock()
            mock_observer.providers = [mock_provider]
            mock_create.return_value = mock_observer

            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            client = app.test_client()

            # /health 应报告 consumers（配置值，不依赖真实线程）
            data = client.get("/health").get_json()
            assert data["consumers"] >= 1
            # cpu_count() 通常 >= 4
            import os as _os
            expected = _os.cpu_count() or 4
            assert data["consumers"] == expected

    def test_session_tracking_cleanup(self, tmp_path):
        """过期 session 自动清理"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer") as mock_create, \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            MockDB.return_value.count_pending_observations.return_value = 0
            MockDB.return_value.enqueue_observation.return_value = 1
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
                observer_worker_port=6889,
                observer_worker_idle_timeout=2.0,  # 2s for fast test
            )

            _p = MagicMock()
            _p.name = "test_provider"
            mock_observer = MagicMock()
            mock_observer.providers = [_p]
            mock_create.return_value = mock_observer

            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            client = app.test_client()

            # 发送 session-1
            p1 = {
                "tool_name": "Edit", "tool_input": {},
                "tool_output": "ok", "collection": "test",
                "session_id": "sess-old",
            }
            client.post("/observe", json=p1)
            h1 = client.get("/health").get_json()
            assert h1["active_sessions"] >= 1

            # 等待超过 idle_timeout 让 session 过期
            time.sleep(2.5)

            # 发送新 session → 旧的应被清理
            p2 = {
                "tool_name": "Edit", "tool_input": {},
                "tool_output": "ok", "collection": "test",
                "session_id": "sess-new",
            }
            client.post("/observe", json=p2)
            h2 = client.get("/health").get_json()
            # 只剩 1 个活跃 session
            assert h2["active_sessions"] == 1

    def test_idle_timeout_in_health(self, tmp_path):
        """health 端点显示 idle_timeout 配置"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer") as mock_create, \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM:

            MockDB.return_value.count_pending_observations.return_value = 0
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
                observer_worker_port=6889,
                observer_worker_idle_timeout=600.0,
            )

            _p = MagicMock()
            _p.name = "test_provider"
            mock_observer = MagicMock()
            mock_observer.providers = [_p]
            mock_create.return_value = mock_observer

            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            client = app.test_client()

            data = client.get("/health").get_json()
            assert data["idle_timeout"] == 600.0

    def test_shutdown_stops_all_consumers(self, tmp_path):
        """POST /shutdown 停止所有 consumer 线程"""
        with patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.cognitive.observer.create_observer") as mock_create, \
             patch("index1.config.load_config") as mock_config, \
             patch("index1.cognitive.observer.claude_session.SessionManager") as MockSM, \
             patch("os.kill") as mock_kill:

            MockDB.return_value.count_pending_observations.return_value = 0
            mock_config.return_value = MagicMock(
                cognitive_db_path=str(tmp_path / "cog.db"),
                observer_worker_port=6889,
            )

            _p = MagicMock()
            _p.name = "test_provider"
            mock_observer = MagicMock()
            mock_observer.providers = [_p]
            mock_create.return_value = mock_observer

            mock_sm = MagicMock()
            mock_sm.stats = {"active_sessions": 0, "total_sessions": 0, "total_spawned": 0}
            MockSM.return_value = mock_sm

            from index1.cognitive.observer_worker import create_app
            app = create_app(start_background_threads=False)
            app.config["TESTING"] = True
            client = app.test_client()

            resp = client.post("/shutdown")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["status"] == "shutting_down"
            # 确认 SIGTERM 被发送
            mock_kill.assert_called_once()
            # P2 fix: 确认 SessionManager.stop_all() 被调用（清理 claude 持久进程）
            mock_sm.stop_all.assert_called_once()


# ── PID 文件 + 幂等启动测试 ────────────────────────────────────────

class TestEnsureObserverRunning:
    """ensure_observer_running() 测试"""

    def test_already_running_pid_alive_port_open(self, tmp_path):
        """PID 存活 + 端口开放 → True（不启动新进程）"""
        from index1.cognitive.observer_worker import ensure_observer_running

        with patch("index1.cognitive.observer_worker._PID_FILE", tmp_path / ".pid"), \
             patch("index1.cognitive.observer_worker._INDEX1_HOME", tmp_path), \
             patch("index1.cognitive.observer_worker._is_process_alive", return_value=True), \
             patch("index1.cognitive.observer_worker._is_port_open", return_value=True):

            # 写 PID 文件
            pid_file = tmp_path / ".pid"
            pid_file.write_text(json.dumps({"pid": 12345, "port": 6889}))

            config = MagicMock(
                observer_worker_enabled=True,
                observer_worker_port=6889,
            )
            result = ensure_observer_running(config)
            assert result is True

    def test_stale_pid_cleans_up(self, tmp_path):
        """PID 不存活 → 清理 PID 文件"""
        from index1.cognitive.observer_worker import ensure_observer_running

        pid_file = tmp_path / ".pid"
        pid_file.write_text(json.dumps({"pid": 99999, "port": 6889}))

        with patch("index1.cognitive.observer_worker._PID_FILE", pid_file), \
             patch("index1.cognitive.observer_worker._INDEX1_HOME", tmp_path), \
             patch("index1.cognitive.observer_worker._LOG_FILE", tmp_path / "log"), \
             patch("index1.cognitive.observer_worker._is_process_alive", return_value=False), \
             patch("index1.cognitive.observer_worker._is_port_open", return_value=False), \
             patch("shutil.which", return_value=None):

            config = MagicMock(
                observer_worker_enabled=True,
                observer_worker_port=6889,
            )
            result = ensure_observer_running(config)
            # index1 不在 PATH 中 → 返回 False
            assert result is False
            # PID 文件应该被清理
            assert not pid_file.exists()

    def test_disabled_returns_false(self):
        """observer_worker_enabled=False → False"""
        from index1.cognitive.observer_worker import ensure_observer_running

        config = MagicMock(observer_worker_enabled=False)
        assert ensure_observer_running(config) is False

    def test_port_already_open_no_pid(self, tmp_path):
        """端口已占用但无 PID 文件 → True（认为手动启动的）"""
        from index1.cognitive.observer_worker import ensure_observer_running

        with patch("index1.cognitive.observer_worker._PID_FILE", tmp_path / ".pid"), \
             patch("index1.cognitive.observer_worker._INDEX1_HOME", tmp_path), \
             patch("index1.cognitive.observer_worker._is_port_open", return_value=True):

            config = MagicMock(
                observer_worker_enabled=True,
                observer_worker_port=6889,
            )
            result = ensure_observer_running(config)
            assert result is True


# ── hooks.py 三级降级测试 ──────────────────────────────────────────

class TestTripleFallback:
    """hooks.py _try_observe_l2 三级降级"""

    @pytest.fixture
    def handler(self, tmp_path):
        """创建 HookHandler（mock 依赖）"""
        from index1.cognitive.hooks import HookHandler

        db_cog = MagicMock()
        config = MagicMock(
            observer_worker_enabled=True,
            observer_worker_port=6889,
            observer_enabled=True,
            anthropic_api_key="",
        )

        with patch.object(HookHandler, "__init__", lambda self, *a, **kw: None):
            h = HookHandler.__new__(HookHandler)
            h.db_cog = db_cog
            h.config = config
            h._observer_unavailable = False
            # 初始化 breaker mocks
            h._llm_breaker = MagicMock()
            h._llm_breaker.is_open.return_value = False
            h._llm_breaker.record_success = MagicMock()
            h._llm_breaker.record_failure = MagicMock()
            yield h

    def test_worker_available_returns_ok(self, handler):
        """Worker 可用 → fire-and-forget 成功"""
        with patch.object(handler, "_send_to_worker", return_value=True):
            result = handler._try_observe_l2(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is not None
            assert result.ok is True
            assert result.provider == "worker"

    def test_worker_down_api_key_available(self, handler):
        """Worker 不可用 + API key → httpx 直调"""
        with patch.object(handler, "_send_to_worker", return_value=False), \
             patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-123"}), \
             patch.object(handler, "_observe_via_api") as mock_api:
            mock_api.return_value = MagicMock(ok=True, provider="claude_api")

            result = handler._try_observe_l2(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is not None
            assert result.ok is True
            mock_api.assert_called_once()

    def test_worker_down_no_api_key_returns_none(self, handler):
        """Worker 不可用 + 无 API key → None"""
        with patch.object(handler, "_send_to_worker", return_value=False), \
             patch.dict(os.environ, {}, clear=True):
            # 确保 config 也没有 API key
            handler.config.anthropic_api_key = ""

            result = handler._try_observe_l2(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is None

    def test_worker_disabled_skips_to_api(self, handler):
        """observer_worker_enabled=False → 跳过 Worker，直接尝试 API key"""
        handler.config.observer_worker_enabled = False
        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-123"}), \
             patch.object(handler, "_observe_via_api") as mock_api:
            mock_api.return_value = MagicMock(ok=True, provider="claude_api")

            result = handler._try_observe_l2(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is not None
            mock_api.assert_called_once()

    def test_send_to_worker_success(self, handler):
        """_send_to_worker GET /health + POST /observe 成功"""
        import httpx as real_httpx
        health_resp = MagicMock()
        health_resp.status_code = 200
        health_resp.json.return_value = {"status": "running", "queue_depth": 0}
        post_resp = MagicMock()
        post_resp.status_code = 202
        with patch.object(real_httpx, "get", return_value=health_resp), \
             patch.object(real_httpx, "post", return_value=post_resp):
            result = handler._send_to_worker(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is True

    def test_send_to_worker_health_fail(self, handler):
        """_send_to_worker GET /health 失败 → False（不发 POST）"""
        import httpx as real_httpx
        with patch.object(real_httpx, "get", side_effect=Exception("Connection refused")):
            result = handler._send_to_worker(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is False

    def test_send_to_worker_post_error(self, handler):
        """_send_to_worker /health OK 但 POST 失败 → False"""
        import httpx as real_httpx
        health_resp = MagicMock()
        health_resp.status_code = 200
        health_resp.json.return_value = {"status": "running", "queue_depth": 0}
        with patch.object(real_httpx, "get", return_value=health_resp), \
             patch.object(real_httpx, "post", side_effect=Exception("timeout")):
            result = handler._send_to_worker(
                "Edit", {"file_path": "test.py"}, "ok", "test-col", "sess-1",
            )
            assert result is False

    def test_observe_stderr_when_all_fail(self, handler):
        """observe() 三级全失败 → stderr 通知用户"""
        with patch.object(handler, "_try_observe_l2", return_value=None), \
             patch("sys.stderr") as mock_stderr:
            # 模拟 observe() 中的逻辑
            observed = handler._try_observe_l2(
                "Edit", {}, "ok", "col", "sess",
            )
            if observed is None:
                import sys
                print("[index1] ⚠ Observer: no worker, no API key", file=sys.stderr)
            mock_stderr.write.assert_called()


# ── orient 通知测试 ────────────────────────────────────────────────

class TestObserverAvailability:
    """#248: _is_observer_available() 磁盘态检测"""

    def test_unavailable_when_cb_open(self):
        """CB 熔断 → 不可用"""
        from index1.cognitive.hooks import HookHandler

        with patch.object(HookHandler, "__init__", lambda self, *a, **kw: None):
            h = HookHandler.__new__(HookHandler)
            h._llm_breaker = MagicMock()
            h._llm_breaker.is_open.return_value = True
            h.config = MagicMock(observer_worker_port=6889)

            assert h._is_observer_available() is False

    def test_available_when_worker_running(self):
        """Worker 端口存活 → 可用"""
        from index1.cognitive.hooks import HookHandler

        with patch.object(HookHandler, "__init__", lambda self, *a, **kw: None):
            h = HookHandler.__new__(HookHandler)
            h._llm_breaker = MagicMock()
            h._llm_breaker.is_open.return_value = False
            h.config = MagicMock(observer_worker_port=6889)

            # Mock socket connect_ex → 0 (connected)
            mock_sock = MagicMock()
            mock_sock.connect_ex.return_value = 0
            with patch("socket.socket", return_value=mock_sock):
                assert h._is_observer_available() is True

    def test_available_when_api_key_set(self):
        """无 Worker 但有 API key → 可用"""
        from index1.cognitive.hooks import HookHandler

        with patch.object(HookHandler, "__init__", lambda self, *a, **kw: None):
            h = HookHandler.__new__(HookHandler)
            h._llm_breaker = MagicMock()
            h._llm_breaker.is_open.return_value = False
            h.config = MagicMock(observer_worker_port=6889)

            # socket 连接失败
            mock_sock = MagicMock()
            mock_sock.connect_ex.return_value = 111  # connection refused
            with patch("socket.socket", return_value=mock_sock), \
                 patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}):
                assert h._is_observer_available() is True

    def test_unavailable_when_nothing_works(self):
        """无 Worker + 无 API key + CB closed → 不可用"""
        from index1.cognitive.hooks import HookHandler

        with patch.object(HookHandler, "__init__", lambda self, *a, **kw: None):
            h = HookHandler.__new__(HookHandler)
            h._llm_breaker = MagicMock()
            h._llm_breaker.is_open.return_value = False
            h.config = MagicMock(observer_worker_port=6889)

            mock_sock = MagicMock()
            mock_sock.connect_ex.return_value = 111
            env = {k: "" for k in (
                "ANTHROPIC_API_KEY", "GEMINI_API_KEY",
                "OPENROUTER_API_KEY", "OPENCLAW_API_KEY",
            )}
            with patch("socket.socket", return_value=mock_sock), \
                 patch.dict(os.environ, env, clear=False):
                # 确保环境变量为空
                for k in env:
                    os.environ.pop(k, None)
                assert h._is_observer_available() is False
