"""Claude SDK 持久会话测试

覆盖:
- ClaudeSession: 启动/停止/生成/超时/进程死亡检测
- SessionManager: get_or_create/cleanup_idle/cleanup_session/stop_all/stats
- Worker 集成: /health 显示 claude_sessions stats
"""

from __future__ import annotations

import json
import subprocess
import threading
import time
from unittest.mock import MagicMock, PropertyMock, patch

import pytest


# ── ClaudeSession 测试 ──────────────────────────────────────────────


class TestClaudeSession:
    """ClaudeSession 单元测试"""

    @pytest.fixture
    def config(self):
        cfg = MagicMock()
        cfg.observer_claude_model = ""
        return cfg

    def test_start_no_claude_cli(self, config):
        """claude 不在 PATH → start 返回 False"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        with patch("shutil.which", return_value=None):
            session = ClaudeSession(config, "test-session")
            assert session.start() is False
            assert session.is_alive is False

    def test_start_popen_failure(self, config):
        """Popen 抛 OSError → start 返回 False"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        with patch("shutil.which", return_value="/usr/bin/claude"), \
             patch("subprocess.Popen", side_effect=OSError("exec failed")):
            session = ClaudeSession(config, "test-session")
            assert session.start() is False

    def test_stop_no_proc(self, config):
        """stop 在未启动时不崩溃"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        session.stop()  # 不应抛异常
        assert session.is_alive is False

    def test_stop_terminates_proc(self, config):
        """stop 调用 terminate + wait"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_proc.poll.return_value = None
        session._proc = mock_proc
        session._ready = True

        session.stop()

        mock_proc.terminate.assert_called_once()
        assert session._proc is None
        assert session.is_alive is False

    def test_generate_not_ready(self, config):
        """未就绪时 generate 返回 None"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        assert session.generate("test") is None

    def test_generate_proc_dead(self, config):
        """进程已退出时 generate 返回 None"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        mock_proc = MagicMock()
        mock_proc.poll.return_value = 1  # 已退出
        session._proc = mock_proc
        session._ready = True

        result = session.generate("test")
        assert result is None
        assert session._ready is False

    def test_generate_stdin_broken_pipe(self, config):
        """stdin 写入失败 → 返回 None"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        mock_proc = MagicMock()
        mock_proc.poll.return_value = None
        mock_proc.stdin.write.side_effect = BrokenPipeError()
        session._proc = mock_proc
        session._ready = True

        result = session.generate("test")
        assert result is None
        assert session._ready is False

    def test_idle_seconds(self, config):
        """idle_seconds 返回距上次活动的秒数"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")
        session._last_activity = time.monotonic() - 10
        assert session.idle_seconds >= 9.0

    def test_is_alive_checks_all_conditions(self, config):
        """is_alive 需要 _ready + _proc + poll=None"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        session = ClaudeSession(config, "test-session")

        # 未启动
        assert session.is_alive is False

        # 有 proc 但未 ready
        mock_proc = MagicMock()
        mock_proc.poll.return_value = None
        session._proc = mock_proc
        assert session.is_alive is False

        # ready + proc + alive
        session._ready = True
        assert session.is_alive is True

        # proc 退出
        mock_proc.poll.return_value = 0
        assert session.is_alive is False


# ── SessionManager 测试 ──────────────────────────────────────────────


class TestSessionManager:
    """SessionManager 单元测试"""

    @pytest.fixture
    def config(self):
        cfg = MagicMock()
        cfg.observer_claude_model = ""
        return cfg

    def test_get_or_create_new_session(self, config):
        """首次请求 → 创建新 session"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        mock_session = MagicMock()
        mock_session.is_alive = True
        mock_session.start.return_value = True

        with patch("index1.cognitive.observer.claude_session.ClaudeSession", return_value=mock_session):
            result = mgr.get_or_create("session-1")

        assert result is mock_session
        assert mgr.stats["active_sessions"] == 1
        assert mgr.stats["total_spawned"] == 1

    def test_get_or_create_reuse_existing(self, config):
        """已有存活 session → 复用"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        mock_session = MagicMock()
        mock_session.is_alive = True
        mgr._sessions["session-1"] = mock_session

        result = mgr.get_or_create("session-1")
        assert result is mock_session

    def test_get_or_create_replace_dead(self, config):
        """已有但已死 → 清理 + 创建新的"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        dead_session = MagicMock()
        dead_session.is_alive = False
        mgr._sessions["session-1"] = dead_session

        new_session = MagicMock()
        new_session.is_alive = True
        new_session.start.return_value = True

        with patch("index1.cognitive.observer.claude_session.ClaudeSession", return_value=new_session):
            result = mgr.get_or_create("session-1")

        dead_session.stop.assert_called_once()
        assert result is new_session

    def test_get_or_create_start_fails(self, config):
        """start 失败 → 返回 None + 设置冷却"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        mock_session = MagicMock()
        mock_session.start.return_value = False

        with patch("index1.cognitive.observer.claude_session.ClaudeSession", return_value=mock_session):
            result = mgr.get_or_create("session-1")

        assert result is None
        assert mgr.stats["active_sessions"] == 0
        # 冷却标记已设置
        assert mgr._last_spawn_failure > 0

    def test_spawn_cooldown(self, config):
        """spawn 失败后冷却期内跳过 spawn"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        mock_session = MagicMock()
        mock_session.start.return_value = False

        with patch("index1.cognitive.observer.claude_session.ClaudeSession", return_value=mock_session):
            # 第一次失败 — 实际尝试 spawn
            mgr.get_or_create("session-1")
            assert mock_session.start.call_count == 1

        # 冷却期内再次请求 — 跳过 spawn，ClaudeSession 不再被构造
        with patch("index1.cognitive.observer.claude_session.ClaudeSession") as MockCls:
            result = mgr.get_or_create("session-2")
            assert result is None
            MockCls.assert_not_called()

    def test_spawn_cooldown_resets_on_success(self, config):
        """spawn 成功后冷却重置"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)
        # 模拟之前有失败
        mgr._last_spawn_failure = time.monotonic() - 120  # 已过冷却

        mock_session = MagicMock()
        mock_session.is_alive = True
        mock_session.start.return_value = True

        with patch("index1.cognitive.observer.claude_session.ClaudeSession", return_value=mock_session):
            result = mgr.get_or_create("session-1")

        assert result is mock_session
        assert mgr._last_spawn_failure == 0.0  # 重置

    def test_cleanup_idle(self, config):
        """清理空闲超时的 session"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        # 活跃 session（10s 空闲）
        active = MagicMock()
        active.is_alive = True
        active.idle_seconds = 10.0
        mgr._sessions["active"] = active

        # 空闲 session（600s 空闲）
        idle = MagicMock()
        idle.is_alive = True
        idle.idle_seconds = 600.0
        mgr._sessions["idle"] = idle

        cleaned = mgr.cleanup_idle(max_idle=300.0)

        assert cleaned == 1
        idle.stop.assert_called_once()
        assert "active" in mgr._sessions
        assert "idle" not in mgr._sessions

    def test_cleanup_dead_sessions(self, config):
        """cleanup_idle 也清理已死的 session"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        dead = MagicMock()
        dead.is_alive = False
        dead.idle_seconds = 0.0
        mgr._sessions["dead"] = dead

        cleaned = mgr.cleanup_idle(max_idle=300.0)
        assert cleaned == 1

    def test_cleanup_session(self, config):
        """主动清理指定 session"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        session = MagicMock()
        mgr._sessions["session-1"] = session

        assert mgr.cleanup_session("session-1") is True
        session.stop.assert_called_once()
        assert "session-1" not in mgr._sessions

        # 不存在的 session
        assert mgr.cleanup_session("nonexistent") is False

    def test_stop_all(self, config):
        """stop_all 清理所有 session"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)

        s1 = MagicMock()
        s2 = MagicMock()
        mgr._sessions["s1"] = s1
        mgr._sessions["s2"] = s2

        mgr.stop_all()

        s1.stop.assert_called_once()
        s2.stop.assert_called_once()
        assert len(mgr._sessions) == 0

    def test_stats(self, config):
        """stats 返回统计信息"""
        from index1.cognitive.observer.claude_session import SessionManager

        mgr = SessionManager(config)
        mgr._total_spawned = 5

        alive = MagicMock()
        alive.is_alive = True
        dead = MagicMock()
        dead.is_alive = False

        mgr._sessions["alive"] = alive
        mgr._sessions["dead"] = dead

        stats = mgr.stats
        assert stats["active_sessions"] == 1
        assert stats["total_sessions"] == 2
        assert stats["total_spawned"] == 5


# ── Worker 集成测试 ──────────────────────────────────────────────────


class TestWorkerSessionIntegration:
    """Observer Worker 集成 SessionManager 测试"""

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

            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,
            )

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

            mock_mgr_instance = MockMgr.return_value
            mock_mgr_instance.stats = {
                "active_sessions": 0,
                "total_sessions": 0,
                "total_spawned": 0,
            }
            mock_mgr_instance.cleanup_session.return_value = True

            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_includes_claude_sessions(self, client):
        """GET /health 包含 claude_sessions 字段"""
        resp = client.get("/health")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "claude_sessions" in data
        assert "active_sessions" in data["claude_sessions"]
        assert "total_spawned" in data["claude_sessions"]

    def test_cleanup_session_endpoint(self, client):
        """POST /cleanup-session 清理指定 session"""
        resp = client.post("/cleanup-session", json={"session_id": "test-123"})
        assert resp.status_code == 200
        data = resp.get_json()
        assert "cleaned" in data

    def test_cleanup_session_missing_id(self, client):
        """POST /cleanup-session 缺少 session_id → 400"""
        resp = client.post("/cleanup-session", json={})
        assert resp.status_code == 400


# ── skip_cli 测试 ──────────────────────────────────────────────────


class TestSkipCli:
    """ClaudeObserverProvider skip_cli 参数测试"""

    def test_skip_cli_disables_cli(self):
        """skip_cli=True → _claude_cli 为 None"""
        from index1.cognitive.observer.claude import ClaudeObserverProvider

        config = MagicMock()
        config.anthropic_api_key = ""
        config.observer_claude_model = ""

        with patch("shutil.which", return_value="/usr/bin/claude"):
            # 不跳过 → 有 CLI
            provider = ClaudeObserverProvider(config, skip_cli=False)
            assert provider._claude_cli == "/usr/bin/claude"

            # 跳过 → 无 CLI
            provider_skip = ClaudeObserverProvider(config, skip_cli=True)
            assert provider_skip._claude_cli is None

    def test_create_observer_skip_cli(self):
        """create_observer(skip_cli=True) 传递到 Claude provider"""
        from index1.cognitive.observer import create_observer

        config = MagicMock()
        config.observer_enabled = True
        config.observer_provider_priority = ["claude"]
        db_cog = MagicMock()

        with patch("shutil.which", return_value="/usr/bin/claude"), \
             patch("os.environ", {"ANTHROPIC_API_KEY": ""}):
            # skip_cli=True
            observer = create_observer(db_cog, config, skip_cli=True)
            if observer:
                claude_provider = observer.providers[0]
                assert claude_provider._claude_cli is None

            # skip_cli=False (默认)
            observer2 = create_observer(db_cog, config, skip_cli=False)
            if observer2:
                claude_provider2 = observer2.providers[0]
                assert claude_provider2._claude_cli == "/usr/bin/claude"


# ── 孤儿进程清理测试 ──────────────────────────────────────────────────


class TestOrphanProcessCleanup:
    """PID 追踪 + 孤儿清理测试"""

    def test_add_remove_child_pid(self, tmp_path):
        """PID 追踪文件正确读写"""
        from index1.cognitive.observer.claude_session import (
            _add_child_pid, _read_child_pids, _remove_child_pid,
        )
        with patch("index1.cognitive.observer.claude_session._CHILD_PIDS_FILE", tmp_path / "pids"), \
             patch("index1.cognitive.observer.claude_session._INDEX1_HOME", tmp_path):
            # 空文件
            assert _read_child_pids() == set()

            # 添加
            _add_child_pid(1001)
            _add_child_pid(1002)
            assert _read_child_pids() == {1001, 1002}

            # 移除
            _remove_child_pid(1001)
            assert _read_child_pids() == {1002}

            # 移除最后一个 → 文件删除
            _remove_child_pid(1002)
            assert _read_child_pids() == set()
            assert not (tmp_path / "pids").exists()

    def test_cleanup_orphan_kills_alive_processes(self, tmp_path):
        """孤儿清理 kill 存活的进程"""
        from index1.cognitive.observer.claude_session import (
            _cleanup_orphan_processes, _CHILD_PIDS_FILE,
        )
        pids_file = tmp_path / "pids"
        pids_file.write_text("1001\n1002\n1003\n")

        # L1 校验：ps -p <pid> 检查进程名含 "claude"，需 mock subprocess.run
        mock_ps = MagicMock()
        mock_ps.stdout = "claude --setting-sources ''"
        with patch("index1.cognitive.observer.claude_session._CHILD_PIDS_FILE", pids_file), \
             patch("os.kill") as mock_kill, \
             patch("subprocess.run", return_value=mock_ps):
            # 1001 存活，1002 已死（OSError），1003 存活
            def side_effect(pid, sig):
                if pid == 1002:
                    raise ProcessLookupError("no such process")
            mock_kill.side_effect = side_effect

            killed = _cleanup_orphan_processes()

            # 1001: kill(0) + ps(claude) + kill(SIGTERM) = alive
            # 1002: kill(0) raises → 跳过
            # 1003: kill(0) + ps(claude) + kill(SIGTERM) = alive
            assert killed == 2
            # 追踪文件已清空
            assert not pids_file.exists()

    def test_session_manager_init_cleans_orphans(self):
        """SessionManager 初始化时调用孤儿清理"""
        from index1.cognitive.observer.claude_session import SessionManager

        config = MagicMock()
        with patch("index1.cognitive.observer.claude_session._cleanup_orphan_processes") as mock_cleanup:
            mock_cleanup.return_value = 0
            mgr = SessionManager(config)
            mock_cleanup.assert_called_once()

    def test_start_adds_pid_stop_removes(self):
        """start() 追踪 PID，stop() 移除 PID"""
        from index1.cognitive.observer.claude_session import ClaudeSession

        config = MagicMock()
        config.observer_claude_model = "test-model"
        session = ClaudeSession(config, "test-session")

        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_proc.poll.return_value = None
        mock_proc.stdout.fileno.return_value = 3

        with patch("shutil.which", return_value="/usr/bin/claude"), \
             patch("subprocess.Popen", return_value=mock_proc), \
             patch.object(session, "_wait_for_init", return_value=True), \
             patch("index1.cognitive.observer.claude_session.make_clean_env", return_value={}), \
             patch("index1.cognitive.observer.claude_session._add_child_pid") as mock_add, \
             patch("index1.cognitive.observer.claude_session._remove_child_pid") as mock_remove, \
             patch("builtins.open", MagicMock()):

            assert session.start() is True
            mock_add.assert_called_once_with(12345)

            session.stop()
            mock_remove.assert_called_once_with(12345)
