# apps/api/tests/test_ai_router_diagnose_fallback.py
# 2026-04-27 Claude Sonnet 4.6: A2 INC-20260425 — DIAGNOSE fallback chain 移除 Ollama
"""
DIAGNOSE Fallback Chain 測試 (A2 INC-20260425)
===============================================
驗收標準:
1. DIAGNOSE intent，NEMO 失敗 → 跳 Gemini（不跳 Ollama）
2. Gemini 失敗 → 跳 Claude
3. 全失敗 → graceful 降級（不再去 Ollama）
4. 其他 intent（如 RESTART）的 fallback 行為不變（Ollama 仍在鏈中）
5. aiops_diagnose_fallback_total metric 可正常累計

測試分類：unit（mock provider / registry，無 Redis / DB / K8s 依賴）
"""

from __future__ import annotations

import os

os.environ.setdefault("MOCK_MODE", "true")

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from src.services.ai_router import (
    AIProviderEnum,
    AIRouter,
    AIRouterExecutor,
    AIProviderRegistry,
    reset_ai_router,
)
from src.services.intent_classifier import IntentType


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture(autouse=True)
def reset_router():
    """每個測試前後重置 singleton，避免 mock 殘留"""
    yield
    reset_ai_router()


def _make_router() -> AIRouter:
    """建立 AIRouter（mock failover_manager 避免 Redis 依賴）"""
    router = AIRouter()
    mock_fm = MagicMock()
    mock_fm.select_provider = AsyncMock(side_effect=RuntimeError("not needed"))
    router._failover_manager = mock_fm
    return router


def _make_registry_with_providers(
    *,
    nemo_success: bool = True,
    gemini_success: bool = True,
    claude_success: bool = True,
) -> AIProviderRegistry:
    """建立只含 openclaw_nemo / gemini / claude 三個 provider 的 registry（無 Ollama）"""
    from src.services.ai_providers.interfaces import AIResult

    registry = AIProviderRegistry()

    def _make_provider(name: str, privacy: str, success: bool, response: str = "") -> MagicMock:
        p = MagicMock()
        p.name = name
        p.privacy_level = privacy
        p.is_enabled = True
        p.capabilities = {"rca", "chat"}
        p.analyze = AsyncMock(
            return_value=AIResult(
                raw_response=response or f"{name}_response",
                success=success,
                provider=name,
                error="" if success else f"{name}_timeout",
            )
        )
        p.health_check = AsyncMock(return_value=success)
        return p

    registry._providers = {
        "openclaw_nemo": _make_provider("openclaw_nemo", "cloud", nemo_success),
        "gemini": _make_provider("gemini", "cloud", gemini_success),
        "claude": _make_provider("claude", "cloud", claude_success, "claude_diagnosis_result"),
    }
    return registry


# =============================================================================
# Test 1: _diagnose_fallback_chain 屬性存在且不含 Ollama
# =============================================================================


def test_diagnose_fallback_chain_ollama_primary():
    """2026-04-29 ogt + Claude Code: 推翻 A2，OLLAMA 為 DIAGNOSE primary

    統帥鐵律 (2026-04-29): 主要優先用 111 主機的 Ollama
    + feedback_ai_autonomous_direction.md: 以本地免費 LLM 為主
    + feedback_ollama_111_only.md: Ollama 唯一主機 = 111

    推翻 A2 (2026-04-27 INC-20260425) 原因：
      舊事實: Ollama = CPU-only deepseek-r1:14b @ 238s（不可用）
      新事實: prod Ollama 111 = M1 Pro GPU + qwen2.5:7b 已實載（VRAM 8.2GB）
              實測 hi 0.54s
      雲端全死: OpenClaw 500 / Gemini 429 / Claude 404
              不推翻 → 100% llm_failed
    """
    router = _make_router()

    assert hasattr(router, "_diagnose_fallback_chain"), (
        "_diagnose_fallback_chain 屬性不存在"
    )

    providers_in_chain = [p for p, _ in router._diagnose_fallback_chain]
    # 新鐵律：OLLAMA 必須在 chain 第一位
    assert providers_in_chain[0] == AIProviderEnum.OLLAMA, (
        f"統帥鐵律: chain 第一位應為 OLLAMA，實際: {providers_in_chain}"
    )
    # 雲端 fallback 仍在（救命備援）
    assert AIProviderEnum.OPENCLAW_NEMO in providers_in_chain
    assert AIProviderEnum.GEMINI in providers_in_chain
    assert AIProviderEnum.CLAUDE in providers_in_chain
    # 188 不得作為 Ollama provider；本地備援只允許 ollama_local。
    provider_values = {p.value for p in providers_in_chain}
    assert "ollama_188" not in provider_values


def test_diagnose_fallback_chain_contains_cloud_providers():
    """_diagnose_fallback_chain 應含 OPENCLAW_NEMO, GEMINI, CLAUDE"""
    router = _make_router()

    providers_in_chain = [p for p, _ in router._diagnose_fallback_chain]
    assert AIProviderEnum.OPENCLAW_NEMO in providers_in_chain
    assert AIProviderEnum.GEMINI in providers_in_chain
    assert AIProviderEnum.CLAUDE in providers_in_chain


# =============================================================================
# Test 2: DIAGNOSE route() 的 fallback_chain 不含 Ollama
# =============================================================================


@pytest.mark.asyncio
async def test_diagnose_route_primary_is_ollama():
    """2026-04-29: DIAGNOSE intent route() primary 必須是 OLLAMA（推翻 A2）"""
    router = _make_router()

    decision = await router.route(
        "pod crash loop detected",
        context={"intent_hint": "diagnose"},
    )

    assert decision.selected_provider == AIProviderEnum.OLLAMA, (
        f"統帥鐵律: DIAGNOSE primary 應為 OLLAMA，實際: {decision.selected_provider}"
    )

    # 雲端 fallback 仍在（OpenClaw / Gemini / Claude 救命備援）
    fb_providers = [p for p, _ in decision.fallback_chain]
    # ollama_failover_manager 可能轉到 GCP-B / ollama_local，但雲端救命備援仍必須存在。
    has_cloud_fallback = (
        AIProviderEnum.GEMINI in fb_providers or AIProviderEnum.CLAUDE in fb_providers
    )
    assert has_cloud_fallback, (
        f"雲端 fallback 應存在當救命備援: {fb_providers}"
    )


@pytest.mark.asyncio
async def test_diagnose_route_sync_primary_is_ollama():
    """2026-04-29: DIAGNOSE route_sync() primary 同樣是 OLLAMA"""
    router = _make_router()

    decision = router.route_sync(
        "pod crash loop detected",
        context={"intent_hint": "diagnose"},
    )

    assert decision.selected_provider == AIProviderEnum.OLLAMA, (
        f"統帥鐵律: DIAGNOSE route_sync primary 應為 OLLAMA，實際: {decision.selected_provider}"
    )


# =============================================================================
# Test 3: DIAGNOSE NEMO 失敗 → fallback 到 Gemini（不是 Ollama）
# =============================================================================


@pytest.mark.asyncio
async def test_diagnose_nemo_fail_fallback_to_gemini_not_ollama():
    """DIAGNOSE: NEMO 失敗 → executor 嘗試 Gemini，不嘗試 Ollama"""
    registry = _make_registry_with_providers(
        nemo_success=False,
        gemini_success=True,
    )
    executor = AIRouterExecutor(registry)

    with patch("src.services.ai_router._settings") as mock_settings:
        mock_settings.MOCK_MODE = False
        result = await executor.execute(
            prompt="RCA: pod OOMKilled",
            provider_order=["openclaw_nemo", "gemini", "claude"],
            context={"intent_hint": "diagnose"},
        )

    assert result.success is True
    assert result.provider == "gemini", (
        f"應 fallback 到 gemini，實際: {result.provider}"
    )
    # 驗證 Ollama 根本不在 provider_order（確保沒被加進去）
    ollama_provider = registry._providers.get("ollama")
    assert ollama_provider is None, "registry 不應含 ollama provider（DIAGNOSE 路徑）"


# =============================================================================
# Test 4: DIAGNOSE Gemini 失敗 → fallback 到 Claude
# =============================================================================


@pytest.mark.asyncio
async def test_diagnose_gemini_fail_fallback_to_claude():
    """DIAGNOSE: NEMO 失敗 + Gemini 失敗 → executor 嘗試 Claude"""
    registry = _make_registry_with_providers(
        nemo_success=False,
        gemini_success=False,
        claude_success=True,
    )
    executor = AIRouterExecutor(registry)

    with patch("src.services.ai_router._settings") as mock_settings:
        mock_settings.MOCK_MODE = False
        result = await executor.execute(
            prompt="RCA: pod crash",
            provider_order=["openclaw_nemo", "gemini", "claude"],
            context={"intent_hint": "diagnose"},
        )

    assert result.success is True
    assert result.provider == "claude", (
        f"應 fallback 到 claude，實際: {result.provider}"
    )


# =============================================================================
# Test 5: DIAGNOSE 全失敗 → graceful 降級（不去 Ollama）
# =============================================================================


@pytest.mark.asyncio
async def test_diagnose_all_fail_graceful_no_ollama():
    """DIAGNOSE: NEMO + Gemini + Claude 全失敗 → graceful error，不嘗試 Ollama"""
    registry = _make_registry_with_providers(
        nemo_success=False,
        gemini_success=False,
        claude_success=False,
    )
    executor = AIRouterExecutor(registry)

    with patch("src.services.ai_router._settings") as mock_settings:
        mock_settings.MOCK_MODE = False
        result = await executor.execute(
            prompt="RCA: cascading failure",
            provider_order=["openclaw_nemo", "gemini", "claude"],
            context={"intent_hint": "diagnose"},
        )

    # 全失敗應回傳 success=False（graceful 降級，不 raise）
    assert result.success is False
    assert result.provider == "none"
    # 確認沒有嘗試 Ollama（registry 裡根本沒有 ollama）
    assert "ollama" not in registry._providers


# =============================================================================
# Test 6: 其他 intent（RESTART）的 fallback 行為不變（Ollama 仍在鏈中）
# =============================================================================


@pytest.mark.asyncio
async def test_restart_intent_still_has_ollama_in_fallback():
    """RESTART intent 的 fallback_chain 應仍包含 OLLAMA（行為不變）"""
    router = _make_router()

    # RESTART → None（複雜度路由），低複雜度 → OLLAMA primary
    # 使用 context_hint 直接指定，避免 LLM 分類
    decision = await router.route(
        "restart the api service",
        context={"intent_hint": "restart"},
    )

    # RESTART intent 不受 A2 影響，_full_fallback_chain 仍含 OLLAMA
    all_providers_in_decision = [decision.selected_provider] + [
        p for p, _ in decision.fallback_chain
    ]
    assert AIProviderEnum.OLLAMA in all_providers_in_decision, (
        f"RESTART 路徑應仍含 OLLAMA（行為不變），實際: {all_providers_in_decision}"
    )


def test_build_fallback_chain_for_intent_diagnose_with_ollama_primary():
    """2026-04-29: _build_fallback_chain_for_intent(DIAGNOSE, primary=OLLAMA)
    回傳結果應排除 primary OLLAMA，但保留雲端 fallback。"""
    router = _make_router()

    # primary 是 OLLAMA（推翻 A2 後）
    chain = router._build_fallback_chain_for_intent(
        AIProviderEnum.OLLAMA,
        IntentType.DIAGNOSE,
    )
    providers = [p for p, _ in chain]

    # primary 已排除
    assert AIProviderEnum.OLLAMA not in providers
    # fallback 雲端救命備援必須存在
    assert AIProviderEnum.OPENCLAW_NEMO in providers
    assert AIProviderEnum.GEMINI in providers
    assert AIProviderEnum.CLAUDE in providers


def test_build_fallback_chain_for_intent_restart_has_ollama():
    """_build_fallback_chain_for_intent(RESTART) 回傳結果仍含 OLLAMA"""
    router = _make_router()

    chain = router._build_fallback_chain_for_intent(
        AIProviderEnum.OPENCLAW_NEMO,
        IntentType.RESTART,
    )
    providers = [p for p, _ in chain]

    assert AIProviderEnum.OLLAMA in providers, (
        f"RESTART fallback 應含 OLLAMA，實際: {providers}"
    )


# =============================================================================
# Test 7: aiops_diagnose_fallback_total metric 正常累計
# =============================================================================


@pytest.mark.asyncio
async def test_diagnose_fallback_metric_incremented():
    """DIAGNOSE NEMO 失敗 → fallback Gemini 時，aiops_diagnose_fallback_total metric 被記錄"""
    registry = _make_registry_with_providers(
        nemo_success=False,
        gemini_success=True,
    )
    executor = AIRouterExecutor(registry)

    with patch("src.services.ai_router._settings") as mock_settings:
        mock_settings.MOCK_MODE = False
        with patch("src.core.metrics.record_diagnose_fallback") as mock_metric:
            await executor.execute(
                prompt="RCA: high error rate",
                provider_order=["openclaw_nemo", "gemini", "claude"],
                context={"intent_hint": "diagnose"},
            )

    # fallback from openclaw_nemo → gemini 應被記錄一次
    mock_metric.assert_called_once_with(
        from_provider="openclaw_nemo",
        to_provider="gemini",
    )


@pytest.mark.asyncio
async def test_non_diagnose_intent_no_fallback_metric():
    """非 DIAGNOSE intent 的 fallback 不應觸發 aiops_diagnose_fallback_total"""
    from src.services.ai_providers.interfaces import AIResult

    registry = AIProviderRegistry()

    # ollama 失敗
    mock_ollama = MagicMock()
    mock_ollama.name = "ollama"
    mock_ollama.privacy_level = "local"
    mock_ollama.is_enabled = True
    mock_ollama.capabilities = {"chat"}
    mock_ollama.analyze = AsyncMock(
        return_value=AIResult(raw_response="", success=False, provider="ollama", error="timeout")
    )

    # gemini 成功
    mock_gemini = MagicMock()
    mock_gemini.name = "gemini"
    mock_gemini.privacy_level = "cloud"
    mock_gemini.is_enabled = True
    mock_gemini.analyze = AsyncMock(
        return_value=AIResult(raw_response="ok", success=True, provider="gemini")
    )

    registry._providers = {"ollama": mock_ollama, "gemini": mock_gemini}
    executor = AIRouterExecutor(registry)

    with patch("src.services.ai_router._settings") as mock_settings:
        mock_settings.MOCK_MODE = False
        with patch("src.core.metrics.record_diagnose_fallback") as mock_metric:
            await executor.execute(
                prompt="restart service",
                provider_order=["ollama", "gemini"],
                context={"intent_hint": "restart"},  # 非 DIAGNOSE
            )

    # 非 DIAGNOSE intent → metric 不應被呼叫
    mock_metric.assert_not_called()
