"""
PostExecutionVerifier 測試
===========================
ADR-081: Phase 1 執行後驗證器

測試項目：
- _assess_recovery() 三態判斷邏輯（success / degraded / failed）
- 空 post_state → degraded
- failure 信號優先於 success 信號
- restart action 時 pre/post 都 Running → success
- 非 restart action 時 pre/post 都 Running → degraded
- verify() 收斂等待 warmup（warmup=0 時跳過）
- verify() 逾時 → "timeout"
- capture_pre_execution_state 填入 pre_execution_state

2026-04-15 Claude Sonnet 4.6 + ogt: Phase 1 初始建立
"""

from __future__ import annotations

import asyncio
import pytest
from unittest.mock import AsyncMock, patch

from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
from src.plugins.mcp.registry import AuditedMCPToolProvider
from src.services import post_execution_verifier as pev_module
from src.services.post_execution_verifier import (
    PostExecutionVerifier,
    _assess_recovery,
    _get_incident_id,
    _get_labels,
)
from src.services.evidence_snapshot import EvidenceSnapshot
from src.services.mcp_tool_registry import RegisteredTool, SensorDimension


# ─────────────────────────────────────────────────────────────────────────────
# Incident stub
# ─────────────────────────────────────────────────────────────────────────────

def _stub_incident(
    alertname: str = "KubePodCrashLooping",
    namespace: str = "awoooi-prod",
    pod: str = "api-xyz",
) -> object:
    class _Signal:
        labels = {
            "alertname": alertname,
            "namespace": namespace,
            "pod": pod,
        }

    class _Incident:
        incident_id = "INC-TEST"
        signals = [_Signal()]

    return _Incident()


# ─────────────────────────────────────────────────────────────────────────────
# _assess_recovery — 核心三態邏輯
# ─────────────────────────────────────────────────────────────────────────────

class TestAssessRecovery:
    """Phase 1 啟發式規則驗證"""

    def test_empty_post_state_is_degraded(self):
        assert _assess_recovery(None, {}, "restart_service") == "degraded"

    def test_running_in_post_state_is_success(self):
        post = {"pod": {"status": "Running"}}
        assert _assess_recovery(None, post, "restart_service:api") == "success"

    def test_1_of_1_ready_is_success(self):
        post = {"pod": {"containers": "1/1"}}
        assert _assess_recovery(None, post, "scale_up") == "success"

    def test_rollout_success_is_success(self):
        post = {
            "k8s_watch_rollout": {
                "deployment": "awoooi-auto-repair-canary",
                "success": True,
                "status": 'deployment "awoooi-auto-repair-canary" successfully rolled out',
            }
        }
        assert _assess_recovery(None, post, "auto_repair_playbook:PB-CANARY") == "success"

    def test_crashloopbackoff_is_failed(self):
        post = {"pod": {"status": "CrashLoopBackOff"}}
        assert _assess_recovery(None, post, "restart_service") == "failed"

    def test_oomkilled_is_failed(self):
        post = {"pod": {"status": "OOMKilled"}}
        assert _assess_recovery(None, post, "restart_service") == "failed"

    def test_pod_phase_failed_is_failed(self):
        """K8s Pod phase 'Failed' 正確觸發 failed（原 Error state 測試 — Gate 1 fix: 移除裸 "error" 防止 error_rate 等 key 誤觸）"""
        post = {"phase": "Failed"}
        assert _assess_recovery(None, post, "patch_config") == "failed"

    def test_error_rate_key_does_not_trigger_failed(self):
        """error_rate 等指標 key 不得誤判為 failed — Gate 1 回歸"""
        post = {"error_rate": 0.5, "status": "Running"}
        # 含 Running → success（error_rate key 不觸發 failed）
        assert _assess_recovery(None, post, "restart") == "success"

    def test_failure_signal_beats_success_signal(self):
        # CrashLoopBackOff 且含 Running（混合狀態）— 失敗優先
        post = {"status": "Running", "reason": "CrashLoopBackOff"}
        assert _assess_recovery(None, post, "restart") == "failed"

    def test_pre_running_post_running_no_restart_is_degraded(self):
        """非 restart 動作，前後都 Running → 操作無效 → degraded"""
        pre = {"status": "Running"}
        post = {"status": "Running"}
        assert _assess_recovery(pre, post, "scale_up") == "degraded"

    def test_pre_running_post_running_restart_is_success(self):
        """restart 動作，前後都 Running → 重啟成功 → success"""
        pre = {"status": "Running"}
        post = {"status": "Running"}
        assert _assess_recovery(pre, post, "restart_service:api") == "success"

    def test_rollout_restart_step_success_when_pre_and_post_running(self):
        """auto-repair label 必須帶實際 rollout restart 步驟，才能驗證為真修復。"""
        pre = {"status": "Running"}
        post = {"status": "Running"}
        action = "auto_repair_playbook:PB-TEST kubectl rollout restart deployment/api"
        assert _assess_recovery(pre, post, action) == "success"

    def test_diagnosis_only_step_is_not_verified_repair(self):
        """診斷型 playbook 即使 post 狀態健康，也不能被算成 verified repair。"""
        pre = {"status": "Running"}
        post = {"status": "Running"}
        action = "auto_repair_playbook:PB-TEST mcp:ssh_diagnose docker stats"
        assert _assess_recovery(pre, post, action) == "degraded"

    def test_diagnosis_only_without_pre_state_is_not_verified_repair(self):
        """只有 read-only SSH 診斷且無 pre-state 時，也不能被誤算成真修復。"""
        post = {"ssh_diagnose": {"command": "docker stats --no-stream api", "stdout": '"Status": "running"'}}
        action = "auto_repair:PB-TEST steps=Step 1: docker stats -> SUCCESS: mcp:ssh_diagnose"
        assert _assess_recovery(None, post, action) == "degraded"

    def test_docker_running_after_restart_is_success(self):
        """Docker restart 後看到 docker ps/inspect running，可驗證為 success。"""
        post = {
            "ssh_get_container_status": {
                "command": "docker ps -a --filter name=api",
                "stdout": "api   Up 12 seconds",
            }
        }
        action = "auto_repair:PB-TEST steps=Step 1: docker restart api -> SUCCESS"
        assert _assess_recovery(None, post, action) == "success"

    def test_pre_running_post_running_delete_is_success(self):
        """kubectl delete 動作，前後都 Running → success"""
        pre = {"status": "Running"}
        post = {"status": "Running"}
        assert _assess_recovery(pre, post, "kubectl_delete_pod:api") == "success"

    def test_pre_none_post_running_is_success(self):
        """無前狀態 + 後狀態 Running → success"""
        assert _assess_recovery(None, {"status": "Running"}, "restart") == "success"

    def test_healthy_signal_is_success(self):
        post = {"health": "healthy"}
        assert _assess_recovery(None, post, "patch") == "success"

    def test_pre_post_identical_no_change_is_degraded(self):
        state = {"status": "Pending"}
        assert _assess_recovery(state, state, "patch_config") == "degraded"

    def test_case_insensitive_matching(self):
        """信號匹配必須不分大小寫"""
        post = {"STATUS": "RUNNING"}
        assert _assess_recovery(None, post, "restart") == "success"


# ─────────────────────────────────────────────────────────────────────────────
# Helper 函式
# ─────────────────────────────────────────────────────────────────────────────

class TestHelpers:
    def test_get_incident_id_from_incident_id_attr(self):
        class I:
            incident_id = "INC-001"
        assert _get_incident_id(I()) == "INC-001"

    def test_get_incident_id_fallback_to_id(self):
        class I:
            id = 42
        assert _get_incident_id(I()) == "42"

    def test_get_labels_from_signals(self):
        inc = _stub_incident(namespace="ns-test")
        labels = _get_labels(inc)
        assert labels["namespace"] == "ns-test"

    def test_get_labels_no_signals_returns_empty(self):
        class I:
            signals = []
        assert _get_labels(I()) == {}


# ─────────────────────────────────────────────────────────────────────────────
# PostExecutionVerifier.verify() — 端對端邏輯（mock MCP 層）
# ─────────────────────────────────────────────────────────────────────────────

class TestVerify:
    """verify() 整合測試：使用 mock 繞過真實 MCP / DB"""

    @pytest.mark.asyncio
    async def test_warmup_zero_skips_sleep(self):
        """warmup_sec=0 必須立即執行，不 sleep"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()

        with patch.object(
            verifier,
            "_collect_post_state",
            new=AsyncMock(return_value={"status": "Running"}),
        ):
            result = await verifier.verify(
                incident=incident,
                snapshot=None,
                action_taken="restart_service:api",
                warmup_sec=0.0,
            )

        assert result == "success"

    @pytest.mark.asyncio
    async def test_post_state_failed_signals_returns_failed(self):
        verifier = PostExecutionVerifier()
        incident = _stub_incident()

        with patch.object(
            verifier,
            "_collect_post_state",
            new=AsyncMock(return_value={"status": "CrashLoopBackOff"}),
        ):
            result = await verifier.verify(
                incident=incident,
                snapshot=None,
                action_taken="restart_service:api",
                warmup_sec=0.0,
            )

        assert result == "failed"

    @pytest.mark.asyncio
    async def test_snapshot_missing_persists_fallback_verification(self):
        """snapshot=None 也必須把 verification_result 寫成可稽核 fallback snapshot。"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()
        persist = AsyncMock()

        with patch.object(
            verifier,
            "_collect_post_state",
            new=AsyncMock(return_value={"status": "Running"}),
        ):
            with patch(
                "src.services.post_execution_verifier._persist_fallback_snapshot",
                new=persist,
            ):
                result = await verifier.verify(
                    incident=incident,
                    snapshot=None,
                    action_taken="auto_repair_playbook:PB-TEST",
                    warmup_sec=0.0,
                )

        assert result == "success"
        persist.assert_awaited_once()
        call_kwargs = persist.await_args.kwargs
        assert call_kwargs["incident"] is incident
        assert call_kwargs["post_state"] == {"status": "Running"}
        assert call_kwargs["result"] == "success"
        assert call_kwargs["action_taken"] == "auto_repair_playbook:PB-TEST"

    @pytest.mark.asyncio
    async def test_collect_timeout_returns_timeout(self):
        """MCP 蒐集超時 → "timeout"，不 raise"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()

        async def _slow(*args, **kwargs):
            await asyncio.sleep(9999)

        with patch.object(verifier, "_collect_post_state", new=_slow):
            with patch("src.services.post_execution_verifier.VERIFY_TIMEOUT_SEC", 0.05):
                result = await verifier.verify(
                    incident=incident,
                    snapshot=None,
                    action_taken="restart_service",
                    warmup_sec=0.0,
                )

        assert result == "timeout"

    @pytest.mark.asyncio
    async def test_collect_exception_returns_failed(self):
        """MCP 蒐集拋例外 → "failed"，不 raise"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()

        async def _raise(*args, **kwargs):
            raise ConnectionError("k8s unreachable")

        with patch.object(verifier, "_collect_post_state", new=_raise):
            result = await verifier.verify(
                incident=incident,
                snapshot=None,
                action_taken="restart_service",
                warmup_sec=0.0,
            )

        assert result == "failed"

    @pytest.mark.asyncio
    async def test_snapshot_pre_state_used_for_comparison(self):
        """pre_execution_state 須傳入 _assess_recovery 做對比"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()
        snapshot = EvidenceSnapshot(incident_id="INC-TEST")
        snapshot.pre_execution_state = {"status": "Running"}

        # post_state 也 Running，但動作不是 restart → degraded
        with patch.object(
            verifier,
            "_collect_post_state",
            new=AsyncMock(return_value={"status": "Running"}),
        ):
            with patch(
                "src.services.post_execution_verifier._update_snapshot",
                new=AsyncMock(),
            ):
                result = await verifier.verify(
                    incident=incident,
                    snapshot=snapshot,
                    action_taken="scale_up",
                    warmup_sec=0.0,
                )

        assert result == "degraded"


# ─────────────────────────────────────────────────────────────────────────────
# capture_pre_execution_state
# ─────────────────────────────────────────────────────────────────────────────

class TestCapturePreState:
    @pytest.mark.asyncio
    async def test_captures_state_into_snapshot(self):
        verifier = PostExecutionVerifier()
        incident = _stub_incident()
        snapshot = EvidenceSnapshot(incident_id="INC-TEST")

        with patch.object(
            verifier,
            "_collect_post_state",
            new=AsyncMock(return_value={"status": "Running", "ready": "2/2"}),
        ):
            await verifier.capture_pre_execution_state(incident, snapshot)

        assert snapshot.pre_execution_state is not None
        assert "Running" in str(snapshot.pre_execution_state)

    @pytest.mark.asyncio
    async def test_exception_sets_empty_pre_state(self):
        """蒐集失敗 → pre_execution_state = {}，不 raise"""
        verifier = PostExecutionVerifier()
        incident = _stub_incident()
        snapshot = EvidenceSnapshot(incident_id="INC-TEST")

        async def _raise(*args, **kwargs):
            raise RuntimeError("k8s down")

        with patch.object(verifier, "_collect_post_state", new=_raise):
            await verifier.capture_pre_execution_state(incident, snapshot)

        assert snapshot.pre_execution_state == {}


class _CaptureProvider(MCPToolProvider):
    name = "capture"
    seen_parameters: dict | None = None

    async def list_tools(self) -> list[MCPTool]:
        return []

    async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
        self.seen_parameters = dict(parameters)
        return MCPToolResult(success=True, output={"status": "Running"})


class _FakeRegistry:
    def __init__(self, provider: _CaptureProvider) -> None:
        self.provider = provider

    def suggest_tools(self, alertname: str = "", incident_labels: dict | None = None) -> list[RegisteredTool]:
        return [
            RegisteredTool(
                tool=MCPTool(
                    name="kubectl_get",
                    description="",
                    input_schema={},
                    server_name="capture",
                ),
                provider=self.provider,
                dimensions=[SensorDimension.D1_K8S_STATE],
            )
        ]


class _PrometheusRegistry:
    def __init__(self, provider: _CaptureProvider) -> None:
        self.provider = provider

    def suggest_tools(self, alertname: str = "", incident_labels: dict | None = None) -> list[RegisteredTool]:
        return [
            RegisteredTool(
                tool=MCPTool(
                    name="prometheus_query",
                    description="",
                    input_schema={},
                    server_name="capture",
                ),
                provider=self.provider,
                dimensions=[SensorDimension.D3_METRICS],
            )
        ]


class _DockerRegistry:
    def __init__(self, provider: _CaptureProvider) -> None:
        self.provider = provider

    def suggest_tools(self, alertname: str = "", incident_labels: dict | None = None) -> list[RegisteredTool]:
        return [
            RegisteredTool(
                tool=MCPTool(
                    name="ssh_get_container_status",
                    description="",
                    input_schema={},
                    server_name="capture",
                ),
                provider=self.provider,
                dimensions=[SensorDimension.D3_METRICS],
            )
        ]


class _DbContext:
    async def __aenter__(self) -> object:
        return object()

    async def __aexit__(self, *_args: object) -> None:
        return None


class TestCollectPostStateAuditContext:
    @pytest.mark.asyncio
    async def test_collect_post_state_injects_mcp_audit_context(self):
        provider = _CaptureProvider()
        verifier = PostExecutionVerifier()
        verifier._registry = _FakeRegistry(provider)

        state = await verifier._collect_post_state(_stub_incident())

        assert state["kubectl_get"]["status"] == "Running"
        assert provider.seen_parameters is not None
        audit_context = provider.seen_parameters["_mcp_audit"]
        assert audit_context["incident_id"] == "INC-TEST"
        assert audit_context["session_id"] == "incident:INC-TEST:post_execution"
        assert audit_context["flywheel_node"] == "verify"
        assert audit_context["agent_role"] == "post_execution_verifier"

    @pytest.mark.asyncio
    async def test_collect_post_state_routes_audited_provider_through_gateway(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ):
        provider = _CaptureProvider()
        verifier = PostExecutionVerifier()
        audited_provider = AuditedMCPToolProvider(provider)
        verifier._registry = _FakeRegistry(audited_provider)
        calls: list[dict] = []

        class FakeGateway:
            def __init__(self, db: object) -> None:
                self.db = db

            async def call(self, ctx, parameters: dict) -> MCPToolResult:
                calls.append({"ctx": ctx, "parameters": parameters, "db": self.db})
                return MCPToolResult(
                    success=True,
                    execution_id="gw",
                    output={"status": "Running"},
                )

        monkeypatch.setattr(pev_module, "get_db_context", lambda _project_id: _DbContext())
        monkeypatch.setattr(pev_module, "McpGateway", FakeGateway)

        state = await verifier._collect_post_state(_stub_incident())

        assert state["kubectl_get"]["status"] == "Running"
        assert provider.seen_parameters is None
        assert calls
        ctx = calls[0]["ctx"]
        assert ctx.project_id == "awoooi"
        assert ctx.agent_id == "post_execution_verifier"
        assert ctx.tool_name == "kubectl_get"
        assert ctx.trace_id == "INC-TEST"
        assert ctx.required_scope == "read"
        assert calls[0]["parameters"]["_mcp_audit"]["incident_id"] == "INC-TEST"
        assert calls[0]["parameters"]["_mcp_audit"]["flywheel_node"] == "verify"

    @pytest.mark.asyncio
    async def test_collect_post_state_sends_prometheus_query_parameter(self):
        provider = _CaptureProvider()
        verifier = PostExecutionVerifier()
        verifier._registry = _PrometheusRegistry(provider)
        incident = _stub_incident(alertname="DockerContainerMemoryLimitPressure")
        incident.signals[0].labels.update({
            "host": "110",
            "container_name": "momo-scheduler",
        })

        await verifier._collect_post_state(incident)

        assert provider.seen_parameters is not None
        query = provider.seen_parameters["query"]
        assert "docker_container_memory_usage_bytes" in query
        assert 'host="110"' in query
        assert 'container_name="momo-scheduler"' in query

    @pytest.mark.asyncio
    async def test_collect_post_state_sends_docker_container_target(self):
        provider = _CaptureProvider()
        verifier = PostExecutionVerifier()
        verifier._registry = _DockerRegistry(provider)
        incident = _stub_incident(alertname="DockerContainerUnhealthy")
        incident.signals[0].labels.update({
            "host": "188",
            "container_name": "momo-pro-system",
        })

        await verifier._collect_post_state(incident)

        assert provider.seen_parameters is not None
        assert provider.seen_parameters["container_name"] == "momo-pro-system"
        assert provider.seen_parameters["filter_name"] == "momo-pro-system"


class TestPrometheusQueryBuilder:
    def test_docker_memory_alert_query_is_not_empty(self):
        query = pev_module._build_prometheus_query(
            "DockerContainerMemoryLimitPressure",
            {"host": "110", "container_name": "momo-scheduler"},
        )

        assert "docker_container_memory_usage_bytes" in query
        assert "docker_container_memory_limit_bytes" in query

    def test_canary_alert_uses_generic_non_empty_query(self):
        query = pev_module._build_prometheus_query("AwoooPAutoRepairCanaryT16", {})

        assert query
        assert "up" in query
