# apps/api/tests/test_governance_dispatcher.py | 2026-05-03 @ Asia/Taipei
"""
Unit Tests — GovernanceDispatcher (Wave 2E)

覆蓋範圍：
  1. high confidence (>= 0.85)  → decision_path=auto_dispatch → status=pending（dispatch 建立）
  2. mid confidence (0.65-0.85)  → decision_path=pending_approval → dispatch 建立（executor=manual）
  3. low confidence (< 0.65)     → decision_path=skip → 不寫 dispatch，返回 None
  4. 重複事件：get_active_for_event 有值 → 不重複 dispatch（返回 None）
  5. LLM 失敗 fallback：fusion 拋 Exception → skip + log，不寫 dispatch
  6. _build_decision_context 完整三維欄位驗證

測試策略：mock DB / adapter / repo，不依賴真實 Postgres。
"""

from __future__ import annotations

from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ─── 模擬 AiGovernanceEvent（避免 DB 連線）────────────────────────────────

TAIPEI = timezone(timedelta(hours=8))
NOW = datetime(2026, 5, 3, 12, 0, tzinfo=TAIPEI)


def _make_governance_event(
    event_id: str = "evt-001",
    event_type: str = "trust_drift",
) -> MagicMock:
    """建立 mock AiGovernanceEvent。"""
    evt = MagicMock()
    evt.id = event_id
    evt.event_type = event_type
    evt.triggered_at = NOW
    evt.resolved = False
    evt.details = {
        "status": "warning",
        "impact": {"drifted_count": 3, "total_playbooks": 10},
        "remediation": {"next_action": "review_playbooks"},
    }
    return evt


# ─── FusedDecision factory ──────────────────────────────────────────────────

def _make_fused_decision(
    confidence: float = 0.9,
    playbook_id: str | None = "pb-001",
) -> MagicMock:
    """建立 mock FusedDecision。"""
    from src.services.decision_fusion_adapter import FusedDecision

    if confidence >= 0.85:
        path = "auto_dispatch"
    elif confidence >= 0.65:
        path = "pending_approval"
    else:
        path = "skip"

    return FusedDecision(
        confidence=confidence,
        recommended_action="啟動 Playbook 信任度修復流程",
        matched_playbook_id=playbook_id,
        playbook_trust=0.7 if playbook_id else None,
        llm_reasoning={"parsed_confidence": confidence, "parsed_action": "review"},
        mcp_snapshot={"autonomy_rate": 0.82, "_meta": {"success_count": 2, "total_queries": 2}},
        decision_path=path,
        llm_score=confidence,
        playbook_score=0.7 if playbook_id else 0.3,
        mcp_score=0.8,
    )


# =============================================================================
# Tests — dispatch_governance_event
# =============================================================================

class TestDispatchGovernanceEvent:
    """dispatch_governance_event 核心邏輯測試。"""

    @pytest.mark.asyncio
    async def test_high_confidence_creates_auto_dispatch(self):
        """confidence >= 0.85 → decision_path=auto_dispatch → dispatch 建立，executor=playbook_executor。"""
        event = _make_governance_event()
        decision = _make_fused_decision(confidence=0.90)

        mock_dispatch_row = MagicMock()
        mock_dispatch_row.id = "dispatch-001"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(return_value=mock_dispatch_row),
            ) as mock_create,
        ):
            mock_adapter = MagicMock()
            mock_adapter.fuse_decision = AsyncMock(return_value=decision)
            mock_adapter_factory.return_value = mock_adapter

            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result == "dispatch-001"
        mock_create.assert_awaited_once()
        call_kwargs = mock_create.call_args
        assert call_kwargs.kwargs["executor_type"] == "playbook_executor"
        assert call_kwargs.kwargs["event_id"] == "evt-001"
        assert call_kwargs.kwargs["event_type"] == "trust_drift"

    @pytest.mark.asyncio
    async def test_mid_confidence_creates_pending_approval(self):
        """0.65 <= confidence < 0.85 → decision_path=pending_approval → executor=manual。"""
        event = _make_governance_event()
        decision = _make_fused_decision(confidence=0.75)

        mock_dispatch_row = MagicMock()
        mock_dispatch_row.id = "dispatch-002"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(return_value=mock_dispatch_row),
            ) as mock_create,
        ):
            mock_adapter = MagicMock()
            mock_adapter.fuse_decision = AsyncMock(return_value=decision)
            mock_adapter_factory.return_value = mock_adapter

            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result == "dispatch-002"
        call_kwargs = mock_create.call_args
        assert call_kwargs.kwargs["executor_type"] == "manual"

    @pytest.mark.asyncio
    async def test_low_confidence_skips_dispatch(self):
        """confidence < 0.65 → decision_path=skip → 留下 skipped dispatch trail，返回 None。"""
        event = _make_governance_event()
        decision = _make_fused_decision(confidence=0.40)
        mock_dispatch_row = MagicMock()
        mock_dispatch_row.id = "dispatch-skipped-001"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(return_value=mock_dispatch_row),
            ) as mock_create,
            patch(
                "src.services.governance_dispatcher.transition_status",
                new=AsyncMock(),
            ) as mock_transition,
        ):
            mock_adapter = MagicMock()
            mock_adapter.fuse_decision = AsyncMock(return_value=decision)
            mock_adapter_factory.return_value = mock_adapter

            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result is None
        mock_create.assert_awaited_once()
        mock_transition.assert_awaited_once_with(
            "dispatch-skipped-001",
            "pending",
            "skipped",
        )

    @pytest.mark.asyncio
    async def test_duplicate_event_does_not_dispatch(self):
        """同一事件已有 active dispatch → 返回 None，不重複 dispatch。"""
        event = _make_governance_event()

        # 模擬已有活躍 dispatch
        existing_dispatch = MagicMock()
        existing_dispatch.id = "existing-dispatch-001"
        existing_dispatch.dispatch_status = "pending"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=existing_dispatch),
            ),
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(),
            ) as mock_create,
        ):
            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result is None
        mock_create.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_knowledge_degradation_creates_kb_healthcheck_intake(self):
        """knowledge_degradation 應先建立 non-executing Hermes KB healthcheck work item。"""
        event = _make_governance_event(event_type="knowledge_degradation")
        event.details = {
            "impact": {"stale_count": 1451, "total_count": 1870, "stale_ratio": 0.776},
            "remediation": {"next_action": "run_kb_growth_healthcheck"},
            "ownership": {"lead_agent": "Hermes"},
        }
        mock_dispatch_row = MagicMock()
        mock_dispatch_row.id = "dispatch-kb-001"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(return_value=mock_dispatch_row),
            ) as mock_create,
        ):
            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result == "dispatch-kb-001"
        mock_adapter_factory.assert_not_called()
        mock_create.assert_awaited_once()
        dispatch_kwargs = mock_create.call_args.kwargs
        assert dispatch_kwargs["executor_type"] == "hermes_kb_growth_healthcheck"
        assert dispatch_kwargs["created_by"] == "governance_dispatcher_intake"
        assert dispatch_kwargs["decision_context"]["next_action"] == "run_kb_growth_healthcheck"
        assert dispatch_kwargs["decision_context"]["workflow"]["current_stage"] == "queued_kb_healthcheck"

    @pytest.mark.asyncio
    async def test_knowledge_degradation_intake_ignores_legacy_skip_cooldown(self):
        """舊 skip cooldown 不可阻止 KM healthcheck 補建 work item。"""
        event = _make_governance_event(event_type="knowledge_degradation")
        event.details = {
            "remediation": {"next_action": "run_kb_growth_healthcheck"},
            "ownership": {"lead_agent": "Hermes"},
        }
        mock_dispatch_row = MagicMock()
        mock_dispatch_row.id = "dispatch-kb-cooldown"

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher._is_skip_cooldown",
                new=AsyncMock(return_value=True),
            ) as mock_skip_cooldown,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(return_value=mock_dispatch_row),
            ) as mock_create,
        ):
            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result == "dispatch-kb-cooldown"
        mock_skip_cooldown.assert_not_awaited()
        mock_create.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_knowledge_degradation_does_not_requeue_after_review_draft(self):
        """同一 event 已有 Hermes review draft 後，不可反覆補 pending dispatch。"""
        event = _make_governance_event(event_type="knowledge_degradation")
        event.details = {
            "remediation": {"next_action": "run_kb_growth_healthcheck"},
            "ownership": {"lead_agent": "Hermes"},
        }
        succeeded_dispatch = MagicMock()
        succeeded_dispatch.executor_type = "hermes_kb_growth_healthcheck"
        succeeded_dispatch.dispatch_status = "succeeded"
        succeeded_dispatch.decision_context = {
            "workflow": {"current_stage": "waiting_owner_review"},
            "worker_result": {"status": "draft_created"},
        }

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.list_by_event",
                new=AsyncMock(return_value=[succeeded_dispatch]),
            ) as mock_history,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(),
            ) as mock_create,
        ):
            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result is None
        mock_history.assert_awaited_once_with("evt-001")
        mock_create.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_llm_failure_fallback_to_skip(self):
        """fusion adapter 拋 Exception → fallback skip，不寫 dispatch，返回 None。"""
        event = _make_governance_event()

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(),
            ) as mock_create,
        ):
            mock_adapter = MagicMock()
            mock_adapter.fuse_decision = AsyncMock(
                side_effect=RuntimeError("Ollama 連線失敗")
            )
            mock_adapter_factory.return_value = mock_adapter

            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result is None
        mock_create.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_dispatch_already_active_race_condition(self):
        """並行建立時 DispatchAlreadyActive → 靜默返回 None（冪等）。"""
        event = _make_governance_event()
        decision = _make_fused_decision(confidence=0.90)

        from src.repositories.governance_remediation_dispatch_repo import DispatchAlreadyActive

        with (
            patch(
                "src.services.governance_dispatcher.get_active_for_event",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "src.services.governance_dispatcher.get_decision_fusion_adapter",
            ) as mock_adapter_factory,
            patch(
                "src.services.governance_dispatcher.create_dispatch",
                new=AsyncMock(side_effect=DispatchAlreadyActive("race")),
            ),
        ):
            mock_adapter = MagicMock()
            mock_adapter.fuse_decision = AsyncMock(return_value=decision)
            mock_adapter_factory.return_value = mock_adapter

            from src.services.governance_dispatcher import dispatch_governance_event
            result = await dispatch_governance_event(event)

        assert result is None


# =============================================================================
# Tests — _build_decision_context
# =============================================================================

class TestBuildDecisionContext:
    """_build_decision_context 完整三維欄位驗證。"""

    def test_decision_context_has_all_required_fields(self):
        """decision_context 必須包含完整三維輸入快照。"""
        from src.services.governance_dispatcher import _build_decision_context

        event = _make_governance_event()
        decision = _make_fused_decision(confidence=0.90)

        ctx = _build_decision_context(event, decision)

        # 版本化
        assert ctx["version"] == "v1"

        # 觸發來源
        assert ctx["trigger_source"] == "governance_dispatcher"
        assert ctx["triggered_metric"] == "trust_drift"

        # 三維分數均記錄
        fusion = ctx["fusion_scores"]
        assert "llm_score" in fusion
        assert "playbook_score" in fusion
        assert "mcp_score" in fusion
        assert "confidence" in fusion
        assert "weights" in fusion

        # LLM 推理摘要
        assert "llm_reasoning" in ctx
        assert isinstance(ctx["llm_reasoning"], dict)

        # MCP 快照
        assert "mcp_snapshot" in ctx
        assert isinstance(ctx["mcp_snapshot"], dict)

        # 決策路徑
        assert ctx["decision_path"] in ("auto_dispatch", "pending_approval", "skip")

    def test_decision_context_no_hardcoded_event_type_rules(self):
        """decision_context 不得含 hardcode event_type → playbook 對應規則。"""
        from src.services.governance_dispatcher import _build_decision_context

        for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination"):
            event = _make_governance_event(event_type=event_type)
            decision = _make_fused_decision(confidence=0.90)
            ctx = _build_decision_context(event, decision)

            # 驗證 decision 基於信心度，不是 hardcode event_type 規則
            assert ctx["decision_path"] == decision.decision_path
            assert ctx["fusion_scores"]["confidence"] == round(decision.confidence, 4)

    def test_knowledge_degradation_context_exposes_kb_work_item(self):
        """knowledge_degradation 的 next_action / owner / workflow 必須進 dispatch context."""
        from src.services.governance_dispatcher import _build_decision_context

        event = _make_governance_event(event_type="knowledge_degradation")
        event.details = {
            "impact": {
                "stale_count": 1450,
                "total_count": 1867,
                "stale_ratio": 0.777,
                "threshold": 0.2,
            },
            "remediation": {
                "next_action": "run_kb_growth_healthcheck",
            },
            "ownership": {
                "lead_agent": "Hermes",
                "support_agents": [
                    "OpenClaw：提供告警分類、規則匹配與 PlayBook 脈絡摘要，不直接批量改寫 KM。",
                    "ElephantAlpha：read-only 稽核高影響 KM 草稿與風險，不執行寫入或通知。",
                ],
                "human_owner": "KM owner / SRE owner",
            },
        }
        decision = _make_fused_decision(confidence=0.75)

        ctx = _build_decision_context(event, decision)

        assert ctx["next_action"] == "run_kb_growth_healthcheck"
        assert ctx["ownership"]["lead_agent"] == "Hermes"
        assert ctx["workflow"]["work_kind"] == "kb_growth_healthcheck"
        assert ctx["workflow"]["current_stage"] == "queued_kb_healthcheck"
        assert ctx["workflow"]["stage_by_dispatch_status"]["skipped"] == "waiting_owner_review"
        assert ctx["workflow"]["writes_km_without_approval"] is False


# =============================================================================
# Tests — DecisionFusionAdapter._build_decision_context (adapter 本身單元)
# =============================================================================

class TestDecisionFusionAdapterHelpers:
    """DecisionFusionAdapter 靜態輔助方法測試。"""

    def test_summarize_details_with_impact(self):
        """summarize_details 應提取 impact / status 等關鍵欄位。"""
        from src.services.decision_fusion_adapter import DecisionFusionAdapter

        details = {
            "status": "warning",
            "impact": {"drifted_count": 3, "threshold": 0.2},
            "remediation": {"next_action": "run_playbook"},
        }
        summary = DecisionFusionAdapter._summarize_details(details)

        assert "status" in summary
        assert "warning" in summary
        assert len(summary) <= 300

    def test_summarize_details_empty(self):
        """空 details → 返回預設提示，不崩潰。"""
        from src.services.decision_fusion_adapter import DecisionFusionAdapter

        summary = DecisionFusionAdapter._summarize_details({})
        assert summary == "（無詳細資訊）"

    def test_get_mcp_queries_returns_base_for_all_types(self):
        """所有 event_type 都應包含基礎指標查詢。"""
        from src.services.decision_fusion_adapter import DecisionFusionAdapter

        for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination",
                           "execution_blast_radius", "governance_slo_data_gap"):
            queries = DecisionFusionAdapter._get_mcp_queries(event_type)
            assert "autonomy_rate" in queries
            assert "decision_accuracy" in queries
            assert len(queries) >= 2

    def test_extract_keywords_from_details(self):
        """_extract_keywords 應從 remediation/actionable/impact 中提取關鍵字。"""
        from src.services.decision_fusion_adapter import DecisionFusionAdapter

        details = {
            "remediation": {
                "next_action": "run_kb_growth_healthcheck",
                "items": ["check_index", "rebuild_embeddings"],
            },
        }
        keywords = DecisionFusionAdapter._extract_keywords(details)
        assert len(keywords) <= 5
        assert "run_kb_growth_healthcheck" in keywords


# =============================================================================
# Tests — run_governance_dispatcher_loop（loop 邏輯）
# =============================================================================

class TestRunGovernanceDispatcherLoop:
    """run_governance_dispatcher_loop 排程迴圈行為測試。"""

    def test_poll_unresolved_events_excludes_active_dispatch_rows(self):
        """poll SQL 必須跳過已有 active dispatch 的事件，避免 backlog 餓死。"""
        import inspect
        from src.services import governance_dispatcher

        source = inspect.getsource(governance_dispatcher._poll_unresolved_events)

        assert "GovernanceRemediationDispatch" in source
        assert "ACTIVE_STATUSES" in source
        assert ".where(~active_dispatch_exists)" in source

    @pytest.mark.asyncio
    async def test_loop_processes_events_and_sleeps(self):
        """loop 一次 cycle 應處理 events 並 sleep。"""
        event = _make_governance_event()

        call_count = 0

        async def mock_sleep(seconds):
            nonlocal call_count
            call_count += 1
            if call_count >= 2:
                raise asyncio.CancelledError()

        with (
            patch(
                "src.services.governance_dispatcher._poll_unresolved_events",
                new=AsyncMock(return_value=[event]),
            ),
            patch(
                "src.services.governance_dispatcher.dispatch_governance_event",
                new=AsyncMock(return_value="dispatch-new"),
            ),
            patch("asyncio.sleep", side_effect=mock_sleep),
        ):
            import asyncio
            from src.services.governance_dispatcher import run_governance_dispatcher_loop

            with pytest.raises(asyncio.CancelledError):
                await run_governance_dispatcher_loop(interval_seconds=1)

        # sleep 被呼叫至少一次
        assert call_count >= 1

    @pytest.mark.asyncio
    async def test_loop_no_events_does_not_crash(self):
        """無事件時 loop 應平穩 sleep，不報錯。"""
        call_count = 0

        async def mock_sleep(seconds):
            nonlocal call_count
            call_count += 1
            if call_count >= 1:
                raise asyncio.CancelledError()

        with (
            patch(
                "src.services.governance_dispatcher._poll_unresolved_events",
                new=AsyncMock(return_value=[]),
            ),
            patch("asyncio.sleep", side_effect=mock_sleep),
        ):
            import asyncio
            from src.services.governance_dispatcher import run_governance_dispatcher_loop

            with pytest.raises(asyncio.CancelledError):
                await run_governance_dispatcher_loop(interval_seconds=1)

        assert call_count >= 1
