# apps/api/tests/test_ai_governance_endpoints.py | 2026-05-02 @ Asia/Taipei
"""
Unit Tests — AI Governance Endpoints (PR 1)

覆蓋範圍：
  1. events endpoint 分頁邏輯正確
  2. events endpoint severity 映射正確（critical / warning / info）
  3. queue endpoint graceful fallback（mock ProgrammingError）
  4. summary endpoint compliance_rate 計算（含 total=0 邊界）
  5. summary endpoint compliance_rate 計算（有 unresolved 的正常情況）

測試策略：mock service 層函式，不依賴 DB，確保 Router 邏輯正確。
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from src.api.v1.ai_governance import router
from src.db.models import GovernanceRemediationDispatch, KnowledgeEntryRecord
from src.models.governance import (
    DailyCount,
    DispatchItem,
    GovernanceEvent,
    GovernanceEventsResponse,
    GovernanceQueueResponse,
    GovernanceSummaryResponse,
    KnowledgeReviewDraftArchiveRequest,
    KnowledgeReviewDraftArchiveResponse,
    KnowledgeReviewDraftDedupeGroup,
    KnowledgeReviewDraftDedupeResponse,
    KnowledgeReviewDraftStaleRatioSnapshot,
    KnowledgeStaleCandidate,
    KnowledgeStaleCandidatesResponse,
    KnowledgeStaleOwnerReviewBatchItem,
    KnowledgeStaleOwnerReviewBatchQueueRequest,
    KnowledgeStaleOwnerReviewBatchQueueResponse,
    KnowledgeStaleOwnerReviewBurnDownItem,
    KnowledgeStaleOwnerReviewBurnDownResponse,
    KnowledgeStaleOwnerReviewCompleteRequest,
    KnowledgeStaleOwnerReviewCompleteResponse,
    KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
    KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
    KnowledgeStaleOwnerReviewCompletionQueueItem,
    KnowledgeStaleOwnerReviewCompletionQueueResponse,
    KnowledgeStaleOwnerReviewInboxItem,
    KnowledgeStaleOwnerReviewInboxResponse,
    KnowledgeStaleOwnerReviewRequest,
    KnowledgeStaleOwnerReviewResponse,
    map_severity,
)
from src.models.knowledge import EntrySource, EntryStatus, EntryType
from src.services.governance_km_review_service import (
    KmReviewDraftArchiveError,
    _build_dry_run_plan_fingerprint,
    _build_stale_ratio_recheck_context,
    _validate_archive_request_against_plan,
    _validate_dry_run_plan_fingerprint,
)
from src.services.governance_km_stale_review_service import (
    KmStaleOwnerReviewError,
    _build_batch_owner_review_decision_context,
    _build_batch_queue_plan_fingerprint,
    _build_completion_batch_preview_fingerprint,
    _build_completion_plan_fingerprint,
    _build_completion_queue_item,
    _build_owner_review_burndown_items,
    _build_owner_review_completion_audit_context,
    _build_owner_review_inbox_item,
    _build_stale_owner_review_decision_context,
    _completion_stage_for_outcome,
    _entries_to_stale_threshold,
)
from src.services.governance_km_stale_review_service import (
    _build_stale_ratio_recheck_context as _build_stale_owner_review_recheck_context,
)
from src.services.governance_query_service import (
    _build_km_review_draft_dedupe_groups,
    _build_km_stale_candidate,
    _extract_archived_count,
    _extract_dry_run_plan_fingerprint,
    _extract_governance_event_id_from_tags,
    _extract_kb_draft_entry_id,
    _extract_remediation,
    _extract_stale_ratio_snapshot,
    _extract_worker_status,
    _merge_dispatch_ids,
    _query_dispatch_table,
    _to_governance_event,
)

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


# =============================================================================
# Fixture
# =============================================================================

@pytest.fixture
def client():
    app = FastAPI()
    app.include_router(router, prefix="/api/v1")
    return TestClient(app)


def _make_event(
    event_id: str = "evt-001",
    event_type: str = "slo_violation",
    resolved: bool = False,
) -> GovernanceEvent:
    return GovernanceEvent(
        id=event_id,
        event_type=event_type,
        severity=map_severity(event_type),
        triggered_at=NOW,
        resolved=resolved,
        resolved_at=None,
        impact="SLO violated",
        details={"message": "test"},
        remediation=None,
        dispatch_ids=[],
    )


# =============================================================================
# 1. severity 映射單元測試
# =============================================================================

class TestSeverityMapping:
    def test_critical_types(self):
        for et in ("slo_violation", "conservative_mode", "governance_slo_data_gap"):
            assert map_severity(et) == "critical", f"{et} should be critical"

    def test_warning_types(self):
        for et in ("trust_drift", "kb_stale", "knowledge_degradation", "execution_blast_radius"):
            assert map_severity(et) == "warning", f"{et} should be warning"

    def test_info_types(self):
        for et in ("replay_degraded", "self_demotion", "llm_hallucination", "unknown_event"):
            assert map_severity(et) == "info", f"{et} should be info"


# =============================================================================
# 2. events endpoint 分頁
# =============================================================================

class TestEventsEndpoint:
    def test_pagination_default(self, client):
        """page=1 size=20 預設分頁正確."""
        fake_response = GovernanceEventsResponse(
            items=[_make_event(str(i)) for i in range(5)],
            total=5,
            page=1,
            size=20,
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_events",
            new_callable=lambda: lambda **kw: None,
        ):
            with patch(
                "src.api.v1.ai_governance.query_governance_events",
                new=AsyncMock(return_value=fake_response),
            ):
                r = client.get("/api/v1/ai/governance/events")
        assert r.status_code == 200
        data = r.json()
        assert data["total"] == 5
        assert data["page"] == 1
        assert data["size"] == 20
        assert len(data["items"]) == 5

    def test_pagination_custom(self, client):
        """自訂分頁參數傳入 service."""
        fake_response = GovernanceEventsResponse(
            items=[_make_event()],
            total=50,
            page=3,
            size=10,
        )
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return fake_response

        with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
            r = client.get("/api/v1/ai/governance/events?page=3&size=10")

        assert r.status_code == 200
        assert captured["page"] == 3
        assert captured["size"] == 10
        data = r.json()
        assert data["total"] == 50

    def test_severity_filter_passed(self, client):
        """severity query param 正確傳入 service."""
        fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20)
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return fake_response

        with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
            r = client.get("/api/v1/ai/governance/events?severity=critical")

        assert r.status_code == 200
        assert captured["severity"] == "critical"

    def test_event_id_filter_passed(self, client):
        """event_id query param 供 Telegram 詳情 / 歷史精準回看."""
        fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20)
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return fake_response

        with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
            r = client.get(
                "/api/v1/ai/governance/events"
                "?event_id=evt-001&event_id=evt-002&status=unresolved"
            )

        assert r.status_code == 200
        assert captured["event_ids"] == ["evt-001", "evt-002"]
        assert captured["status"] == "unresolved"

    def test_invalid_severity_rejected(self, client):
        """非法 severity 值應被拒絕（422）."""
        r = client.get("/api/v1/ai/governance/events?severity=bad_value")
        assert r.status_code == 422

    def test_invalid_status_rejected(self, client):
        """非法 status 值應被拒絕（422）."""
        r = client.get("/api/v1/ai/governance/events?status=invalid")
        assert r.status_code == 422

    def test_severity_in_response(self, client):
        """回傳的事件 severity 欄位對應 event_type 映射."""
        events = [
            _make_event("e1", "slo_violation"),    # critical
            _make_event("e2", "trust_drift"),       # warning
            _make_event("e3", "self_demotion"),     # info
        ]
        fake_response = GovernanceEventsResponse(items=events, total=3, page=1, size=20)

        with patch(
            "src.api.v1.ai_governance.query_governance_events",
            new=AsyncMock(return_value=fake_response),
        ):
            r = client.get("/api/v1/ai/governance/events")

        assert r.status_code == 200
        items = r.json()["items"]
        assert items[0]["severity"] == "critical"
        assert items[1]["severity"] == "warning"
        assert items[2]["severity"] == "info"


class TestEventsReadSideNormalization:
    def test_remediation_dict_is_normalized_to_string(self):
        """production details.remediation 可能是 dict，response schema 必須仍回字串."""
        remediation = _extract_remediation({
            "remediation": {
                "items": [
                    "補齊 ADR-100 SLO emitter",
                    "設置 PROMETHEUS_MULTIPROC_DIR",
                ]
            }
        })

        assert remediation == "補齊 ADR-100 SLO emitter；設置 PROMETHEUS_MULTIPROC_DIR"

    def test_governance_event_accepts_dict_remediation(self):
        """dict remediation 不應讓 GovernanceEvent Pydantic validation 變成 500."""
        row = type("Row", (), {
            "id": "evt-001",
            "event_type": "governance_slo_data_gap",
            "triggered_at": NOW,
            "resolved": False,
            "resolved_at": None,
            "details": {
                "message": "SLO metrics missing",
                "remediation": {"items": ["補齊 SLO emitter"]},
            },
        })()

        event = _to_governance_event(row)

        assert event.remediation == "補齊 SLO emitter"
        assert event.impact == "SLO metrics missing"

    def test_governance_event_uses_db_dispatch_ids_first(self):
        """events read model 應以 dispatch table ids 補齊 detail/history 鏈路。"""
        row = type("Row", (), {
            "id": "evt-001",
            "event_type": "knowledge_degradation",
            "triggered_at": NOW,
            "resolved": False,
            "resolved_at": None,
            "details": {
                "message": "KM stale",
                "dispatch_ids": ["legacy-dispatch"],
            },
        })()

        event = _to_governance_event(row, dispatch_ids=["db-dispatch", "legacy-dispatch"])

        assert event.dispatch_ids == ["db-dispatch", "legacy-dispatch"]

    def test_merge_dispatch_ids_deduplicates_and_preserves_db_priority(self):
        """DB truth-first，legacy payload 只作 fallback。"""
        assert _merge_dispatch_ids(
            ["db-2", "db-1"],
            ["db-1", "legacy-1", None],
        ) == ["db-2", "db-1", "legacy-1"]

    def test_events_query_joins_dispatch_table_for_history_buttons(self):
        """events endpoint 不可只讀 details.dispatch_ids，必須查 dispatch table。"""
        import inspect

        from src.services import governance_query_service

        source = inspect.getsource(governance_query_service._load_dispatch_ids_for_events)

        assert "FROM governance_remediation_dispatch d" in source
        assert "d.governance_event_id IN :event_ids" in source
        assert "ORDER BY d.dispatched_at DESC" in source


# =============================================================================
# 3. queue endpoint graceful fallback
# =============================================================================

class TestQueueEndpoint:
    def test_graceful_fallback_on_programming_error(self, client):
        """dispatch 表不存在時回 table_pending=true，不拋 500."""
        fallback = GovernanceQueueResponse(
            items=[], total=0, page=1, size=10, table_pending=True,
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_queue",
            new=AsyncMock(return_value=fallback),
        ):
            r = client.get("/api/v1/ai/governance/queue")

        assert r.status_code == 200
        data = r.json()
        assert data["table_pending"] is True
        assert data["items"] == []
        assert data["total"] == 0

    def test_normal_response_when_table_ready(self, client):
        """表就緒時正常回傳 items."""
        dispatch_item = DispatchItem(
            id="d-001",
            governance_event_id="evt-001",
            event_type="slo_violation",
            dispatch_status="pending",
            proposed_action="restart deployment",
            playbook_id=None,
            playbook_trust=None,
            created_at=NOW,
            dispatched_at=None,
            completed_at=None,
            operator_note=None,
        )
        normal = GovernanceQueueResponse(
            items=[dispatch_item], total=1, page=1, size=10, table_pending=False,
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_queue",
            new=AsyncMock(return_value=normal),
        ):
            r = client.get("/api/v1/ai/governance/queue")

        assert r.status_code == 200
        data = r.json()
        assert data["table_pending"] is False
        assert len(data["items"]) == 1
        assert data["items"][0]["dispatch_status"] == "pending"

    def test_invalid_dispatch_status_rejected(self, client):
        """非法 dispatch_status 應被拒絕（422）."""
        r = client.get("/api/v1/ai/governance/queue?dispatch_status=unknown")
        assert r.status_code == 422

    def test_all_status_and_event_type_filter_passed(self, client):
        """Work Items 需要 all + event_type 查 KM healthcheck 全狀態鏈。"""
        normal = GovernanceQueueResponse(
            items=[], total=0, page=1, size=20, table_pending=False,
        )
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return normal

        with patch("src.api.v1.ai_governance.query_governance_queue", new=mock_query):
            r = client.get(
                "/api/v1/ai/governance/queue?dispatch_status=all"
                "&event_type=knowledge_degradation&size=20"
            )

        assert r.status_code == 200
        assert captured["dispatch_status"] == "all"
        assert captured["event_types"] == ["knowledge_degradation"]
        assert captured["size"] == 20

    def test_queue_item_exposes_kb_draft_review_context(self, client):
        """Work Items 需要看到 Hermes 草稿 ID 與 worker 狀態，而不只 dispatch succeeded。"""
        item = DispatchItem(
            id="dispatch-001",
            governance_event_id="event-001",
            event_type="knowledge_degradation",
            dispatch_status="succeeded",
            executor_type="hermes_kb_growth_healthcheck",
            proposed_action="Hermes 已建立 KM 更新草稿，等待 owner 審核",
            created_at=NOW,
            workflow_stage="waiting_owner_review",
            next_action="owner_review_km_draft",
            lead_agent="Hermes",
            human_owner="KM owner / SRE owner",
            kb_draft_entry_id="km-001",
            worker_status="draft_created",
        )
        normal = GovernanceQueueResponse(
            items=[item], total=1, page=1, size=20, table_pending=False,
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_queue",
            new=AsyncMock(return_value=normal),
        ):
            r = client.get(
                "/api/v1/ai/governance/queue?dispatch_status=all"
                "&event_type=knowledge_degradation&size=20"
            )

        assert r.status_code == 200
        data = r.json()
        assert data["items"][0]["kb_draft_entry_id"] == "km-001"
        assert data["items"][0]["worker_status"] == "draft_created"

    def test_extract_kb_draft_review_context_from_decision_context(self):
        """queue read model 從 workflow / worker_result 補出 KM 草稿追蹤欄位。"""
        context = {
            "workflow": {"kb_draft_entry_id": "km-workflow"},
            "worker_result": {
                "km_draft_entry_id": "km-worker",
                "status": "draft_created",
            },
        }

        assert _extract_kb_draft_entry_id(context) == "km-workflow"
        assert _extract_worker_status(context) == "draft_created"

    def test_extract_km_archive_audit_context_from_decision_context(self):
        """queue read model 應把 archive audit / stale ratio recheck 證據交給 Work Items。"""
        context = {
            "archived_count": 5,
            "dry_run_plan_fingerprint": "sha256:" + "a" * 64,
            "stale_ratio_snapshot": {
                "stale_count": 120,
                "total_count": 200,
                "stale_ratio": 0.6,
                "threshold": 0.2,
                "stale_days": 7,
                "extra": "ignored",
            },
            "workflow": {
                "archived_entry_ids": ["fallback"],
                "dry_run_plan_fingerprint": "sha256:" + "b" * 64,
            },
        }

        assert _extract_dry_run_plan_fingerprint(context) == "sha256:" + "a" * 64
        assert _extract_archived_count(context) == 5
        snapshot = _extract_stale_ratio_snapshot(context)
        assert snapshot == {
            "stale_count": 120,
            "total_count": 200,
            "stale_ratio": 0.6,
            "threshold": 0.2,
            "stale_days": 7,
        }

    def test_queue_query_uses_production_dispatch_schema(self):
        """queue 查詢必須對齊 migration schema：使用 dispatched_at，不讀不存在的 created_at/operator_note."""
        import inspect

        source = inspect.getsource(_query_dispatch_table)

        assert "d.dispatched_at AS created_at" in source
        assert "ORDER BY d.dispatched_at DESC" in source
        assert "NULL::text AS operator_note" in source
        assert "CAST(:dispatch_status AS governance_dispatch_status)" in source
        assert "e.event_type::text IN :event_types" in source
        assert "d.created_at" not in source
        assert "d.operator_note" not in source


class TestKmReviewDraftDedupe:
    def test_endpoint_passes_limit_and_returns_owner_review_plan(self, client):
        """Work Items 需要 read-only canonical/duplicate plan，不直接封存 KM。"""
        fake = KnowledgeReviewDraftDedupeResponse(
            total_review_drafts=3,
            event_group_total=1,
            duplicate_draft_total=2,
            generated_at=NOW,
            groups=[
                KnowledgeReviewDraftDedupeGroup(
                    governance_event_id="event-001",
                    canonical_entry_id="km-canonical",
                    canonical_title="KM healthcheck review draft - event",
                    preferred_source="dispatch_context",
                    duplicate_entry_ids=["km-dup-1", "km-dup-2"],
                    duplicate_count=2,
                    total_entries=3,
                    suggested_action="owner_review_canonical_then_archive_duplicates",
                    owner_action="review_canonical_and_archive_duplicate_drafts",
                    writes_on_read=False,
                    can_archive_without_owner_approval=False,
                    archive_history=[
                        DispatchItem(
                            id="dispatch-archive-001",
                            governance_event_id="event-001",
                            event_type="knowledge_degradation",
                            dispatch_status="succeeded",
                            executor_type="hermes_km_review_dedupe_owner_archive",
                            proposed_action="Owner archived duplicate KM drafts",
                            created_at=NOW,
                            workflow_stage="km_duplicate_archive_after_owner_approval",
                            dry_run_plan_fingerprint="sha256:" + "a" * 64,
                            archived_count=2,
                        )
                    ],
                )
            ],
        )
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return fake

        with patch("src.api.v1.ai_governance.query_km_review_draft_dedupe", new=mock_query):
            r = client.get("/api/v1/ai/governance/km-review-drafts/dedupe?limit=50")

        assert r.status_code == 200
        assert captured["limit"] == 50
        data = r.json()
        assert data["duplicate_draft_total"] == 2
        assert data["groups"][0]["canonical_entry_id"] == "km-canonical"
        assert data["groups"][0]["writes_on_read"] is False
        assert data["groups"][0]["can_archive_without_owner_approval"] is False
        assert data["groups"][0]["archive_history"][0]["executor_type"] == (
            "hermes_km_review_dedupe_owner_archive"
        )
        assert data["groups"][0]["archive_history"][0]["archived_count"] == 2

    def test_governance_event_tag_extraction(self):
        assert _extract_governance_event_id_from_tags([
            "agent:Hermes",
            "governance_event:event-001",
        ]) == "event-001"
        assert _extract_governance_event_id_from_tags(["agent:Hermes"]) is None

    def test_build_dedupe_groups_prefers_dispatch_context_draft(self):
        """若 dispatch context 指出 canonical draft，dedupe plan 應以它為準。"""
        rows = [
            {
                "id": "km-latest",
                "title": "latest draft",
                "tags": ["governance_event:event-001"],
                "updated_at": datetime(2026, 5, 2, 14, 0, tzinfo=TAIPEI),
            },
            {
                "id": "km-canonical",
                "title": "canonical draft",
                "tags": ["governance_event:event-001"],
                "updated_at": datetime(2026, 5, 2, 13, 0, tzinfo=TAIPEI),
            },
            {
                "id": "km-other",
                "title": "other event draft",
                "tags": ["governance_event:event-002"],
                "updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI),
            },
        ]

        groups = _build_km_review_draft_dedupe_groups(
            rows,
            {"event-001": "km-canonical"},
            {
                "event-001": [
                    DispatchItem(
                        id="dispatch-recheck-001",
                        governance_event_id="event-001",
                        event_type="knowledge_degradation",
                        dispatch_status="succeeded",
                        executor_type="hermes_km_stale_ratio_recheck",
                        proposed_action="Rechecked stale ratio",
                        created_at=NOW,
                        workflow_stage="stale_ratio_recheck",
                        stale_ratio_snapshot={
                            "stale_count": 10,
                            "total_count": 100,
                            "stale_ratio": 0.1,
                            "threshold": 0.2,
                            "stale_days": 7,
                        },
                    )
                ]
            },
        )

        first = groups[0]
        assert first.governance_event_id == "event-001"
        assert first.canonical_entry_id == "km-canonical"
        assert first.preferred_source == "dispatch_context"
        assert first.duplicate_entry_ids == ["km-latest"]
        assert first.writes_on_read is False
        assert first.archive_history[0].executor_type == "hermes_km_stale_ratio_recheck"
        assert first.archive_history[0].stale_ratio_snapshot["stale_ratio"] == pytest.approx(0.1)

    def test_km_stale_candidates_endpoint_returns_read_only_priority_queue(self, client):
        """stale KM endpoint 應回傳 owner 可排序處理的 read-only 清單。"""
        fake = KnowledgeStaleCandidatesResponse(
            project_id="awoooi",
            total_stale=1490,
            returned=1,
            threshold_days=7,
            items=[
                KnowledgeStaleCandidate(
                    entry_id="km-001",
                    project_id="awoooi",
                    title="Sentry / SigNoz checkout repair runbook",
                    entry_type="auto_runbook",
                    category="AI系統",
                    status="review",
                    source="ai_extracted",
                    updated_at=NOW - timedelta(days=21),
                    stale_days=21,
                    view_count=9,
                    priority_score=265,
                    priority_tier="P0",
                    recommended_action="refresh_with_evidence",
                    reasons=[
                        "linked_incident",
                        "linked_playbook",
                        "sentry_context",
                        "signoz_context",
                    ],
                    correlation_sources=["incident", "playbook", "sentry", "signoz"],
                    related_incident_id="INC-20260513-79ED5E",
                    related_playbook_id="pb:auto-repair-canary",
                    tags=["sentry", "signoz"],
                )
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_query(**kwargs):
            captured.update(kwargs)
            return fake

        with patch("src.api.v1.ai_governance.query_km_stale_candidates", new=mock_query):
            r = client.get(
                "/api/v1/ai/governance/km-stale-candidates"
                "?project_id=awoooi&limit=25"
            )

        assert r.status_code == 200
        assert captured == {"project_id": "awoooi", "limit": 25}
        data = r.json()
        assert data["writes_on_read"] is False
        assert data["manual_review_required"] is True
        assert data["total_stale"] == 1490
        assert data["items"][0]["priority_tier"] == "P0"
        assert data["items"][0]["correlation_sources"] == [
            "incident",
            "playbook",
            "sentry",
            "signoz",
        ]

    def test_build_km_stale_candidate_prioritizes_linked_evidence(self):
        """有 Incident / PlayBook / Sentry / SigNoz 脈絡的 stale KM 應排前面。"""
        record = KnowledgeEntryRecord(
            id="km-001",
            project_id="awoooi",
            title="Sentry checkout failure repair",
            content="Use SigNoz trace and PlayBook verification before KM writeback.",
            entry_type=EntryType.AUTO_RUNBOOK,
            category="AI系統",
            tags=["sentry", "signoz", "workflow:kb_growth_healthcheck"],
            source=EntrySource.AI_EXTRACTED,
            status=EntryStatus.REVIEW,
            related_incident_id="INC-20260513-79ED5E",
            related_playbook_id="pb:auto-repair-canary",
            view_count=7,
            updated_at=NOW - timedelta(days=35),
        )

        candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7)

        assert candidate.priority_tier == "P0"
        assert candidate.recommended_action == "refresh_with_evidence"
        assert candidate.stale_days == 35
        assert candidate.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
        assert "linked_incident" in candidate.reasons
        assert "linked_playbook" in candidate.reasons
        assert "sentry_context" in candidate.reasons
        assert "signoz_context" in candidate.reasons

    def test_queue_stale_candidate_endpoint_returns_owner_review_dispatch(self, client):
        """單筆 stale KM 可以排入 owner review，但不直接改寫 KM。"""
        fake = KnowledgeStaleOwnerReviewResponse(
            entry_id="km-001",
            project_id="awoooi",
            status="queued",
            governance_event_id="event-001",
            dispatch_id="dispatch-001",
            workflow_stage="waiting_owner_review",
            recommended_action="refresh_with_evidence",
            owner="operator_console",
            owner_note="prioritize P0",
            writes_km=False,
            writes_governance_audit=True,
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_queue(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.queue_km_stale_owner_review",
            new=mock_queue,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
                json={
                    "owner": "operator_console",
                    "owner_note": "prioritize P0",
                    "dry_run": False,
                },
            )

        assert r.status_code == 200
        assert captured["entry_id"] == "km-001"
        assert isinstance(captured["request"], KnowledgeStaleOwnerReviewRequest)
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_v1"
        assert data["status"] == "queued"
        assert data["dispatch_id"] == "dispatch-001"
        assert data["workflow_stage"] == "waiting_owner_review"
        assert data["writes_km"] is False
        assert data["writes_governance_audit"] is True

    def test_queue_stale_candidate_endpoint_maps_validation_error(self, client):
        async def mock_queue(**kwargs):
            raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold")

        with patch(
            "src.api.v1.ai_governance.queue_km_stale_owner_review",
            new=mock_queue,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
                json={"owner": "operator_console"},
            )

        assert r.status_code == 409
        assert r.json()["detail"] == "KM entry is no longer past the stale threshold"

    def test_batch_queue_stale_candidate_endpoint_returns_batch_dispatch(self, client):
        """P0/P1 stale KM batch queue 應只寫治理 dispatch，不改寫 KM。"""
        fake = KnowledgeStaleOwnerReviewBatchQueueResponse(
            project_id="awoooi",
            status="queued",
            owner="operator_console",
            dry_run=False,
            priority_tiers=["P0", "P1"],
            requested_limit=10,
            candidate_count=2,
            queued_count=1,
            already_queued_count=1,
            skipped_count=0,
            batch_governance_event_id="event-batch-001",
            batch_dispatch_id="dispatch-batch-001",
            workflow_stage="batch_owner_review_queued",
            writes_km=False,
            writes_governance_audit=True,
            stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
                stale_count=119,
                total_count=200,
                stale_ratio=0.595,
                threshold=0.2,
                stale_days=7,
            ),
            dry_run_plan_fingerprint="sha256:" + "b" * 64,
            items=[
                KnowledgeStaleOwnerReviewBatchItem(
                    entry_id="km-001",
                    title="Sentry checkout failure repair",
                    priority_tier="P0",
                    recommended_action="refresh_with_evidence",
                    status="queued",
                    governance_event_id="event-001",
                    dispatch_id="dispatch-001",
                    workflow_stage="waiting_owner_review",
                ),
                KnowledgeStaleOwnerReviewBatchItem(
                    entry_id="km-002",
                    title="Already queued",
                    priority_tier="P1",
                    recommended_action="owner_review",
                    status="already_queued",
                    reason="active_owner_review_exists",
                    dispatch_id="dispatch-002",
                    workflow_stage="waiting_owner_review",
                ),
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_batch_queue(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
            new=mock_batch_queue,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
                json={
                    "project_id": "awoooi",
                    "priority_tiers": ["P0", "P1"],
                    "limit": 10,
                    "owner": "operator_console",
                    "dry_run": False,
                    "dry_run_plan_fingerprint": "sha256:" + "b" * 64,
                },
            )

        assert r.status_code == 200
        assert isinstance(captured["request"], KnowledgeStaleOwnerReviewBatchQueueRequest)
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_batch_v1"
        assert data["status"] == "queued"
        assert data["workflow_stage"] == "batch_owner_review_queued"
        assert data["writes_km"] is False
        assert data["writes_governance_audit"] is True
        assert data["batch_dispatch_id"] == "dispatch-batch-001"
        assert data["items"][0]["status"] == "queued"
        assert data["items"][1]["status"] == "already_queued"

    def test_batch_queue_stale_candidate_endpoint_maps_validation_error(self, client):
        async def mock_batch_queue(**kwargs):
            raise KmStaleOwnerReviewError(
                409,
                "dry_run_plan_fingerprint does not match the latest stale KM batch queue plan",
            )

        with patch(
            "src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
            new=mock_batch_queue,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
                json={
                    "project_id": "awoooi",
                    "limit": 10,
                    "dry_run": False,
                    "dry_run_plan_fingerprint": "sha256:" + "c" * 64,
                },
            )

        assert r.status_code == 409
        assert "batch queue plan" in r.json()["detail"]

    def test_owner_review_inbox_endpoint_returns_sorted_work_items(self, client):
        """Owner-review inbox 應回傳 pending dispatch 與 KM priority context。"""
        fake = KnowledgeStaleOwnerReviewInboxResponse(
            project_id="awoooi",
            dispatch_status="pending",
            total=1,
            returned=1,
            items=[
                KnowledgeStaleOwnerReviewInboxItem(
                    dispatch_id="dispatch-001",
                    governance_event_id="event-001",
                    entry_id="km-001",
                    project_id="awoooi",
                    title="Sentry checkout failure repair",
                    dispatch_status="pending",
                    workflow_stage="waiting_owner_review",
                    next_action="owner_review_stale_km_candidate",
                    owner="operator_console",
                    owner_note="p0_p1_stale_km_batch",
                    batch_governance_event_id="event-batch-001",
                    batch_dispatch_id="dispatch-batch-001",
                    priority_tier="P0",
                    priority_score=265,
                    recommended_action="refresh_with_evidence",
                    stale_days=35,
                    view_count=7,
                    correlation_sources=["incident", "playbook", "sentry"],
                    reasons=["linked_incident", "linked_playbook"],
                    related_incident_id="INC-20260513-79ED5E",
                    related_playbook_id="pb:auto-repair-canary",
                    queued_at=NOW,
                )
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_inbox(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.query_km_stale_owner_review_inbox",
            new=mock_inbox,
        ):
            r = client.get(
                "/api/v1/ai/governance/km-stale-owner-reviews"
                "?project_id=awoooi&dispatch_status=pending&limit=20"
            )

        assert r.status_code == 200
        assert captured == {
            "project_id": "awoooi",
            "dispatch_status": "pending",
            "limit": 20,
        }
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_inbox_v1"
        assert data["total"] == 1
        assert data["writes_on_read"] is False
        assert data["manual_review_required"] is True
        assert data["items"][0]["dispatch_id"] == "dispatch-001"
        assert data["items"][0]["workflow_stage"] == "waiting_owner_review"
        assert data["items"][0]["batch_dispatch_id"] == "dispatch-batch-001"

    def test_owner_review_inbox_context_keeps_batch_and_priority_visible(self):
        record = KnowledgeEntryRecord(
            id="km-001",
            project_id="awoooi",
            title="Sentry checkout failure repair",
            content="Use Sentry and SigNoz evidence before writeback.",
            entry_type=EntryType.AUTO_RUNBOOK,
            category="AI系統",
            tags=["sentry", "signoz"],
            source=EntrySource.AI_EXTRACTED,
            status=EntryStatus.REVIEW,
            related_incident_id="INC-20260513-79ED5E",
            related_playbook_id="pb:auto-repair-canary",
            view_count=7,
            updated_at=NOW - timedelta(days=35),
        )
        candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7)
        row = type("Row", (), {
            "id": "dispatch-001",
            "governance_event_id": "event-001",
            "dispatch_status": "pending",
            "dispatched_at": NOW,
            "started_at": None,
            "completed_at": None,
        })()
        ctx = {
            "owner": "operator_console",
            "owner_note": "p0_p1_stale_km_batch",
            "next_action": "owner_review_stale_km_candidate",
            "workflow": {
                "entry_id": "km-001",
                "project_id": "awoooi",
                "batch_governance_event_id": "event-batch-001",
                "batch_dispatch_id": "dispatch-batch-001",
                "current_stage": "waiting_owner_review",
                "stage_by_dispatch_status": {"pending": "waiting_owner_review"},
            },
        }

        item = _build_owner_review_inbox_item(
            row=row,
            candidate=candidate,
            decision_context=ctx,
        )

        assert item.dispatch_id == "dispatch-001"
        assert item.batch_dispatch_id == "dispatch-batch-001"
        assert item.priority_tier == "P0"
        assert item.priority_score > 200
        assert item.workflow_stage == "waiting_owner_review"
        assert item.recommended_action == "refresh_with_evidence"
        assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"]

    def test_owner_review_burndown_endpoint_returns_completion_progress(self, client):
        """Burn-down endpoint 應把 pending、completion audit 與 recheck snapshot 集中呈現。"""
        fake = KnowledgeStaleOwnerReviewBurnDownResponse(
            project_id="awoooi",
            burn_down_status="above_threshold",
            current_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
                stale_count=118,
                total_count=200,
                stale_ratio=0.59,
                threshold=0.2,
                stale_days=7,
            ),
            entries_to_threshold=78,
            pending_owner_reviews=9,
            completed_owner_reviews=1,
            completion_audit_total=1,
            stale_ratio_recheck_total=1,
            latest_stale_count_delta=-1,
            latest_stale_ratio_delta=-0.005,
            returned=1,
            items=[
                KnowledgeStaleOwnerReviewBurnDownItem(
                    completion_dispatch_id="dispatch-audit-001",
                    governance_event_id="event-001",
                    source_dispatch_id="dispatch-001",
                    recheck_dispatch_id="dispatch-recheck-001",
                    entry_id="km-001",
                    project_id="awoooi",
                    dispatch_status="succeeded",
                    workflow_stage="km_writeback_after_approval",
                    review_outcome="refresh_with_evidence",
                    owner="operator_console",
                    completed_at=NOW,
                    stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
                        stale_count=118,
                        total_count=200,
                        stale_ratio=0.59,
                        threshold=0.2,
                        stale_days=7,
                    ),
                    stale_count_delta=-1,
                    stale_ratio_delta=-0.005,
                    above_threshold=True,
                )
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_burndown(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.query_km_stale_owner_review_burndown",
            new=mock_burndown,
        ):
            r = client.get(
                "/api/v1/ai/governance/km-stale-owner-review-burndown"
                "?project_id=awoooi&limit=12"
            )

        assert r.status_code == 200
        assert captured == {"project_id": "awoooi", "limit": 12}
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_burndown_v1"
        assert data["burn_down_status"] == "above_threshold"
        assert data["writes_on_read"] is False
        assert data["manual_review_required"] is True
        assert data["pending_owner_reviews"] == 9
        assert data["completion_audit_total"] == 1
        assert data["stale_ratio_recheck_total"] == 1
        assert data["latest_stale_count_delta"] == -1
        assert data["items"][0]["recheck_dispatch_id"] == "dispatch-recheck-001"

    def test_owner_review_burndown_items_compute_chronological_delta(self):
        older_snapshot = {
            "stale_count": 119,
            "total_count": 200,
            "stale_ratio": 0.595,
            "threshold": 0.2,
            "stale_days": 7,
        }
        newer_snapshot = {
            "stale_count": 118,
            "total_count": 200,
            "stale_ratio": 0.59,
            "threshold": 0.2,
            "stale_days": 7,
        }
        rows = [
            SimpleNamespace(
                id="dispatch-audit-new",
                governance_event_id="event-001",
                dispatch_status="succeeded",
                completed_at=NOW,
                decision_context={
                    "project_id": "awoooi",
                    "owner": "operator_console",
                    "review_outcome": "refresh_with_evidence",
                    "workflow": {
                        "project_id": "awoooi",
                        "entry_id": "km-002",
                        "source_dispatch_id": "dispatch-002",
                        "current_stage": "km_writeback_after_approval",
                        "stale_ratio_snapshot": newer_snapshot,
                    },
                    "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-new"},
                },
            ),
            SimpleNamespace(
                id="dispatch-audit-old",
                governance_event_id="event-001",
                dispatch_status="succeeded",
                completed_at=NOW - timedelta(minutes=10),
                decision_context={
                    "project_id": "awoooi",
                    "owner": "operator_console",
                    "review_outcome": "refresh_with_evidence",
                    "workflow": {
                        "project_id": "awoooi",
                        "entry_id": "km-001",
                        "source_dispatch_id": "dispatch-001",
                        "current_stage": "km_writeback_after_approval",
                        "stale_ratio_snapshot": older_snapshot,
                    },
                    "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-old"},
                },
            ),
        ]

        items = _build_owner_review_burndown_items(rows, project_id="awoooi")

        assert items[0].completion_dispatch_id == "dispatch-audit-new"
        assert items[0].stale_count_delta == -1
        assert items[0].stale_ratio_delta == pytest.approx(-0.005)
        assert items[0].above_threshold is True
        assert items[1].stale_count_delta is None
        assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78

    def test_owner_review_completion_queue_endpoint_returns_readiness_split(self, client):
        """Completion queue 應把 ready / blocked / completed / failed 分流給前端。"""
        fake = KnowledgeStaleOwnerReviewCompletionQueueResponse(
            project_id="awoooi",
            status_bucket="all",
            total=2,
            returned=2,
            pending_count=1,
            ready_count=1,
            blocked_count=0,
            completed_count=1,
            failed_count=0,
            items=[
                KnowledgeStaleOwnerReviewCompletionQueueItem(
                    dispatch_id="dispatch-ready-001",
                    governance_event_id="event-001",
                    entry_id="km-001",
                    project_id="awoooi",
                    title="Sentry checkout failure repair",
                    dispatch_status="pending",
                    workflow_stage="waiting_owner_review",
                    readiness="ready",
                    recommended_completion_outcome="refresh_with_evidence",
                    next_action="preview_stale_km_review_completion",
                    required_owner_fields=["owner_note_or_updated_content"],
                    can_preview=True,
                    can_confirm_after_preview=True,
                    writes_km_on_confirm=True,
                    priority_tier="P0",
                    priority_score=265,
                    recommended_action="refresh_with_evidence",
                    stale_days=35,
                    view_count=7,
                    correlation_sources=["incident", "playbook", "sentry"],
                    reasons=["linked_incident", "linked_playbook"],
                    related_incident_id="INC-20260513-79ED5E",
                    queued_at=NOW,
                ),
                KnowledgeStaleOwnerReviewCompletionQueueItem(
                    dispatch_id="dispatch-done-001",
                    governance_event_id="event-001",
                    entry_id="km-002",
                    project_id="awoooi",
                    title="Auto repair canary runbook",
                    dispatch_status="succeeded",
                    workflow_stage="km_candidate_reviewed",
                    readiness="completed",
                    recommended_completion_outcome="archive",
                    next_action="view_stale_km_completion_audit",
                    required_owner_fields=["owner_note"],
                    can_preview=False,
                    can_confirm_after_preview=False,
                    writes_km_on_confirm=False,
                    priority_tier="P1",
                    priority_score=120,
                    recommended_action="archive_or_supersede",
                    stale_days=18,
                    view_count=2,
                    correlation_sources=["playbook"],
                    reasons=["high_stale_days"],
                    completed_at=NOW,
                ),
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_completion_queue(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue",
            new=mock_completion_queue,
        ):
            r = client.get(
                "/api/v1/ai/governance/km-stale-owner-review-completion-queue"
                "?project_id=awoooi&status_bucket=all&priority_tier=P0"
                "&recommended_completion_outcome=refresh_with_evidence"
                "&can_preview=true&limit=12"
            )

        assert r.status_code == 200
        assert captured == {
            "project_id": "awoooi",
            "status_bucket": "all",
            "priority_tiers": ["P0"],
            "recommended_completion_outcome": "refresh_with_evidence",
            "batch_governance_event_id": None,
            "can_preview": True,
            "limit": 12,
        }
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1"
        assert data["writes_on_read"] is False
        assert data["manual_review_required"] is True
        assert data["batch_writes_allowed"] is False
        assert data["pending_count"] == 1
        assert data["completed_count"] == 1
        assert data["items"][0]["readiness"] == "ready"
        assert data["items"][0]["can_preview"] is True
        assert data["items"][1]["writes_km_on_confirm"] is False

    def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client):
        fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
            project_id="awoooi",
            owner="operator_console",
            owner_note="review top ready items",
            status_bucket="ready",
            priority_tiers=["P0", "P1"],
            recommended_completion_outcome="all",
            requested_limit=10,
            candidate_count=1,
            previewable_count=1,
            blocked_count=0,
            completed_count=0,
            failed_count=0,
            dry_run_plan_fingerprint="sha256:" + "d" * 64,
            items=[
                KnowledgeStaleOwnerReviewCompletionQueueItem(
                    dispatch_id="dispatch-ready-001",
                    governance_event_id="event-001",
                    entry_id="km-001",
                    project_id="awoooi",
                    title="Sentry checkout failure repair",
                    dispatch_status="pending",
                    workflow_stage="waiting_owner_review",
                    readiness="ready",
                    recommended_completion_outcome="refresh_with_evidence",
                    next_action="preview_stale_km_review_completion",
                    required_owner_fields=["owner_note_or_updated_content"],
                    can_preview=True,
                    can_confirm_after_preview=True,
                    writes_km_on_confirm=True,
                    priority_tier="P0",
                    priority_score=265,
                    recommended_action="refresh_with_evidence",
                    stale_days=35,
                    view_count=7,
                    correlation_sources=["incident", "playbook", "sentry"],
                    reasons=["linked_incident"],
                    queued_at=NOW,
                )
            ],
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_completion_batch_preview(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.preview_km_stale_owner_review_completion_batch",
            new=mock_completion_batch_preview,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
                json={
                    "project_id": "awoooi",
                    "status_bucket": "ready",
                    "priority_tiers": ["P0", "P1"],
                    "limit": 10,
                    "owner": "operator_console",
                    "owner_note": "review top ready items",
                },
            )

        assert r.status_code == 200
        assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest)
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_completion_batch_preview_v1"
        assert data["status"] == "dry_run"
        assert data["writes_km"] is False
        assert data["writes_governance_audit"] is False
        assert data["batch_writes_allowed"] is False
        assert data["manual_review_required"] is True
        assert data["previewable_count"] == 1
        assert data["items"][0]["can_preview"] is True

    def test_completion_queue_item_marks_active_review_ready(self):
        inbox_item = KnowledgeStaleOwnerReviewInboxItem(
            dispatch_id="dispatch-ready-001",
            governance_event_id="event-001",
            entry_id="km-001",
            project_id="awoooi",
            title="Sentry checkout failure repair",
            dispatch_status="pending",
            workflow_stage="waiting_owner_review",
            next_action="owner_review_stale_km_candidate",
            owner="operator_console",
            owner_note="p0_p1_stale_km_batch",
            priority_tier="P0",
            priority_score=265,
            recommended_action="refresh_with_evidence",
            stale_days=35,
            view_count=7,
            correlation_sources=["incident", "playbook", "sentry"],
            reasons=["linked_incident"],
            related_incident_id="INC-20260513-79ED5E",
            queued_at=NOW,
        )

        item = _build_completion_queue_item(inbox_item)

        assert item.readiness == "ready"
        assert item.next_action == "preview_stale_km_review_completion"
        assert item.recommended_completion_outcome == "refresh_with_evidence"
        assert item.required_owner_fields == ["owner_note_or_updated_content"]
        assert item.can_preview is True
        assert item.can_confirm_after_preview is True
        assert item.writes_km_on_confirm is True

    def test_completion_queue_item_marks_terminal_states_non_writable(self):
        inbox_item = KnowledgeStaleOwnerReviewInboxItem(
            dispatch_id="dispatch-completed-001",
            governance_event_id="event-001",
            entry_id="km-001",
            project_id="awoooi",
            title="Old auto repair note",
            dispatch_status="succeeded",
            workflow_stage="km_candidate_reviewed",
            priority_tier="P1",
            priority_score=120,
            recommended_action="archive_or_supersede",
            stale_days=20,
            view_count=2,
            correlation_sources=["playbook"],
            reasons=["high_stale_days"],
            completed_at=NOW,
        )

        item = _build_completion_queue_item(inbox_item)

        assert item.readiness == "completed"
        assert item.next_action == "view_stale_km_completion_audit"
        assert item.recommended_completion_outcome == "archive"
        assert item.can_preview is False
        assert item.can_confirm_after_preview is False
        assert item.writes_km_on_confirm is False

    def test_completion_batch_preview_fingerprint_is_stable_and_read_only(self):
        request = KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(
            project_id="awoooi",
            status_bucket="ready",
            priority_tiers=["P0", "P1"],
            limit=10,
            owner="operator_console",
            owner_note="top ready items",
        )
        items = [
            KnowledgeStaleOwnerReviewCompletionQueueItem(
                dispatch_id="dispatch-ready-001",
                governance_event_id="event-001",
                entry_id="km-001",
                project_id="awoooi",
                title="Sentry checkout failure repair",
                dispatch_status="pending",
                workflow_stage="waiting_owner_review",
                readiness="ready",
                recommended_completion_outcome="refresh_with_evidence",
                next_action="preview_stale_km_review_completion",
                required_owner_fields=["owner_note_or_updated_content"],
                can_preview=True,
                can_confirm_after_preview=True,
                writes_km_on_confirm=True,
                priority_tier="P0",
                priority_score=265,
                recommended_action="refresh_with_evidence",
                stale_days=35,
                view_count=7,
                correlation_sources=["incident", "playbook"],
                reasons=["linked_incident"],
                queued_at=NOW,
            )
        ]

        fingerprint = _build_completion_batch_preview_fingerprint(
            request=request,
            items=items,
        )

        assert fingerprint.startswith("sha256:")
        assert len(fingerprint) == 71

    def test_stale_owner_review_batch_context_is_operator_visible(self):
        request = KnowledgeStaleOwnerReviewBatchQueueRequest(
            project_id="awoooi",
            priority_tiers=["P0", "P1"],
            limit=10,
            owner="operator_console",
            dry_run=False,
        )
        snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
            stale_count=119,
            total_count=200,
            stale_ratio=0.595,
            threshold=0.2,
            stale_days=7,
        )
        items = [
            KnowledgeStaleOwnerReviewBatchItem(
                entry_id="km-001",
                title="Sentry checkout failure repair",
                priority_tier="P0",
                recommended_action="refresh_with_evidence",
                status="would_queue",
                workflow_stage="waiting_owner_review",
            )
        ]
        fingerprint = _build_batch_queue_plan_fingerprint(
            request=request,
            items=items,
            snapshot=snapshot,
        )
        ctx = _build_batch_owner_review_decision_context(
            batch_governance_event_id="event-batch-001",
            batch_dispatch_id="dispatch-batch-001",
            request=request,
            items=items,
            stale_ratio_snapshot=snapshot,
            plan_fingerprint=fingerprint,
        )

        assert fingerprint.startswith("sha256:")
        assert ctx["decision_path"] == "batch_owner_review_queued"
        assert ctx["workflow"]["work_kind"] == "km_stale_owner_review_batch"
        assert ctx["workflow"]["current_stage"] == "batch_owner_review_queued"
        assert ctx["workflow"]["writes_km"] is False
        assert ctx["workflow"]["writes_km_without_approval"] is False
        assert ctx["worker_result"]["status"] == "batch_owner_review_queued"
        assert ctx["worker_result"]["queued_count"] == 1
        assert ctx["ownership"]["lead_agent"] == "Hermes"

    def test_stale_owner_review_context_is_operator_visible(self):
        candidate = {
            "entry_id": "km-001",
            "project_id": "awoooi",
            "title": "Sentry checkout failure repair",
            "stale_days": 35,
            "priority_tier": "P0",
            "priority_score": 265,
            "recommended_action": "refresh_with_evidence",
            "correlation_sources": ["incident", "playbook", "sentry", "signoz"],
            "related_incident_id": "INC-20260513-79ED5E",
            "related_playbook_id": "pb:auto-repair-canary",
            "related_approval_id": "approval-001",
        }

        ctx = _build_stale_owner_review_decision_context(
            governance_event_id="event-001",
            entry_id="km-001",
            candidate=candidate,
            owner="operator_console",
            owner_note="prioritize P0",
        )

        assert ctx["decision_path"] == "pending_owner_review"
        assert ctx["next_action"] == "owner_review_stale_km_candidate"
        assert ctx["workflow"]["work_kind"] == "km_stale_owner_review"
        assert ctx["workflow"]["current_stage"] == "waiting_owner_review"
        assert ctx["workflow"]["entry_id"] == "km-001"
        assert ctx["workflow"]["writes_km"] is False
        assert ctx["workflow"]["writes_km_without_approval"] is False
        assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "waiting_owner_review"
        assert ctx["worker_result"]["status"] == "queued_owner_review"
        assert ctx["ownership"]["lead_agent"] == "Hermes"

    def test_complete_stale_candidate_endpoint_returns_writeback_audit_result(self, client):
        """Owner 審核完成後要回傳 KM write / audit / stale ratio recheck 狀態。"""
        fake = KnowledgeStaleOwnerReviewCompleteResponse(
            entry_id="km-001",
            project_id="awoooi",
            status="completed",
            review_outcome="refresh_with_evidence",
            governance_event_id="event-001",
            dispatch_id="dispatch-001",
            audit_dispatch_id="dispatch-audit-001",
            stale_ratio_recheck_dispatch_id="dispatch-recheck-001",
            workflow_stage="km_writeback_after_approval",
            owner="operator_console",
            owner_approved=True,
            dry_run=False,
            writes_km=True,
            writes_governance_audit=True,
            stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
                stale_count=119,
                total_count=200,
                stale_ratio=0.595,
                threshold=0.2,
                stale_days=7,
            ),
            dry_run_plan_fingerprint="sha256:" + "a" * 64,
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_complete(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.complete_km_stale_owner_review",
            new=mock_complete,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/km-001/complete-review",
                json={
                    "dispatch_id": "dispatch-001",
                    "owner": "operator_console",
                    "owner_approved": True,
                    "dry_run": False,
                    "review_outcome": "refresh_with_evidence",
                    "owner_note": "Reviewed Sentry and SigNoz evidence.",
                    "dry_run_plan_fingerprint": "sha256:" + "a" * 64,
                },
            )

        assert r.status_code == 200
        assert captured["entry_id"] == "km-001"
        assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompleteRequest)
        data = r.json()
        assert data["schema_version"] == "km_stale_owner_review_complete_v1"
        assert data["status"] == "completed"
        assert data["workflow_stage"] == "km_writeback_after_approval"
        assert data["writes_km"] is True
        assert data["writes_governance_audit"] is True
        assert data["audit_dispatch_id"] == "dispatch-audit-001"
        assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001"
        assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595)

    def test_complete_stale_candidate_endpoint_maps_validation_error(self, client):
        async def mock_complete(**kwargs):
            raise KmStaleOwnerReviewError(403, "owner_approved=true is required")

        with patch(
            "src.api.v1.ai_governance.complete_km_stale_owner_review",
            new=mock_complete,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-stale-candidates/km-001/complete-review",
                json={
                    "dispatch_id": "dispatch-001",
                    "review_outcome": "archive",
                    "dry_run": False,
                },
            )

        assert r.status_code == 403
        assert r.json()["detail"] == "owner_approved=true is required"

    def test_stale_owner_review_completion_fingerprint_and_audit_are_visible(self):
        record = KnowledgeEntryRecord(
            id="km-001",
            project_id="awoooi",
            title="Sentry checkout failure repair",
            content="old evidence",
            entry_type=EntryType.AUTO_RUNBOOK,
            category="AI系統",
            tags=["sentry"],
            source=EntrySource.AI_EXTRACTED,
            status=EntryStatus.REVIEW,
            updated_at=NOW - timedelta(days=35),
        )
        dispatch = GovernanceRemediationDispatch(
            id="dispatch-001",
            governance_event_id="event-001",
            event_type="knowledge_degradation",
            dispatch_status="pending",
            decision_context={
                "workflow": {
                    "entry_id": "km-001",
                    "steps": ["detected", "waiting_owner_review"],
                }
            },
            executor_type="hermes_km_stale_owner_review",
            dispatched_at=NOW,
        )
        request = KnowledgeStaleOwnerReviewCompleteRequest(
            dispatch_id="dispatch-001",
            owner="operator_console",
            owner_approved=True,
            dry_run=False,
            review_outcome="refresh_with_evidence",
            owner_note="Reviewed Incident, Sentry, SigNoz, and PlayBook.",
        )
        snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
            stale_count=119,
            total_count=200,
            stale_ratio=0.595,
            threshold=0.2,
            stale_days=7,
        )

        fingerprint = _build_completion_plan_fingerprint(
            record=record,
            dispatch=dispatch,
            request=request,
            superseded_by=None,
        )
        audit = _build_owner_review_completion_audit_context(
            governance_event_id="event-001",
            source_dispatch_id="dispatch-001",
            entry=record,
            request=request,
            stale_ratio_snapshot=snapshot,
            recheck_dispatch_id="dispatch-recheck-001",
            plan_fingerprint=fingerprint,
            completed_at=NOW.isoformat(),
        )
        recheck = _build_stale_owner_review_recheck_context(
            governance_event_id="event-001",
            entry_id="km-001",
            request=request,
            stale_ratio_snapshot=snapshot,
            plan_fingerprint=fingerprint,
        )

        assert fingerprint.startswith("sha256:")
        assert _completion_stage_for_outcome("refresh_with_evidence") == "km_writeback_after_approval"
        assert audit["workflow"]["current_stage"] == "km_writeback_after_approval"
        assert audit["workflow"]["writes_km_without_approval"] is False
        assert audit["worker_result"]["status"] == "owner_review_completed"
        assert audit["worker_result"]["above_threshold"] is True
        assert audit["stale_ratio_recheck"]["dispatch_id"] == "dispatch-recheck-001"
        assert recheck["workflow"]["current_stage"] == "stale_ratio_recheck"
        assert recheck["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595)

    def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client):
        """Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。"""
        fake = KnowledgeReviewDraftArchiveResponse(
            governance_event_id="event-001",
            canonical_entry_id="km-canonical",
            requested_duplicate_entry_ids=["km-dup-1", "km-dup-2"],
            archived_entry_ids=["km-dup-1", "km-dup-2"],
            status="archived",
            owner="operator_console",
            owner_approved=True,
            dry_run=False,
            writes_km=True,
            writes_governance_audit=True,
            audit_dispatch_id="dispatch-audit-001",
            stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
                stale_count=120,
                total_count=200,
                stale_ratio=0.6,
                threshold=0.2,
                stale_days=7,
            ),
            stale_ratio_recheck_status="completed",
            stale_ratio_recheck_dispatch_id="dispatch-recheck-001",
            generated_at=NOW,
        )
        captured: dict = {}

        async def mock_archive(**kwargs):
            captured.update(kwargs)
            return fake

        with patch(
            "src.api.v1.ai_governance.archive_km_review_draft_duplicates",
            new=mock_archive,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates",
                json={
                    "canonical_entry_id": "km-canonical",
                    "duplicate_entry_ids": ["km-dup-1", "km-dup-2"],
                    "owner": "operator_console",
                    "owner_approved": True,
                    "dry_run": False,
                    "dry_run_plan_fingerprint": "sha256:" + "a" * 64,
                },
            )

        assert r.status_code == 200
        assert captured["governance_event_id"] == "event-001"
        assert captured["request"].owner_approved is True
        assert captured["request"].dry_run_plan_fingerprint == "sha256:" + "a" * 64
        data = r.json()
        assert data["status"] == "archived"
        assert data["writes_km"] is True
        assert data["writes_governance_audit"] is True
        assert data["audit_dispatch_id"] == "dispatch-audit-001"
        assert data["stale_ratio_recheck_status"] == "completed"
        assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001"
        assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)

    def test_archive_endpoint_maps_validation_error_to_http(self, client):
        async def mock_archive(**kwargs):
            raise KmReviewDraftArchiveError(409, "stale plan")

        with patch(
            "src.api.v1.ai_governance.archive_km_review_draft_duplicates",
            new=mock_archive,
        ):
            r = client.post(
                "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates",
                json={
                    "canonical_entry_id": "km-canonical",
                    "duplicate_entry_ids": ["km-dup-1"],
                    "owner_approved": True,
                },
            )

        assert r.status_code == 409
        assert r.json()["detail"] == "stale plan"

    def test_archive_plan_validation_rejects_stale_duplicates(self):
        group = KnowledgeReviewDraftDedupeGroup(
            governance_event_id="event-001",
            canonical_entry_id="km-canonical",
            canonical_title="canonical",
            preferred_source="dispatch_context",
            duplicate_entry_ids=["km-dup-1", "km-dup-2"],
            duplicate_count=2,
            total_entries=3,
            suggested_action="owner_review_canonical_then_archive_duplicates",
            owner_action="review_canonical_and_archive_duplicate_drafts",
            writes_on_read=False,
            can_archive_without_owner_approval=False,
        )
        request = KnowledgeReviewDraftArchiveRequest(
            canonical_entry_id="km-canonical",
            duplicate_entry_ids=["km-dup-1"],
            owner_approved=True,
        )

        with pytest.raises(KmReviewDraftArchiveError) as exc:
            _validate_archive_request_against_plan(group, request, ["km-dup-1"])

        assert exc.value.status_code == 409
        assert "latest dedupe plan" in exc.value.detail

    def test_archive_plan_fingerprint_is_required_before_write(self):
        group = KnowledgeReviewDraftDedupeGroup(
            governance_event_id="event-001",
            canonical_entry_id="km-canonical",
            canonical_title="canonical",
            preferred_source="dispatch_context",
            duplicate_entry_ids=["km-dup-2", "km-dup-1"],
            duplicate_count=2,
            total_entries=3,
            suggested_action="owner_review_canonical_then_archive_duplicates",
            owner_action="review_canonical_and_archive_duplicate_drafts",
            writes_on_read=False,
            can_archive_without_owner_approval=False,
        )

        fingerprint = _build_dry_run_plan_fingerprint("event-001", group)

        assert fingerprint.startswith("sha256:")
        assert fingerprint == _build_dry_run_plan_fingerprint("event-001", group)
        _validate_dry_run_plan_fingerprint(fingerprint, fingerprint)

        with pytest.raises(KmReviewDraftArchiveError) as missing:
            _validate_dry_run_plan_fingerprint(None, fingerprint)

        assert missing.value.status_code == 403
        assert "dry_run_plan_fingerprint" in missing.value.detail

        with pytest.raises(KmReviewDraftArchiveError) as mismatch:
            _validate_dry_run_plan_fingerprint("sha256:" + "0" * 64, fingerprint)

        assert mismatch.value.status_code == 409
        assert "latest dedupe plan" in mismatch.value.detail

    def test_stale_ratio_recheck_context_is_operator_visible(self):
        snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
            stale_count=120,
            total_count=200,
            stale_ratio=0.6,
            threshold=0.2,
            stale_days=7,
        )
        request = KnowledgeReviewDraftArchiveRequest(
            canonical_entry_id="km-canonical",
            duplicate_entry_ids=["km-dup-1", "km-dup-2"],
            owner="operator_console",
            owner_approved=True,
            dry_run_plan_fingerprint="sha256:" + "a" * 64,
        )

        ctx = _build_stale_ratio_recheck_context(
            governance_event_id="event-001",
            request=request,
            archived_ids=["km-dup-1", "km-dup-2"],
            stale_ratio_snapshot=snapshot,
        )

        assert ctx["next_action"] == "run_stale_ratio_recheck"
        assert ctx["decision_path"] == "owner_approved_recheck_after_archive"
        assert ctx["workflow"]["work_kind"] == "km_stale_ratio_recheck"
        assert ctx["workflow"]["current_stage"] == "stale_ratio_recheck"
        assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "stale_ratio_recheck"
        assert ctx["workflow"]["writes_km"] is False
        assert ctx["workflow"]["dry_run_plan_fingerprint"] == "sha256:" + "a" * 64
        assert ctx["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)
        assert ctx["worker_result"]["status"] == "stale_ratio_rechecked"
        assert ctx["worker_result"]["above_threshold"] is True


# =============================================================================
# 4. summary endpoint compliance_rate
# =============================================================================

class TestSummaryEndpoint:
    def test_compliance_rate_normal(self, client):
        """有 unresolved 時計算 1 - unresolved/total."""
        fake = GovernanceSummaryResponse(
            compliance_rate=0.8,
            total_events=10,
            unresolved_count=2,
            daily_counts=[],
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_summary",
            new=AsyncMock(return_value=fake),
        ):
            r = client.get("/api/v1/ai/governance/summary")

        assert r.status_code == 200
        data = r.json()
        assert data["compliance_rate"] == pytest.approx(0.8)
        assert data["total_events"] == 10
        assert data["unresolved_count"] == 2

    def test_compliance_rate_all_resolved(self, client):
        """全部已解決時 compliance_rate = 1.0."""
        fake = GovernanceSummaryResponse(
            compliance_rate=1.0,
            total_events=5,
            unresolved_count=0,
            daily_counts=[],
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_summary",
            new=AsyncMock(return_value=fake),
        ):
            r = client.get("/api/v1/ai/governance/summary?days=7")

        assert r.status_code == 200
        assert r.json()["compliance_rate"] == pytest.approx(1.0)

    def test_compliance_rate_total_zero(self, client):
        """total_events=0 時 compliance_rate = 1.0（邊界測試）."""
        fake = GovernanceSummaryResponse(
            compliance_rate=1.0,
            total_events=0,
            unresolved_count=0,
            daily_counts=[],
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_summary",
            new=AsyncMock(return_value=fake),
        ):
            r = client.get("/api/v1/ai/governance/summary")

        assert r.status_code == 200
        data = r.json()
        assert data["compliance_rate"] == pytest.approx(1.0)
        assert data["total_events"] == 0

    def test_days_max_boundary(self, client):
        """days=90 邊界值應被接受."""
        fake = GovernanceSummaryResponse(
            compliance_rate=1.0, total_events=0, unresolved_count=0, daily_counts=[],
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_summary",
            new=AsyncMock(return_value=fake),
        ):
            r = client.get("/api/v1/ai/governance/summary?days=90")
        assert r.status_code == 200

    def test_days_over_max_rejected(self, client):
        """days=91 應被拒絕（422）."""
        r = client.get("/api/v1/ai/governance/summary?days=91")
        assert r.status_code == 422

    def test_daily_counts_structure(self, client):
        """daily_counts 結構正確."""
        fake = GovernanceSummaryResponse(
            compliance_rate=0.9,
            total_events=10,
            unresolved_count=1,
            daily_counts=[
                DailyCount(date="2026-05-01", total=3, by_type={"slo_violation": 2, "trust_drift": 1}),
                DailyCount(date="2026-05-02", total=7, by_type={"slo_violation": 7}),
            ],
        )
        with patch(
            "src.api.v1.ai_governance.query_governance_summary",
            new=AsyncMock(return_value=fake),
        ):
            r = client.get("/api/v1/ai/governance/summary")

        assert r.status_code == 200
        counts = r.json()["daily_counts"]
        assert len(counts) == 2
        assert counts[0]["date"] == "2026-05-01"
        assert counts[0]["by_type"]["slo_violation"] == 2


# =============================================================================
# 5. service 層 compliance_rate 純函式測試（不經 HTTP）
# =============================================================================

class TestComplianceRateCalculation:
    """直接測試 service 邏輯，不經 Router。"""

    def test_formula_normal(self):
        """1 - 2/10 = 0.8"""
        rate = round(1.0 - 2 / 10, 4)
        assert rate == pytest.approx(0.8)

    def test_formula_zero_total(self):
        """total=0 → 1.0"""
        total = 0
        rate = 1.0 if total == 0 else round(1.0 - 0 / total, 4)
        assert rate == pytest.approx(1.0)

    def test_formula_all_unresolved(self):
        """1 - 5/5 = 0.0"""
        rate = round(1.0 - 5 / 5, 4)
        assert rate == pytest.approx(0.0)
