"""hooks.py 事件处理器测试"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from index1.cognitive.db_cog import CogDatabase
from index1.cognitive.hooks import HookHandler, _detect_collection


@pytest.fixture(autouse=True)
def _isolate_hooks_state(tmp_path, monkeypatch):
    """隔离 INDEX1_HOME 到 tmp_path，防止 FileStateCircuitBreaker 读取真实环境的 CB 状态"""
    monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path / ".claude-index1")


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


@pytest.fixture()
def handler(cog_db: CogDatabase) -> HookHandler:
    from index1.config import Config
    # Disable LLM observer to isolate L0 rule-based extraction
    config = Config(observer_enabled=False)
    return HookHandler(cog_db, config=config)


def _base_data(**overrides) -> dict:
    data = {"cwd": "/home/user/my-project", "session_id": "test-session"}
    data.update(overrides)
    return data


# ── _detect_collection ──


class TestDetectCollection:
    def test_from_cwd(self):
        assert _detect_collection({"cwd": "/home/user/my-project"}) == "my-project"

    def test_from_env(self, monkeypatch):
        monkeypatch.setenv("CLAUDE_PROJECT_DIR", "/opt/workspace/cool-app")
        assert _detect_collection({}) == "cool-app"

    def test_env_takes_priority(self, monkeypatch):
        monkeypatch.setenv("CLAUDE_PROJECT_DIR", "/opt/app-a")
        assert _detect_collection({"cwd": "/home/user/app-b"}) == "app-a"

    def test_default_when_empty(self, monkeypatch):
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        assert _detect_collection({}) == "default"


# ── awaken (SessionStart) ──


class TestAwaken:
    def test_basic_output(self, handler: HookHandler, cog_db: CogDatabase):
        result = handler.awaken(_base_data())
        assert "output" in result
        assert "my-project" in result["output"]
        assert "Session #1" in result["output"]

    def test_with_session_record(self, handler: HookHandler, cog_db: CogDatabase):
        """#260: awaken 从 session_record 注入结构化标签（替代旧 brief 管线）"""
        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Session record for auth fix",
            "category": "session_record",
            "confidence": 1.0,
            "session_id": "s1",
            "context_json": json.dumps({
                "session_record": {
                    "files_changed": ["src/auth.py"],
                    "key_changes": [{"action": "fix", "identifiers": ["login"]}],
                    "patterns": ["error-fix"],
                    "outcome": "tests passed",
                },
            }),
        })
        result = handler.awaken(_base_data())
        assert "[prev_session]" in result["output"]
        assert "auth.py" in result["output"]

    def test_with_top_facts(self, handler: HookHandler, cog_db: CogDatabase):
        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Database uses PostgreSQL with pgvector extension",
            "confidence": 0.95,
            "session_id": "prev",
        })
        result = handler.awaken(_base_data())
        assert "PostgreSQL" in result["output"]

    def test_fallback_collection_default(self, handler: HookHandler, monkeypatch):
        """无 cwd 和环境变量时使用 'default' collection"""
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        result = handler.awaken({"session_id": "s1"})
        assert "output" in result
        assert "default" in result["output"]

    def test_empty_db_returns_minimal(self, handler: HookHandler):
        result = handler.awaken(_base_data())
        assert "output" in result
        # Should have at least the project/session line
        assert "my-project" in result["output"]

    def test_session_count_increments(self, handler: HookHandler, cog_db: CogDatabase):
        handler.awaken(_base_data())
        result = handler.awaken(_base_data())
        assert "Session #2" in result["output"]


# ── orient (UserPromptSubmit) ──


class TestOrient:
    def test_finds_relevant_facts(self, handler: HookHandler, cog_db: CogDatabase):
        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Authentication uses JWT tokens with 24h expiry",
            "confidence": 0.9,
            "session_id": "prev",
        })
        result = handler.orient(_base_data(prompt="How does authentication work?"))
        assert "output" in result
        assert "JWT" in result["output"]

    def test_empty_prompt_returns_empty(self, handler: HookHandler):
        result = handler.orient(_base_data(prompt=""))
        assert result == {}

    def test_no_cwd_uses_default_collection(self, handler: HookHandler, monkeypatch):
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        result = handler.orient({"prompt": "test"})
        assert result == {}  # No facts in "default" collection → empty

    def test_no_results_returns_empty(self, handler: HookHandler):
        result = handler.orient(_base_data(prompt="quantum physics"))
        assert result == {}

    def test_low_confidence_filtered(self, handler: HookHandler, cog_db: CogDatabase):
        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Low confidence fact about quantum tunneling",
            "confidence": 0.2,
            "session_id": "prev",
        })
        result = handler.orient(_base_data(prompt="quantum tunneling"))
        assert result == {}

    def test_touches_accessed_facts(self, handler: HookHandler, cog_db: CogDatabase):
        fid = cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "The caching layer uses Redis for session storage",
            "confidence": 0.9,
            "session_id": "prev",
        })
        handler.orient(_base_data(prompt="caching redis session"))
        fact = cog_db.get_fact(fid)
        assert fact["access_count"] >= 1


# ── observe (PostToolUse) ──


class TestObserve:
    def test_captures_file_edit(self, handler: HookHandler, cog_db: CogDatabase):
        data = _base_data(
            tool_name="Write",
            tool_input={"file_path": "/src/auth.py"},
            tool_response="def login():\n    pass",
        )
        handler.observe(data)
        facts = cog_db.get_session_facts("test-session", "my-project")
        assert len(facts) >= 1
        assert any("auth.py" in f["assertion"] for f in facts)

    def test_captures_bash_test(self, handler: HookHandler, cog_db: CogDatabase):
        data = _base_data(
            tool_name="Bash",
            tool_input={"command": "pytest tests/ -x -q"},
            tool_response="15 passed in 2.3s",
        )
        handler.observe(data)
        facts = cog_db.get_session_facts("test-session", "my-project")
        assert any("passed" in f["assertion"] for f in facts)

    def test_dedup_same_assertion(self, handler: HookHandler, cog_db: CogDatabase):
        data = _base_data(
            tool_name="Write",
            tool_input={"file_path": "/src/main.py"},
            tool_response="",
        )
        handler.observe(data)
        handler.observe(data)  # same again
        facts = cog_db.get_session_facts("test-session", "my-project")
        modified = [f for f in facts if f["assertion"] == "Modified /src/main.py"]
        assert len(modified) == 1

    def test_dedup_jaccard_similar(self, handler: HookHandler, cog_db: CogDatabase):
        """Jaccard 相似度去重：高度相似的 assertion 应只写入一条"""
        from unittest.mock import patch

        # Jaccard: 6/7 ≈ 0.857 > 0.75 threshold
        original = "Fixed authentication bug in login handler"
        similar = "Fixed authentication bug in login handler code"

        similar_fact = {
            "collection": "my-project",
            "assertion": original,
            "category": "code",
            "session_id": "test-session",
            "confidence": 0.9,
            "source": "hook",
            "scope_files": "[]",
            "scope_modules": "[]",
            "scope_entities": "[]",
            "context_json": "{}",
            "scope": "project",
        }
        cog_db.insert_fact(similar_fact)

        data = _base_data(
            tool_name="Bash",
            tool_input={"command": "pytest"},
            tool_response="ok",
        )
        with patch(
            "index1.cognitive.capture.extract",
            return_value=[{**similar_fact, "assertion": similar}],
        ):
            handler.observe(data)

        facts = cog_db.get_session_facts("test-session", "my-project")
        auth_facts = [f for f in facts if "authentication" in f["assertion"]]
        # 应该只有 1 条（原始），相似的被 Jaccard 去重
        assert len(auth_facts) == 1

    def test_ignores_read_tool(self, handler: HookHandler, cog_db: CogDatabase):
        data = _base_data(
            tool_name="Read",
            tool_input={"file_path": "/src/main.py"},
            tool_response="file content",
        )
        handler.observe(data)
        facts = cog_db.get_session_facts("test-session", "my-project")
        assert len(facts) == 0

    def test_no_cwd_uses_default_collection(self, handler: HookHandler, cog_db: CogDatabase, monkeypatch):
        """无 cwd 时使用 'default' collection，仍然正常写入"""
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        result = handler.observe({
            "tool_name": "Write",
            "tool_input": {"file_path": "/a.py"},
            "tool_response": "",
            "session_id": "s1",
        })
        assert result == {}  # observe always returns {}
        facts = cog_db.get_session_facts("s1", "default")
        assert len(facts) >= 1

    def test_short_assertions_filtered(self, handler: HookHandler, cog_db: CogDatabase):
        """Assertions < 10 chars are skipped"""
        data = _base_data(
            tool_name="Bash",
            tool_input={"command": "ls"},
            tool_response="file1 file2",
        )
        handler.observe(data)
        facts = cog_db.get_session_facts("test-session", "my-project")
        # "ls" doesn't match any pattern, so no facts
        assert len(facts) == 0


# ── reflect (SessionEnd) ──


class TestReflect:
    def test_returns_empty(self, handler: HookHandler):
        result = handler.reflect(_base_data())
        assert result == {}

    def test_no_cwd_still_works(self, handler: HookHandler, monkeypatch):
        """无 cwd 时使用 'default' collection"""
        monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
        result = handler.reflect({"session_id": "s1"})
        assert result == {}  # reflect always returns {}

    def test_clears_pending_marker(self, handler: HookHandler, tmp_path, monkeypatch):
        """reflect() 应清除 pending maintenance 标记"""
        from index1.config import INDEX1_HOME
        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        marker = tmp_path / ".pending_maintenance_my-project"
        marker.write_text('{"session_id": "s1", "timestamp": 0}')
        handler.reflect(_base_data())
        assert not marker.exists()


# ── SessionEnd vs Stop binding ──


class TestEventBinding:
    def test_session_end_dispatches_to_reflect(self, handler: HookHandler):
        """SessionEnd 应该触发 reflect()"""
        result = handler.handle("SessionEnd", _base_data())
        assert result == {}  # reflect returns {}

    def test_stop_does_not_dispatch(self, handler: HookHandler):
        """Stop 不应该触发任何处理器"""
        result = handler.handle("Stop", _base_data())
        assert result == {}  # unknown event returns {}


# ── pending marker + fallback ──


class TestPendingMarker:
    def test_observe_writes_marker(self, handler: HookHandler, tmp_path, monkeypatch):
        """observe() 应写入 pending maintenance 标记"""
        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        data = _base_data(
            tool_name="Write",
            tool_input={"file_path": "/src/a.py"},
            tool_response="content",
        )
        handler.observe(data)
        marker = tmp_path / ".pending_maintenance_my-project"
        assert marker.exists()
        marker_data = json.loads(marker.read_text())
        assert marker_data["session_id"] == "test-session"
        assert marker_data["collection"] == "my-project"

    def test_fallback_launches_for_old_marker(
        self, handler: HookHandler, tmp_path, monkeypatch,
    ):
        """awaken() 应为旧 session 的遗留标记补跑 maintenance"""
        import time

        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        marker = tmp_path / ".pending_maintenance_my-project"
        marker.write_text(json.dumps({
            "session_id": "old-session",
            "collection": "my-project",
            "timestamp": time.time() - 120,  # 2 分钟前
        }))
        # awaken 用新 session_id → 应触发兜底
        handler.awaken(_base_data(session_id="new-session"))
        # 标记应被清除
        assert not marker.exists()

    def test_fallback_skips_recent_marker(
        self, handler: HookHandler, tmp_path, monkeypatch,
    ):
        """awaken() 不应处理 <60s 的标记（可能 SessionEnd 还在路上）"""
        import time

        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        marker = tmp_path / ".pending_maintenance_my-project"
        marker.write_text(json.dumps({
            "session_id": "old-session",
            "collection": "my-project",
            "timestamp": time.time() - 10,  # 10 秒前（太新）
        }))
        handler.awaken(_base_data(session_id="new-session"))
        # 标记不应被清除
        assert marker.exists()

    def test_fallback_skips_same_session(
        self, handler: HookHandler, tmp_path, monkeypatch,
    ):
        """awaken() 不应处理当前 session 的标记"""
        import time

        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        marker = tmp_path / ".pending_maintenance_my-project"
        marker.write_text(json.dumps({
            "session_id": "test-session",
            "collection": "my-project",
            "timestamp": time.time() - 120,
        }))
        handler.awaken(_base_data(session_id="test-session"))
        # 同一 session 的标记不处理
        assert marker.exists()

    def test_no_marker_no_fallback(self, handler: HookHandler, tmp_path, monkeypatch):
        """无标记文件时 awaken() 正常工作"""
        monkeypatch.setattr("index1.cognitive.hooks.INDEX1_HOME", tmp_path)
        result = handler.awaken(_base_data())
        assert "output" in result


# ── handle dispatch ──


class TestHandleDispatch:
    def test_unknown_event(self, handler: HookHandler):
        result = handler.handle("UnknownEvent", _base_data())
        assert result == {}

    def test_dispatch_session_start(self, handler: HookHandler):
        result = handler.handle("SessionStart", _base_data())
        assert "output" in result

    def test_dispatch_post_tool_use(self, handler: HookHandler, cog_db: CogDatabase):
        data = _base_data(
            tool_name="Write",
            tool_input={"file_path": "/a.py"},
            tool_response="content",
        )
        result = handler.handle("PostToolUse", data)
        assert result == {}  # observe returns empty (async)

    def test_dispatch_session_end(self, handler: HookHandler):
        """SessionEnd 正确分发到 reflect"""
        result = handler.handle("SessionEnd", _base_data())
        assert result == {}


# ── hook_main (integration) ──


class TestHookMain:
    def test_hook_main_session_start(self, tmp_path, monkeypatch):
        """Integration test: hook_main reads stdin, writes stdout"""
        import io

        from index1.config import INDEX1_HOME
        from index1.cognitive.hooks import hook_main

        # Set up a temporary config
        monkeypatch.setattr("index1.cognitive.hooks.load_config", lambda: _make_config(tmp_path))

        stdin_data = json.dumps({"cwd": "/home/user/test-proj", "session_id": "s1"})
        monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))

        stdout = io.StringIO()
        monkeypatch.setattr("sys.stdout", stdout)

        hook_main("SessionStart")

        output = stdout.getvalue()
        if output:
            result = json.loads(output)
            # #260: SessionStart 使用 hookSpecificOutput 格式
            if "hookSpecificOutput" in result:
                hso = result["hookSpecificOutput"]
                assert hso["hookEventName"] == "SessionStart"
                assert "additionalContext" in hso
            else:
                assert "output" in result

    def test_hook_main_disabled(self, tmp_path, monkeypatch):
        """When cognitive_enabled=False, hook_main does nothing"""
        import io

        from index1.cognitive.hooks import hook_main

        config = _make_config(tmp_path)
        config.cognitive_enabled = False
        monkeypatch.setattr("index1.cognitive.hooks.load_config", lambda: config)

        stdin_data = json.dumps({"cwd": "/proj"})
        monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))

        stdout = io.StringIO()
        monkeypatch.setattr("sys.stdout", stdout)

        hook_main("SessionStart")

        assert stdout.getvalue() == ""

    def test_hook_main_catches_all_exceptions(self, monkeypatch):
        """hook_main never raises — always exit 0"""
        import io

        from index1.cognitive.hooks import hook_main

        # Force an exception by providing invalid stdin
        monkeypatch.setattr("sys.stdin", io.StringIO("{invalid json"))
        monkeypatch.setattr("sys.stdout", io.StringIO())

        # Should NOT raise
        hook_main("SessionStart")

    def test_hook_main_empty_stdin(self, tmp_path, monkeypatch):
        """Empty stdin should not crash"""
        import io

        from index1.cognitive.hooks import hook_main

        monkeypatch.setattr("index1.cognitive.hooks.load_config", lambda: _make_config(tmp_path))
        monkeypatch.setattr("sys.stdin", io.StringIO(""))
        monkeypatch.setattr("sys.stdout", io.StringIO())

        hook_main("SessionStart")


# ── awaken memory tip injection ──


class TestAwakenTip:
    """Issue #82: Tip 已移除 — 最坏假设设计原则（Claude 不会主动调用 cog_save_*）"""

    def test_no_tip_even_with_many_facts(self, handler: HookHandler, cog_db: CogDatabase):
        """即使 facts > 5 也不应注入 cog_save_fact Tip（已移除）"""
        for i in range(6):
            cog_db.insert_fact({
                "collection": "my-project",
                "assertion": f"Fact number {i} about project feature {i}",
                "confidence": 0.9,
                "session_id": "prev",
            })
        result = handler.awaken(_base_data())
        assert "cog_save_fact" not in result["output"]

    def test_no_tip_for_new_project(self, handler: HookHandler, cog_db: CogDatabase):
        """facts 少于 5 时也不注入提示"""
        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Single fact about auth",
            "confidence": 0.9,
            "session_id": "prev",
        })
        result = handler.awaken(_base_data())
        assert "cog_save_fact" not in result["output"]


# ── observe stateless (G6 fix) ──
# Note: Session events tracking, task context tracking, and fix-then-pass
# detection were removed from observe in Issue #108 (G6 fix).
# Hook processes create new instances each call, so in-memory state was
# always empty. Pattern detection moved to maintenance pipeline.


class TestAwakenTaskContext:
    def test_shows_last_task(self, handler: HookHandler, cog_db: CogDatabase):
        """awaken 显示上次的 current_task"""
        cog_db.get_or_create_session("test-session", "my-project")
        cog_db.update_session_context(
            "test-session", current_task="fix #80",
        )
        result = handler.awaken(_base_data())
        assert "fix #80" in result["output"]

    def test_shows_pending_actions(self, handler: HookHandler, cog_db: CogDatabase):
        """awaken 显示 pending actions"""
        cog_db.get_or_create_session("test-session", "my-project")
        cog_db.update_session_context(
            "test-session",
            pending_actions=json.dumps({"/src/auth.py": "modified, needs testing"}),
        )
        result = handler.awaken(_base_data())
        assert "Pending" in result["output"]
        assert "auth.py" in result["output"]


class TestOrientExtractTask:
    def test_extracts_issue_number(self, handler: HookHandler, cog_db: CogDatabase):
        """orient 从 prompt 提取 issue 编号并更新 current_task"""
        handler.awaken(_base_data())  # create session
        handler.orient(_base_data(prompt="fix #80 task context tracking"))
        ctx = cog_db.get_session_context("test-session")
        if ctx:
            assert "80" in (ctx.get("current_task") or "")


class TestOrientUnifiedSearch:
    """orient 统一搜索升级测试"""

    def test_orient_unified_with_db(self, cog_db: CogDatabase, tmp_path: Path):
        """orient 有 corpus DB 时使用统一搜索"""
        from unittest.mock import MagicMock, patch, AsyncMock
        from index1.config import Config
        from index1.unified_search import UnifiedResult, CodeItem, FactItem

        config = Config(embed_backend="onnx")
        mock_db = MagicMock()
        handler = HookHandler(cog_db, config=config, db=mock_db)
        handler.awaken(_base_data())

        # Mock unified_recall 返回分组结果
        mock_result = UnifiedResult(
            code=[CodeItem(id=1, score=0.9, source="src/db.py",
                          section="connect", content="def connect()...", symbols=["connect"])],
            facts=[FactItem(id=10, score=0.85, assertion="Use WAL mode for concurrency",
                           category="code", confidence=0.85)],
            mode="hybrid",
            hint="发现 1 条相关经验；找到 1 个代码片段",
        )

        with patch.object(handler, "_orient_unified_search", return_value=mock_result):
            result = handler.orient(_base_data(prompt="database connection"))

        assert "output" in result
        output = result["output"]
        assert "WAL mode" in output
        assert "connect" in output or "db.py" in output

    def test_orient_degrades_without_db(self, cog_db: CogDatabase):
        """orient 没有 corpus DB 时降级为 cognition-only BM25"""
        handler = HookHandler(cog_db, db=None)
        handler.awaken(_base_data())

        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Always use parameterized SQL queries",
            "confidence": 0.9,
        })

        result = handler.orient(_base_data(prompt="SQL queries"))
        assert "output" in result
        assert "parameterized" in result["output"]

    def test_orient_unified_empty_result(self, cog_db: CogDatabase):
        """统一搜索无结果时返回空"""
        from unittest.mock import MagicMock, patch
        from index1.config import Config
        from index1.unified_search import UnifiedResult

        config = Config(embed_backend="onnx")
        mock_db = MagicMock()
        handler = HookHandler(cog_db, config=config, db=mock_db)
        handler.awaken(_base_data())

        with patch.object(handler, "_orient_unified_search",
                         return_value=UnifiedResult()):
            result = handler.orient(_base_data(prompt="nonexistent topic"))

        assert result == {}

    def test_orient_unified_fallback_on_failure(self, cog_db: CogDatabase):
        """统一搜索失败时降级到 BM25"""
        from unittest.mock import MagicMock, patch
        from index1.config import Config

        config = Config(embed_backend="onnx")
        mock_db = MagicMock()
        handler = HookHandler(cog_db, config=config, db=mock_db)
        handler.awaken(_base_data())

        cog_db.insert_fact({
            "collection": "my-project",
            "assertion": "Critical fact about error handling patterns",
            "confidence": 0.9,
        })

        # unified search 返回 None（失败）→ 降级到 BM25
        with patch.object(handler, "_orient_unified_search", return_value=None):
            result = handler.orient(_base_data(prompt="error handling"))

        assert "output" in result
        assert "error handling" in result["output"]


def _make_config(tmp_path):
    from index1.config import Config
    return Config(
        cognitive_enabled=True,
        cognitive_db_path=str(tmp_path / "test_cog.db"),
    )


# ── Realtime 集成测试 ──


class TestRealtimeIntegration:
    """验证 hooks.py 正确集成 RealtimeUpdater"""

    def test_handler_has_realtime(self, cog_db: CogDatabase):
        """config 存在时应创建 _realtime"""
        from index1.config import Config
        config = Config()
        handler = HookHandler(cog_db, config)
        assert handler._realtime is not None

    def test_handler_no_realtime_without_config(self, cog_db: CogDatabase):
        """config=None 时 _realtime 为 None"""
        handler = HookHandler(cog_db, config=None)
        assert handler._realtime is None

    def test_observe_triggers_on_fact_saved(self, cog_db: CogDatabase, tmp_path):
        """observe 写入 fact 后触发 on_fact_saved"""
        from unittest.mock import patch as mock_patch, MagicMock
        from index1.config import Config

        config = Config()
        handler = HookHandler(cog_db, config)
        mock_rt = MagicMock()
        handler._realtime = mock_rt

        data = {
            "cwd": "/home/user/test-project",
            "session_id": "rt-test-session",
            "tool_name": "Bash",
            "tool_input": {"command": "pytest tests/ -x"},
            "tool_response": "5 passed",
        }
        handler.observe(data)

        # periodic_l0 should always be called
        assert mock_rt.periodic_l0.called

    def test_observe_periodic_l0_called(self, cog_db: CogDatabase):
        """observe 每次都调用 periodic_l0"""
        from unittest.mock import MagicMock
        from index1.config import Config

        config = Config()
        handler = HookHandler(cog_db, config)
        mock_rt = MagicMock()
        handler._realtime = mock_rt

        data = {
            "cwd": "/home/user/test-project",
            "session_id": "rt-test-session",
            "tool_name": "Read",
            "tool_input": {"file_path": "/tmp/test.py"},
            "tool_response": "file content",
        }
        handler.observe(data)
        mock_rt.periodic_l0.assert_called_once_with("test-project", "rt-test-session")

    def test_reflect_clears_marker(self, cog_db: CogDatabase, tmp_path):
        """reflect 清除 realtime marker"""
        from unittest.mock import patch as mock_patch
        from index1.config import Config
        from index1.cognitive.realtime import RealtimeUpdater

        config = Config()
        handler = HookHandler(cog_db, config)

        with mock_patch.object(RealtimeUpdater, "clear_marker") as mock_clear:
            data = {
                "cwd": "/home/user/test-project",
                "session_id": "rt-test-session",
            }
            handler.reflect(data)
            mock_clear.assert_called_once_with("test-project")

    def test_realtime_exception_no_propagate(self, cog_db: CogDatabase):
        """realtime 异常不影响 observe"""
        from unittest.mock import MagicMock
        from index1.config import Config

        config = Config()
        handler = HookHandler(cog_db, config)
        mock_rt = MagicMock()
        mock_rt.periodic_l0.side_effect = RuntimeError("boom")
        handler._realtime = mock_rt

        data = {
            "cwd": "/home/user/test-project",
            "session_id": "rt-test-session",
            "tool_name": "Read",
            "tool_input": {"file_path": "/tmp/test.py"},
            "tool_response": "content",
        }
        # 不应抛异常
        result = handler.observe(data)
        assert result == {}


# ── #260 CLAUDE.md 持久化 + hookSpecificOutput ──


class TestClaudeMdPersistence:
    """#260 P0: reflect 写入 CLAUDE.md + awaken 读取兜底"""

    def test_reflect_writes_claude_md(self, cog_db: CogDatabase, tmp_path):
        """reflect 将 session facts 摘要写入 .claude/CLAUDE.md"""
        from index1.config import Config

        # 准备 .claude 目录
        project_dir = tmp_path / "test-project"
        claude_dir = project_dir / ".claude"
        claude_dir.mkdir(parents=True)
        claude_md = claude_dir / "CLAUDE.md"
        claude_md.write_text("# My Project\n")

        config = Config(observer_enabled=False)
        handler = HookHandler(cog_db, config)

        # 插入 session facts
        session_id = "test-session-260"
        cog_db.get_or_create_session(
            external_id=session_id, collection="test-project",
        )
        cog_db.insert_fact({
            "assertion": "Fixed hooks.py connection bug",
            "category": "root_cause",
            "confidence": 0.9,
            "session_id": session_id,
            "collection": "test-project",
            "scope_files": json.dumps(["src/hooks.py"]),
        })
        cog_db.insert_fact({
            "assertion": "Added retry mechanism",
            "category": "feature",
            "confidence": 0.85,
            "session_id": session_id,
            "collection": "test-project",
            "scope_files": json.dumps(["src/retry.py"]),
        })

        data = {
            "cwd": str(project_dir),
            "session_id": session_id,
        }
        handler.reflect(data)

        # 验证 CLAUDE.md 被写入
        text = claude_md.read_text()
        assert "<index1-context>" in text
        assert "</index1-context>" in text
        assert "# My Project" in text  # 标签外内容保留
        assert "facts: 2" in text

    def test_reflect_skips_without_facts(self, cog_db: CogDatabase, tmp_path):
        """无 session facts 时不写入 CLAUDE.md"""
        from index1.config import Config

        project_dir = tmp_path / "empty-project"
        claude_dir = project_dir / ".claude"
        claude_dir.mkdir(parents=True)
        claude_md = claude_dir / "CLAUDE.md"
        claude_md.write_text("# Empty\n")

        config = Config(observer_enabled=False)
        handler = HookHandler(cog_db, config)

        data = {
            "cwd": str(project_dir),
            "session_id": "empty-session",
        }
        handler.reflect(data)

        # CLAUDE.md 不应有 index1-context 标签
        text = claude_md.read_text()
        assert "<index1-context>" not in text

    def test_reflect_coexists_with_claude_mem(self, cog_db: CogDatabase, tmp_path):
        """写入不影响 claude-mem 标签"""
        from index1.config import Config

        project_dir = tmp_path / "coexist-project"
        claude_dir = project_dir / ".claude"
        claude_dir.mkdir(parents=True)
        claude_md = claude_dir / "CLAUDE.md"
        claude_md.write_text(
            "<claude-mem-context>\nmem data\n</claude-mem-context>\n\n# Project\n"
        )

        config = Config(observer_enabled=False)
        handler = HookHandler(cog_db, config)

        session_id = "coexist-session"
        cog_db.get_or_create_session(
            external_id=session_id, collection="coexist-project",
        )
        cog_db.insert_fact({
            "assertion": "Some fact",
            "category": "discovery",
            "confidence": 0.8,
            "session_id": session_id,
            "collection": "coexist-project",
        })

        handler.reflect({
            "cwd": str(project_dir),
            "session_id": session_id,
        })

        text = claude_md.read_text()
        # 两套标签都在
        assert "<claude-mem-context>" in text
        assert "mem data" in text
        assert "<index1-context>" in text

    def test_awaken_reads_claude_md_fallback(
        self, cog_db: CogDatabase, tmp_path, monkeypatch,
    ):
        """session_record 不存在时从 CLAUDE.md 恢复上下文"""
        from index1.config import Config

        project_dir = tmp_path / "fallback-project"
        claude_dir = project_dir / ".claude"
        claude_dir.mkdir(parents=True)
        claude_md = claude_dir / "CLAUDE.md"

        # 写入 index1-context 标签（模拟上次 reflect 写入）
        from index1.cognitive.context_writer import write_tagged_content
        write_tagged_content(
            claude_md, "index1-context",
            "files: hooks.py\ncategories: bugfix:1\nfacts: 3",
        )

        monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project_dir))

        config = Config(
            observer_enabled=False,
            web_auto_start=False,
            watcher_auto_start=False,
        )
        handler = HookHandler(cog_db, config)

        result = handler.awaken({
            "cwd": str(project_dir),
            "session_id": "new-session",
        })

        output = result.get("output", "")
        assert "[claude_md_recovery]" in output

    def test_reflect_write_failure_silent(self, cog_db: CogDatabase, tmp_path):
        """CLAUDE.md 写入失败不阻塞 reflect"""
        from index1.config import Config

        config = Config(observer_enabled=False)
        handler = HookHandler(cog_db, config)

        session_id = "fail-session"
        cog_db.get_or_create_session(
            external_id=session_id, collection="test",
        )
        cog_db.insert_fact({
            "assertion": "Some fact",
            "category": "discovery",
            "confidence": 0.8,
            "session_id": session_id,
            "collection": "test",
        })

        # cwd 指向不存在的目录 — CLAUDE.md 写入应静默失败
        data = {
            "cwd": "/nonexistent/project",
            "session_id": session_id,
        }
        # 不应抛异常
        result = handler.reflect(data)
        assert result == {}


class TestHookSpecificOutput:
    """#260 P2: SessionStart 使用 hookSpecificOutput 格式"""

    def test_session_start_output_format(self, tmp_path, monkeypatch):
        """SessionStart 输出 hookSpecificOutput.additionalContext"""
        import io
        from index1.cognitive.hooks import hook_main

        monkeypatch.setattr(
            "index1.cognitive.hooks.load_config", lambda: _make_config(tmp_path),
        )

        stdin_data = json.dumps({
            "cwd": "/home/user/test-proj",
            "session_id": "s1",
        })
        monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))

        stdout = io.StringIO()
        monkeypatch.setattr("sys.stdout", stdout)

        hook_main("SessionStart")

        output = stdout.getvalue()
        if output:
            result = json.loads(output)
            # 应该使用 hookSpecificOutput 格式
            assert "hookSpecificOutput" in result
            hso = result["hookSpecificOutput"]
            assert hso["hookEventName"] == "SessionStart"
            assert "additionalContext" in hso
            assert isinstance(hso["additionalContext"], str)

    def test_non_session_start_keeps_original_format(self, tmp_path, monkeypatch):
        """非 SessionStart 事件保持原格式"""
        import io
        from index1.cognitive.hooks import hook_main

        monkeypatch.setattr(
            "index1.cognitive.hooks.load_config", lambda: _make_config(tmp_path),
        )

        stdin_data = json.dumps({
            "cwd": "/home/user/test-proj",
            "session_id": "s1",
            "prompt": "hello",
        })
        monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))

        stdout = io.StringIO()
        monkeypatch.setattr("sys.stdout", stdout)

        hook_main("UserPromptSubmit")

        output = stdout.getvalue()
        if output:
            result = json.loads(output)
            # orient 不使用 hookSpecificOutput
            assert "hookSpecificOutput" not in result
