"""Observer 框架测试 — SessionObserver + Ollama/Claude providers"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.cognitive.db_cog import CogDatabase
from index1.cognitive.observer import (
    SessionObserver, create_observer, OBSERVER_PROMPT,
    _PROVIDER_REGISTRY, _PROVIDER_CONFIDENCE,
)
from index1.cognitive.observer.base import ObserverProvider
from index1.cognitive.observer.ollama import OllamaObserverProvider
from index1.cognitive.observer.claude import ClaudeObserverProvider
from index1.cognitive.observer.openai_compat import OpenAICompatProvider
from index1.cognitive.observer.gemini import GeminiObserverProvider
from index1.cognitive.observer.openrouter import OpenRouterObserverProvider
from index1.cognitive.observer.openclaw import OpenClawObserverProvider
from index1.config import Config


# ── fixtures ──


@pytest.fixture()
def cog_db(tmp_path: Path) -> CogDatabase:
    db = CogDatabase(tmp_path / "observer_test.db", embedding_dim=4)
    db.connect()
    yield db
    db.close()


@pytest.fixture()
def config() -> Config:
    return Config()


@pytest.fixture()
def sample_facts() -> list[dict]:
    return [
        {"assertion": "Chose SQLite over PostgreSQL for simplicity", "category": "decision"},
        {"assertion": "Modified src/db.py", "category": "code"},
        {"assertion": "Bug caused by race condition in WAL mode", "category": "root_cause"},
    ]


# ── OBSERVER_PROMPT ──


class TestObserverPrompt:
    def test_prompt_template_has_placeholders(self):
        assert "{facts_text}" in OBSERVER_PROMPT
        assert "{collection}" in OBSERVER_PROMPT

    def test_prompt_format(self, sample_facts):
        result = OBSERVER_PROMPT.format(
            facts_text="- [decision] Chose SQLite",
            collection="test-proj",
        )
        assert "test-proj" in result
        assert "Chose SQLite" in result


# ── OllamaObserverProvider ──


class TestOllamaProvider:
    def test_name(self, config):
        p = OllamaObserverProvider(config)
        assert p.name == "ollama"

    def test_available_default(self, config):
        p = OllamaObserverProvider(config)
        assert p.available is True  # 未检查时假设可用

    def test_available_after_cooldown(self, config):
        p = OllamaObserverProvider(config)
        p._cooldown.record_failure()
        assert p.available is False

    def test_parse_response_valid(self, config):
        p = OllamaObserverProvider(config)
        raw = '[{"assertion": "test fact", "category": "decision", "confidence": 0.85}]'
        result = p._parse_response(raw)
        assert len(result) == 1
        assert result[0]["assertion"] == "test fact"

    def test_parse_response_with_preamble(self, config):
        """LLM 输出前有文字说明"""
        p = OllamaObserverProvider(config)
        raw = 'Here are the observations:\n[{"assertion": "fact 1", "category": "constraint"}]'
        result = p._parse_response(raw)
        assert len(result) == 1

    def test_parse_response_empty(self, config):
        p = OllamaObserverProvider(config)
        assert p._parse_response("") is None
        assert p._parse_response("no json here") is None

    def test_parse_response_invalid_json(self, config):
        p = OllamaObserverProvider(config)
        assert p._parse_response("[invalid json}") is None

    def test_parse_response_empty_array(self, config):
        p = OllamaObserverProvider(config)
        result = p._parse_response("[]")
        assert result == []


# ── ClaudeObserverProvider ──


class TestClaudeProvider:
    def test_name(self, config):
        p = ClaudeObserverProvider(config)
        assert p.name == "claude"

    def test_unavailable_without_api_key_and_cli(self, config, monkeypatch):
        config.anthropic_api_key = ""
        monkeypatch.setattr("shutil.which", lambda _: None)
        p = ClaudeObserverProvider(config)
        # CLI 也找不到时 → 不可用
        assert p.available is False

    def test_available_with_cli_only(self, config, monkeypatch):
        config.anthropic_api_key = ""
        monkeypatch.setenv("ANTHROPIC_API_KEY", "")
        p = ClaudeObserverProvider(config)
        p._claude_cli = "/usr/local/bin/claude"
        assert p.available is True

    def test_available_with_api_key(self, config):
        config.anthropic_api_key = "sk-test-key"
        p = ClaudeObserverProvider(config)
        assert p.available is True

    def test_available_with_env_var(self, config, monkeypatch):
        config.anthropic_api_key = ""
        monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-key")
        p = ClaudeObserverProvider(config)
        assert p.available is True

    def test_parse_response_valid(self, config):
        p = ClaudeObserverProvider(config)
        raw = '[{"assertion": "test", "category": "decision", "confidence": 0.9}]'
        result = p._parse_response(raw)
        assert len(result) == 1


# ── SessionObserver ──


class TestSessionObserver:
    def test_empty_facts_returns_empty(self, cog_db, config):
        observer = SessionObserver(providers=[], db_cog=cog_db, config=config)
        result = asyncio.run(observer.observe([], "test", "s1"))
        assert result == []

    def test_no_providers_returns_empty(self, cog_db, config, sample_facts):
        observer = SessionObserver(providers=[], db_cog=cog_db, config=config)
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))
        assert result == []

    def test_first_available_provider_used(self, cog_db, config, sample_facts):
        """第一个可用的 provider 被调用"""
        provider1 = MagicMock()
        provider1.name = "mock1"
        provider1.available = True
        provider1.observe_session = AsyncMock(return_value=[
            {"assertion": "Important decision about architecture", "category": "decision", "confidence": 0.85},
        ])

        provider2 = MagicMock()
        provider2.name = "mock2"
        provider2.available = True
        provider2.observe_session = AsyncMock()

        observer = SessionObserver(
            providers=[provider1, provider2],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))

        provider1.observe_session.assert_called_once()
        provider2.observe_session.assert_not_called()
        assert len(result) == 1

    def test_unavailable_provider_skipped(self, cog_db, config, sample_facts):
        """不可用的 provider 被跳过"""
        unavailable = MagicMock()
        unavailable.name = "bad"
        unavailable.available = False

        available = MagicMock()
        available.name = "good"
        available.available = True
        available.observe_session = AsyncMock(return_value=[
            {"assertion": "Found a root cause in the logging module", "category": "root_cause", "confidence": 0.8},
        ])

        observer = SessionObserver(
            providers=[unavailable, available],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))

        assert len(result) == 1

    def test_failed_provider_fallback(self, cog_db, config, sample_facts):
        """第一个 provider 失败后降级到第二个"""
        failing = MagicMock()
        failing.name = "failing"
        failing.available = True
        failing.observe_session = AsyncMock(side_effect=RuntimeError("API error"))

        fallback = MagicMock()
        fallback.name = "fallback"
        fallback.available = True
        fallback.observe_session = AsyncMock(return_value=[
            {"assertion": "Constraint: must use safe_db_write for all writes", "category": "constraint", "confidence": 0.9},
        ])

        observer = SessionObserver(
            providers=[failing, fallback],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))

        assert len(result) == 1

    def test_duplicate_facts_filtered(self, cog_db, config, sample_facts):
        """重复 fact 被去重过滤"""
        # 先插入一条已有 fact
        cog_db.insert_fact({
            "collection": "test",
            "assertion": "Important decision about architecture",
            "confidence": 0.85,
        })

        provider = MagicMock()
        provider.name = "mock"
        provider.available = True
        provider.observe_session = AsyncMock(return_value=[
            {"assertion": "Important decision about architecture", "category": "decision", "confidence": 0.85},
            {"assertion": "New unique observation about caching strategy", "category": "preference", "confidence": 0.8},
        ])

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))

        # 第一条重复被过滤，第二条写入
        assert len(result) == 1
        assert "caching" in result[0]["assertion"]

    def test_short_assertions_skipped(self, cog_db, config, sample_facts):
        """短于 10 字的 assertion 被跳过"""
        provider = MagicMock()
        provider.name = "mock"
        provider.available = True
        provider.observe_session = AsyncMock(return_value=[
            {"assertion": "short", "category": "decision", "confidence": 0.8},
            {"assertion": "This is a valid observation about the system design", "category": "architecture", "confidence": 0.85},
        ])

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))
        assert len(result) == 1

    def test_confidence_clamped(self, cog_db, config, sample_facts):
        """confidence 被钳制到 0.5-0.95"""
        provider = MagicMock()
        provider.name = "mock"
        provider.available = True
        provider.observe_session = AsyncMock(return_value=[
            {"assertion": "Observation with too high confidence value", "category": "decision", "confidence": 1.5},
        ])

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))
        assert len(result) == 1
        assert result[0]["confidence"] <= 0.95

    def test_max_5_facts(self, cog_db, config, sample_facts):
        """最多写入 5 条 facts"""
        provider = MagicMock()
        provider.name = "mock"
        provider.available = True
        provider.observe_session = AsyncMock(return_value=[
            {"assertion": f"Observation number {i} about system behavior", "category": "decision", "confidence": 0.8}
            for i in range(10)
        ])

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))
        assert len(result) <= 5

    def test_source_includes_provider_name(self, cog_db, config, sample_facts):
        """source 字段包含 provider 名称"""
        provider = MagicMock()
        provider.name = "ollama"
        provider.available = True
        provider.observe_session = AsyncMock(return_value=[
            {"assertion": "Observation from ollama about database patterns", "category": "decision", "confidence": 0.8},
        ])

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db, config=config,
        )
        result = asyncio.run(observer.observe(sample_facts, "test", "s1"))
        assert result[0]["source"] == "observer_ollama"


# ── create_observer ──


class TestCreateObserver:
    def test_disabled_returns_none(self):
        config = Config()
        config.observer_enabled = False
        result = create_observer(MagicMock(), config)
        assert result is None

    def test_enabled_returns_observer(self, cog_db):
        config = Config()
        config.observer_enabled = True
        result = create_observer(cog_db, config)
        # Ollama 在 CI/测试环境可能不可用，所以 result 可能为 None
        # 但调用过程不应崩溃
        if result is not None:
            assert isinstance(result, SessionObserver)
            assert len(result.providers) > 0

    def test_enabled_with_mocked_provider(self, cog_db):
        """确保 create_observer 正确组装 providers"""
        config = Config()
        config.observer_enabled = True
        with patch("index1.cognitive.observer.ollama.OllamaObserverProvider") as MockOllama:
            mock_instance = MagicMock()
            mock_instance.name = "ollama"
            MockOllama.return_value = mock_instance
            result = create_observer(cog_db, config)
        assert result is not None
        assert len(result.providers) > 0

    def test_with_api_key_has_claude_provider(self, cog_db):
        config = Config()
        config.anthropic_api_key = "sk-test"
        result = create_observer(cog_db, config)
        if result is not None:
            provider_names = [p.name for p in result.providers]
            assert "claude" in provider_names


# ── scheduler 集成 ──


class TestSchedulerObserverIntegration:
    """验证 observer 在 scheduler 子进程中被正确调用"""

    def test_scheduler_calls_observer_before_maintenance(self, cog_db, config, sample_facts):
        """scheduler._run_maintenance 应在 maintenance.run 之前调用 observer"""
        from index1.cognitive.scheduler import _run_maintenance

        # 插入 session facts 供 observer 读取
        for f in sample_facts:
            cog_db.insert_fact({
                **f,
                "collection": "test",
                "session_id": "sched-test",
                "confidence": 0.8,
            })

        mock_observer = MagicMock()
        mock_observer.observe = AsyncMock(return_value=[])

        with patch("index1.config.load_config", return_value=config), \
             patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.embed.create_embedder") as mock_embed, \
             patch("index1.cognitive.maintenance.MaintenanceEngine") as MockEngine, \
             patch("index1.cognitive.observer.create_observer", return_value=mock_observer) as mock_create:

            # 设置 mock DB
            mock_db_inst = MagicMock()
            mock_db_inst.get_session_facts.return_value = sample_facts
            MockDB.return_value = mock_db_inst

            # 设置 mock embedder
            mock_emb = AsyncMock()
            mock_emb.available = False
            mock_emb.close = AsyncMock()
            mock_embed.return_value = mock_emb

            # 设置 mock engine
            mock_eng = AsyncMock()
            mock_eng.run = AsyncMock(return_value={})
            MockEngine.return_value = mock_eng

            asyncio.run(_run_maintenance("test", "sched-test", str(cog_db._db_path)))

            # observer 被创建并调用
            mock_create.assert_called_once()
            mock_observer.observe.assert_called_once()

    def test_scheduler_skips_observer_without_session_id(self, config):
        """无 session_id 时跳过 observer"""
        from index1.cognitive.scheduler import _run_maintenance

        with patch("index1.config.load_config", return_value=config), \
             patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.embed.create_embedder") as mock_embed, \
             patch("index1.cognitive.maintenance.MaintenanceEngine") as MockEngine, \
             patch("index1.cognitive.observer.create_observer") as mock_create:

            mock_db_inst = MagicMock()
            MockDB.return_value = mock_db_inst

            mock_emb = AsyncMock()
            mock_emb.available = False
            mock_emb.close = AsyncMock()
            mock_embed.return_value = mock_emb

            mock_eng = AsyncMock()
            mock_eng.run = AsyncMock(return_value={})
            MockEngine.return_value = mock_eng

            asyncio.run(_run_maintenance("test", None, "/tmp/fake.db"))

            # observer 不应被创建
            mock_create.assert_not_called()

    def test_scheduler_observer_failure_doesnt_block_maintenance(self, config):
        """observer 失败不阻塞 maintenance"""
        from index1.cognitive.scheduler import _run_maintenance

        with patch("index1.config.load_config", return_value=config), \
             patch("index1.cognitive.db_cog.CogDatabase") as MockDB, \
             patch("index1.embed.create_embedder") as mock_embed, \
             patch("index1.cognitive.maintenance.MaintenanceEngine") as MockEngine, \
             patch("index1.cognitive.observer.create_observer", side_effect=RuntimeError("boom")):

            mock_db_inst = MagicMock()
            mock_db_inst.get_session_facts.return_value = [{"assertion": "x"}]
            MockDB.return_value = mock_db_inst

            mock_emb = AsyncMock()
            mock_emb.available = False
            mock_emb.close = AsyncMock()
            mock_embed.return_value = mock_emb

            mock_eng = AsyncMock()
            mock_eng.run = AsyncMock(return_value={"ok": True})
            MockEngine.return_value = mock_eng

            # 不应抛异常
            asyncio.run(_run_maintenance("test", "s1", "/tmp/fake.db"))

            # maintenance 仍然执行
            mock_eng.run.assert_called_once()


# ── OpenAI 兼容基类 ──


class TestOpenAICompatProvider:
    def test_name(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://localhost:8000/v1",
            api_key="key", model="m", cooldown_name="test_cd",
        )
        assert p.name == "test"

    def test_available_with_key(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://localhost:8000/v1",
            api_key="key", model="m", cooldown_name="test_cd",
        )
        assert p.available is True

    def test_unavailable_without_key(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://localhost:8000/v1",
            api_key="", model="m", cooldown_name="test_cd",
        )
        assert p.available is False

    def test_unavailable_during_cooldown(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://localhost:8000/v1",
            api_key="key", model="m", cooldown_name="test_cd2",
        )
        p._cooldown.record_failure()
        assert p.available is False

    def test_base_url_trailing_slash_stripped(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://example.com/v1/",
            api_key="key", model="m", cooldown_name="test_cd3",
        )
        assert p._base_url == "http://example.com/v1"

    def test_generate_returns_none_without_key(self):
        p = OpenAICompatProvider(
            name="test", base_url="http://localhost:8000/v1",
            api_key="", model="m", cooldown_name="test_cd4",
        )
        result = asyncio.run(p.generate("hello"))
        assert result is None


# ── Gemini Provider ──


class TestGeminiProvider:
    def test_name(self, config):
        p = GeminiObserverProvider(config)
        assert p.name == "gemini"

    def test_base_url(self, config):
        p = GeminiObserverProvider(config)
        assert "generativelanguage.googleapis.com" in p._base_url

    def test_unavailable_without_key(self, config):
        config.gemini_api_key = ""
        p = GeminiObserverProvider(config)
        assert p.available is False

    def test_available_with_key(self, config):
        config.gemini_api_key = "test-key"
        p = GeminiObserverProvider(config)
        assert p.available is True

    def test_env_var_fallback(self, config, monkeypatch):
        config.gemini_api_key = ""
        monkeypatch.setenv("GEMINI_API_KEY", "env-key")
        p = GeminiObserverProvider(config)
        assert p.available is True


# ── OpenRouter Provider ──


class TestOpenRouterProvider:
    def test_name(self, config):
        p = OpenRouterObserverProvider(config)
        assert p.name == "openrouter"

    def test_base_url(self, config):
        p = OpenRouterObserverProvider(config)
        assert "openrouter.ai" in p._base_url

    def test_unavailable_without_key(self, config):
        config.openrouter_api_key = ""
        p = OpenRouterObserverProvider(config)
        assert p.available is False

    def test_available_with_key(self, config):
        config.openrouter_api_key = "test-key"
        p = OpenRouterObserverProvider(config)
        assert p.available is True

    def test_env_var_fallback(self, config, monkeypatch):
        config.openrouter_api_key = ""
        monkeypatch.setenv("OPENROUTER_API_KEY", "env-key")
        p = OpenRouterObserverProvider(config)
        assert p.available is True


# ── OpenClaw Provider ──


class TestOpenClawProvider:
    def test_name(self, config):
        p = OpenClawObserverProvider(config)
        assert p.name == "openclaw"

    def test_default_url(self, config):
        p = OpenClawObserverProvider(config)
        assert "18789" in p._base_url

    def test_custom_url(self, config):
        config.openclaw_url = "http://my-gpu:9000"
        p = OpenClawObserverProvider(config)
        assert "my-gpu:9000" in p._base_url

    def test_unavailable_without_key(self, config):
        config.openclaw_api_key = ""
        p = OpenClawObserverProvider(config)
        assert p.available is False

    def test_available_with_key(self, config):
        config.openclaw_api_key = "test-key"
        p = OpenClawObserverProvider(config)
        assert p.available is True


# ── create_observer 优先级 ──


class TestCreateObserverPriority:
    def test_default_priority_order(self, cog_db):
        config = Config()
        result = create_observer(cog_db, config)
        if result is not None:
            names = [p.name for p in result.providers]
            # 默认顺序中 claude 在 ollama 前面
            if "claude" in names and "ollama" in names:
                assert names.index("claude") < names.index("ollama")

    def test_custom_priority(self, cog_db):
        config = Config()
        config.observer_provider_priority = ["ollama", "claude"]
        result = create_observer(cog_db, config)
        if result is not None:
            names = [p.name for p in result.providers]
            if "ollama" in names and "claude" in names:
                assert names.index("ollama") < names.index("claude")

    def test_unknown_provider_skipped(self, cog_db):
        config = Config()
        config.observer_provider_priority = ["nonexistent", "ollama"]
        result = create_observer(cog_db, config)
        if result is not None:
            names = [p.name for p in result.providers]
            assert "nonexistent" not in names

    def test_registry_has_all_providers(self):
        expected = {"claude", "gemini", "openrouter", "openclaw", "ollama"}
        assert set(_PROVIDER_REGISTRY.keys()) == expected

    def test_confidence_mapping_complete(self):
        for name in _PROVIDER_REGISTRY:
            assert name in _PROVIDER_CONFIDENCE


# ── OpenAICompatProvider generate() ──


class TestOpenAICompatGenerate:
    """mock HTTP 层测试 generate() 各种响应"""

    def _make_provider(self, cooldown_suffix="gen"):
        return OpenAICompatProvider(
            name="test",
            base_url="http://fake:8000/v1",
            api_key="test-key",
            model="test-model",
            cooldown_name=f"test_compat_{cooldown_suffix}",
        )

    def test_generate_success(self):
        """正常 JSON 响应 → 返回 content 文本"""
        p = self._make_provider("success")
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "choices": [{"message": {"content": "<observation>test result</observation>"}}]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is not None
        assert "<observation>" in result
        assert "test result" in result

    def test_generate_http_500(self):
        """HTTP 500 → 返回 None + cooldown 记录"""
        import httpx
        p = self._make_provider("http500")
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(
                side_effect=httpx.HTTPStatusError(
                    "500 Server Error",
                    request=MagicMock(),
                    response=MagicMock(status_code=500),
                )
            )
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None
        assert p._cooldown.is_cooling  # failure 被记录

    def test_generate_timeout(self):
        """超时 → 返回 None + cooldown"""
        import httpx
        p = self._make_provider("timeout")
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(side_effect=httpx.ReadTimeout("timed out"))
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None
        assert p._cooldown.is_cooling

    def test_generate_empty_choices(self):
        """空 choices 数组 → 返回 None（不触发 cooldown）"""
        p = self._make_provider("empty")
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {"choices": []}
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None

    def test_generate_malformed_json(self):
        """json() 解析失败 → 返回 None + cooldown"""
        p = self._make_provider("malformed")
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.side_effect = ValueError("invalid JSON")
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None
        assert p._cooldown.is_cooling

    def test_generate_missing_message_key(self):
        """choices 存在但 message 结构不完整 → 返回空字符串"""
        p = self._make_provider("nomsg")
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {"choices": [{"message": {}}]}
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        # 空字符串，不是 None（HTTP 成功了，只是没 content）
        assert result == ""

    def test_generate_during_cooldown(self):
        """冷却中 → 直接返回 None"""
        p = self._make_provider("cooldown")
        p._cooldown.record_failure()
        result = asyncio.run(p.generate("test prompt"))
        assert result is None

    def test_observe_session_delegates_to_generate(self):
        """observe_session 委托 generate + parse"""
        p = self._make_provider("obs_session")
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "choices": [{"message": {"content": '[{"assertion": "test fact", "category": "decision", "confidence": 0.85}]'}}]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.observe_session("test prompt"))
        assert result is not None
        assert len(result) == 1
        assert result[0]["assertion"] == "test fact"

    def test_close_httpx_client(self):
        """close() 正确关闭 httpx client"""
        p = self._make_provider("close")
        mock_client = AsyncMock()
        mock_client.aclose = AsyncMock()
        p._client = mock_client
        asyncio.run(p.close())
        mock_client.aclose.assert_called_once()
        assert p._client is None

    def test_close_when_no_client(self):
        """没有 client 时 close() 不崩溃"""
        p = self._make_provider("close_none")
        asyncio.run(p.close())  # 不抛异常


# ── ClaudeObserverProvider generate() ──


class TestClaudeGenerate:
    """mock subprocess/httpx 测试 Claude CLI+API 降级"""

    @pytest.fixture()
    def claude_config(self):
        config = Config()
        config.anthropic_api_key = ""
        return config

    def test_cli_success(self, claude_config):
        """CLI 成功 → 返回 stdout 文本"""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        async def fake_create_subprocess_exec(*args, **kwargs):
            proc = AsyncMock()
            proc.communicate = AsyncMock(
                return_value=(b"<observation>CLI result</observation>", b"")
            )
            proc.returncode = 0
            return proc

        with patch("asyncio.create_subprocess_exec", side_effect=fake_create_subprocess_exec), \
             patch("index1.env_utils.make_clean_env", return_value={}):
            result = asyncio.run(p.generate("test prompt"))
        assert result is not None
        assert "CLI result" in result

    def test_cli_timeout_returns_none(self, claude_config):
        """CLI 超时 → 返回 None, 进程被 kill"""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        mock_proc = AsyncMock()
        mock_proc.kill = MagicMock()
        mock_proc.communicate = AsyncMock(
            side_effect=asyncio.TimeoutError()
        )

        async def fake_create(*args, **kwargs):
            return mock_proc

        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={}):
            result = asyncio.run(p._generate_via_cli("test prompt"))
        assert result is None
        mock_proc.kill.assert_called_once()

    def test_cli_timeout_value_is_bounded(self):
        """_CLI_TIMEOUT 设置了合理的超时值（>0 且不超过 hook 进程安全边界）"""
        from index1.cognitive.observer.claude import _CLI_TIMEOUT
        assert _CLI_TIMEOUT > 0
        assert _CLI_TIMEOUT <= 120  # hook 进程 timeout 边界

    def test_cli_timeout_path_uses_wait_for(self, claude_config):
        """验证 CLI 调用路径确实经过 asyncio.wait_for（超时时触发 TimeoutError）"""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        # 模拟进程 communicate 挂住不返回
        mock_proc = AsyncMock()
        mock_proc.kill = MagicMock()

        async def hang_forever():
            await asyncio.sleep(999)

        mock_proc.communicate = MagicMock(return_value=hang_forever())

        async def fake_create(*args, **kwargs):
            return mock_proc

        # 临时把 timeout 设为极小值触发 TimeoutError
        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={}), \
             patch("index1.cognitive.observer.claude._CLI_TIMEOUT", 0.01):
            result = asyncio.run(p._generate_via_cli("test prompt"))
        assert result is None  # TimeoutError → None
        mock_proc.kill.assert_called_once()  # 超时后 kill 进程

    def test_cli_nonzero_exit(self, claude_config):
        """CLI exit code != 0 → 返回 None"""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        async def fake_create(*args, **kwargs):
            proc = AsyncMock()
            proc.communicate = AsyncMock(
                return_value=(b"", b"Error: something went wrong")
            )
            proc.returncode = 1
            return proc

        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={}):
            result = asyncio.run(p._generate_via_cli("test prompt"))
        assert result is None

    def test_cli_fail_api_fallback_success(self, claude_config):
        """CLI 失败 → API fallback 成功"""
        claude_config.anthropic_api_key = "sk-test-key"
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        # CLI 返回 None
        async def fake_create(*args, **kwargs):
            proc = AsyncMock()
            proc.communicate = AsyncMock(return_value=(b"", b"error"))
            proc.returncode = 1
            return proc

        # API 返回成功
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "content": [{"type": "text", "text": "<observation>API result</observation>"}]
        }

        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={}), \
             patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is not None
        assert "API result" in result

    def test_both_cli_and_api_fail(self, claude_config):
        """CLI + API 都失败 → 返回 None + cooldown"""
        claude_config.anthropic_api_key = "sk-test-key"
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        # CLI 失败
        async def fake_create(*args, **kwargs):
            proc = AsyncMock()
            proc.communicate = AsyncMock(return_value=(b"", b"error"))
            proc.returncode = 1
            return proc

        # API 也失败
        import httpx
        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={}), \
             patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(
                side_effect=httpx.ConnectError("connection refused")
            )
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None
        assert p._cooldown.is_cooling

    def test_make_clean_env_called(self, claude_config):
        """CLI 路径调用 make_clean_env"""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = "/usr/bin/fake-claude"

        async def fake_create(*args, **kwargs):
            proc = AsyncMock()
            proc.communicate = AsyncMock(return_value=(b"ok", b""))
            proc.returncode = 0
            return proc

        with patch("asyncio.create_subprocess_exec", side_effect=fake_create), \
             patch("index1.env_utils.make_clean_env", return_value={"PATH": "/usr/bin"}) as mock_env:
            asyncio.run(p._generate_via_cli("test prompt"))
        mock_env.assert_called_once()

    def test_no_cli_no_api_returns_none(self, claude_config):
        """无 CLI 无 API key → 返回 None + cooldown"""
        claude_config.anthropic_api_key = ""
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = None
        result = asyncio.run(p.generate("test prompt"))
        assert result is None

    def test_api_success_without_cli(self, claude_config):
        """无 CLI 有 API key → 直接用 API"""
        claude_config.anthropic_api_key = "sk-test"
        p = ClaudeObserverProvider(claude_config)
        p._claude_cli = None

        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "content": [{"type": "text", "text": "API only result"}]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result == "API only result"

    def test_close_httpx_client(self, claude_config):
        """close() 关闭 httpx client"""
        p = ClaudeObserverProvider(claude_config)
        mock_client = AsyncMock()
        mock_client.aclose = AsyncMock()
        p._client = mock_client
        asyncio.run(p.close())
        mock_client.aclose.assert_called_once()
        assert p._client is None


# ── OllamaObserverProvider generate() + _check_model ──


class TestOllamaGenerate:
    """mock HTTP 层测试 Ollama provider"""

    @pytest.fixture()
    def ollama_config(self):
        config = Config()
        config.ollama_url = "http://localhost:11434"
        config.cognition_model = "qwen2.5:7b"
        return config

    def test_check_model_found(self, ollama_config):
        """模型存在 → available=True"""
        p = OllamaObserverProvider(ollama_config)
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "models": [
                {"name": "qwen2.5:7b"},
                {"name": "llama3:8b"},
            ]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.get = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p._check_model())
        assert result is True
        assert p._is_available is True
        assert p._checked is True

    def test_check_model_prefix_match(self, ollama_config):
        """模型名前缀匹配（如 qwen2.5:7b 匹配 qwen2.5:7b-q4_0）"""
        ollama_config.cognition_model = "qwen2.5"
        p = OllamaObserverProvider(ollama_config)
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "models": [{"name": "qwen2.5:7b-q4_0"}]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.get = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p._check_model())
        assert result is True

    def test_check_model_not_found(self, ollama_config):
        """模型不存在 → available=False"""
        p = OllamaObserverProvider(ollama_config)
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {
            "models": [{"name": "llama3:8b"}]
        }
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.get = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p._check_model())
        assert result is False
        assert p._is_available is False

    def test_check_model_connection_failure(self, ollama_config):
        """连接失败 → False + cooldown"""
        import httpx
        p = OllamaObserverProvider(ollama_config)
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.get = AsyncMock(
                side_effect=httpx.ConnectError("connection refused")
            )
            mc.return_value = client
            result = asyncio.run(p._check_model())
        assert result is False
        assert p._is_available is False
        assert p._cooldown.is_cooling

    def test_generate_success(self, ollama_config):
        """generate 成功 → 返回 response 文本"""
        p = OllamaObserverProvider(ollama_config)
        p._checked = True
        p._is_available = True

        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {"response": "<observation>ollama result</observation>"}
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is not None
        assert "ollama result" in result

    def test_generate_triggers_check_model(self, ollama_config):
        """未检查时 generate() 先触发 _check_model"""
        p = OllamaObserverProvider(ollama_config)
        assert p._checked is False

        # _check_model 返回 False → generate 返回 None
        with patch.object(p, "_check_model", new_callable=AsyncMock, return_value=False):
            result = asyncio.run(p.generate("test prompt"))
        assert result is None

    def test_generate_timeout(self, ollama_config):
        """超时 → 返回 None + cooldown"""
        import httpx
        p = OllamaObserverProvider(ollama_config)
        p._checked = True
        p._is_available = True

        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(side_effect=httpx.ReadTimeout("timed out"))
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result is None
        assert p._cooldown.is_cooling

    def test_generate_during_cooldown(self, ollama_config):
        """冷却中 → 直接返回 None"""
        p = OllamaObserverProvider(ollama_config)
        p._cooldown.record_failure()
        result = asyncio.run(p.generate("test prompt"))
        assert result is None

    def test_generate_empty_response(self, ollama_config):
        """response 为空字符串 → 返回空字符串（成功但无内容）"""
        p = OllamaObserverProvider(ollama_config)
        p._checked = True
        p._is_available = True

        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        mock_resp.json.return_value = {"response": ""}
        with patch.object(p, "_get_client", new_callable=AsyncMock) as mc:
            client = AsyncMock()
            client.post = AsyncMock(return_value=mock_resp)
            mc.return_value = client
            result = asyncio.run(p.generate("test prompt"))
        assert result == ""


# ── observe_tool_call 集成测试 ──


class TestObserveToolCall:
    """测试 SessionObserver.observe_tool_call 完整流程"""

    @pytest.fixture()
    def cog_db(self, tmp_path: Path) -> CogDatabase:
        db = CogDatabase(tmp_path / "otc_test.db", embedding_dim=4)
        db.connect()
        yield db
        db.close()

    def _make_provider(self, raw_response: str):
        provider = MagicMock()
        provider.name = "mock_provider"
        provider.model_name = "mock-model"
        provider.available = True
        provider.generate = AsyncMock(return_value=raw_response)
        return provider

    def test_full_flow_xml_to_db(self, cog_db):
        """XML prompt → generate → parse → DB write"""
        xml_response = """<observation>
  <type>bugfix</type>
  <title>Fixed database connection leak in hooks module</title>
  <narrative>The hooks module was not properly closing database connections after each event, leading to connection exhaustion under load.</narrative>
  <facts>
    <fact>Fixed connection leak in hooks.py by adding explicit db.close() in finally block</fact>
    <fact>Database connections were exhausted after approximately 100 hook events</fact>
  </facts>
  <concepts><concept>problem-solution</concept></concepts>
  <files><file>src/hooks.py</file></files>
</observation>"""

        provider = self._make_provider(xml_response)
        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db,
            config=Config(),
        )
        result = asyncio.run(observer.observe_tool_call(
            tool_name="Edit",
            tool_input={"file_path": "src/hooks.py", "old_string": "x", "new_string": "y"},
            tool_output="File edited successfully",
            collection="test",
            session_id="s1",
        ))
        assert result is not None
        assert len(result) >= 1
        assert any("connection" in f["assertion"].lower() for f in result)

    def test_all_providers_fail_returns_none(self, cog_db):
        """所有 provider 失败 → 返回 None"""
        provider = MagicMock()
        provider.name = "failing"
        provider.available = True
        provider.generate = AsyncMock(return_value=None)

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db,
            config=Config(),
        )
        result = asyncio.run(observer.observe_tool_call(
            tool_name="Bash",
            tool_input={"command": "pytest"},
            tool_output="FAILED",
            collection="test",
            session_id="s1",
        ))
        assert result is None

    def test_no_available_providers_returns_empty(self, cog_db):
        """无可用 provider → 返回 []（不是 None）"""
        provider = MagicMock()
        provider.name = "cooled"
        provider.available = False

        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db,
            config=Config(),
        )
        result = asyncio.run(observer.observe_tool_call(
            tool_name="Bash",
            tool_input={"command": "ls"},
            tool_output="file1.py",
            collection="test",
            session_id="s1",
        ))
        assert result == []

    def test_empty_observation_returns_empty(self, cog_db):
        """LLM 返回 <observation/> 空标签 → 返回 []"""
        provider = self._make_provider("<observation/>")
        observer = SessionObserver(
            providers=[provider],
            db_cog=cog_db,
            config=Config(),
        )
        result = asyncio.run(observer.observe_tool_call(
            tool_name="Read",
            tool_input={"file_path": "README.md"},
            tool_output="# README",
            collection="test",
            session_id="s1",
        ))
        assert result == []

    def test_provider_exception_fallback(self, cog_db):
        """第一个 provider 抛异常 → 降级到第二个"""
        failing = MagicMock()
        failing.name = "failing"
        failing.available = True
        failing.generate = AsyncMock(side_effect=RuntimeError("crash"))

        xml_resp = """<observation>
  <type>change</type>
  <title>Updated configuration file with new database settings</title>
  <narrative>Modified the database configuration to use WAL mode for better concurrency.</narrative>
  <facts>
    <fact>Changed database journal_mode from DELETE to WAL in config.py for better write concurrency</fact>
  </facts>
  <concepts><concept>what-changed</concept></concepts>
  <files><file>config.py</file></files>
</observation>"""
        fallback = self._make_provider(xml_resp)
        fallback.name = "fallback"

        observer = SessionObserver(
            providers=[failing, fallback],
            db_cog=cog_db,
            config=Config(),
        )
        result = asyncio.run(observer.observe_tool_call(
            tool_name="Edit",
            tool_input={"file_path": "config.py"},
            tool_output="ok",
            collection="test",
            session_id="s1",
        ))
        assert result is not None
        assert len(result) >= 1


# ── build_tool_prompt 截断 ──


class TestBuildToolPrompt:
    """测试 prompt 构建和截断策略"""

    def test_edit_preserves_diff(self):
        from index1.cognitive.observer import build_tool_prompt
        prompt = build_tool_prompt(
            "Edit",
            {"file_path": "test.py", "old_string": "def old():", "new_string": "def new():"},
            "File edited",
        )
        assert "def old():" in prompt
        assert "def new():" in prompt

    def test_bash_truncates_output(self):
        from index1.cognitive.observer import build_tool_prompt
        long_output = "x" * 10000
        prompt = build_tool_prompt(
            "Bash",
            {"command": "pytest"},
            long_output,
        )
        assert len(prompt) < 10000
        assert "..." in prompt

    def test_total_limit(self):
        from index1.cognitive.observer import build_tool_prompt, _PROMPT_TOTAL_LIMIT
        huge_input = {"content": "x" * 20000, "file_path": "big.py"}
        prompt = build_tool_prompt("Write", huge_input, "ok")
        assert len(prompt) <= _PROMPT_TOTAL_LIMIT + 20  # +20 for truncation suffix
