"""PR-MacBook-RAG-Backend-1 정정 4 핵심 테스트.

검증 invariant (synthesize 함수 레벨 — /ask wrapper 의 503 매핑은 search.py 의
status="backend_unavailable" 분기로 1:1 deterministic):

1. backend="qwen-macbook" + MacBook URL 죽은 포트
   → synthesize() 가 SynthesisResult(status="backend_unavailable", ...) 반환
   → Gemma backend 의 generate() 가 **단 1번도 호출되지 않음** (자동 fallback 부재)

2. backend 미지정 (None)
   → Gemma backend.generate() 호출, Qwen backend.generate() 호출 0
   → 기존 호출자 (Hermes docsrv_ask / voice-memo-bot) 회귀 0

3. backend="qwen-macbook" + MacBook 정상 응답
   → status="completed" + answer 채워짐, Gemma backend 호출 0

테스트 전략:
- synthesize() 가 호출하는 backend dispatcher (services.llm.get_backend) 를
  monkeypatch 해서 mock backend 주입.
- Gemma backend 의 generate AsyncMock 호출 횟수를 추적.
- 정정 4 의 핵심 가드: `gemma_backend.generate.assert_not_called()`
"""

from __future__ import annotations

import asyncio
import os
import sys
from dataclasses import dataclass
from unittest.mock import AsyncMock

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "app"))


# ── 가짜 evidence (synthesize 의 no_evidence 분기 회피용 최소 객체) ─────────


@dataclass
class _FakeEvidence:
    n: int = 1
    doc_id: int = 100
    chunk_id: int | None = 200
    title: str | None = "fake doc"
    span_text: str = "이것은 짧은 근거 텍스트입니다."
    source: str = "llm"


def _make_evidence():
    return [_FakeEvidence()]


# ── backend mock ───────────────────────────────────────────────────────────


def _gemma_mock(content: str = "GEMMA_SHOULD_NEVER_BE_CALLED"):
    m = AsyncMock()
    m.name = "gemma-macmini"
    m.generate = AsyncMock(return_value=content)
    return m


def _qwen_mock_success(content: str):
    m = AsyncMock()
    m.name = "qwen-macbook"
    m.generate = AsyncMock(return_value=content)
    return m


def _qwen_mock_unavailable():
    from services.llm import BackendUnavailable

    m = AsyncMock()
    m.name = "qwen-macbook"
    m.generate = AsyncMock(
        side_effect=BackendUnavailable("qwen-macbook", "ConnectError")
    )
    return m


# ── 공통 fixture: synthesis_service 에 mock backend 주입 ───────────────────


@pytest.fixture
def patched_backends(monkeypatch):
    """services.llm.get_backend 를 mock dispatcher 로 치환.

    Returns (gemma_mock, qwen_mock, set_qwen_unavailable_fn).
    """
    from services.search import synthesis_service

    gemma = _gemma_mock()
    qwen_holder = {"backend": _qwen_mock_success(
        '{"answer":"Qwen ok [1]","confidence":"high","refused":false}'
    )}

    def _fake_get_backend(name: str | None):
        key = (name or "").strip().lower() or "gemma-macmini"
        if key == "gemma-macmini":
            return gemma
        if key == "qwen-macbook":
            return qwen_holder["backend"]
        raise ValueError(f"unknown backend: {name!r}")

    monkeypatch.setattr(synthesis_service, "get_backend", _fake_get_backend)
    # synthesis_service 캐시 비움 (qwen vs gemma 캐시 분리 invariant)
    synthesis_service._CACHE.clear()

    def _swap_qwen_unavailable():
        qwen_holder["backend"] = _qwen_mock_unavailable()

    return gemma, qwen_holder, _swap_qwen_unavailable


# ── 정정 4 핵심: backend=qwen-macbook + MacBook 비가용 → Gemma 호출 0 ─────


def test_qwen_unavailable_yields_backend_unavailable_status_and_gemma_not_called(
    patched_backends,
):
    """**정정 4 의 핵심 invariant**.

    backend="qwen-macbook" 명시 + Qwen 호출이 BackendUnavailable 로 실패 →
    synthesize() 는 status="backend_unavailable" 반환. Gemma backend 의
    generate() 는 **단 한 번도 호출되지 않음** (silent fallback 금지).
    """
    from services.search.synthesis_service import synthesize

    gemma, qwen_holder, swap_qwen_unavailable = patched_backends
    swap_qwen_unavailable()
    qwen = qwen_holder["backend"]

    result = asyncio.run(
        synthesize(
            query="압력용기 최대허용응력은?",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )

    # 1. status
    assert result.status == "backend_unavailable"
    assert result.answer is None
    assert result.confidence is None
    assert result.refused is False

    # 2. flag 에 backend 비가용 사유 기록
    assert any(
        f.startswith("backend_unavailable:qwen-macbook:") for f in result.hallucination_flags
    ), f"expected backend_unavailable flag, got {result.hallucination_flags}"

    # 3. ★ 핵심 가드 ★ — Gemma backend 자동 fallback 금지
    gemma.generate.assert_not_called()

    # 4. Qwen 은 1회만 호출 (재시도 없음)
    assert qwen.generate.call_count == 1


def test_qwen_unavailable_result_not_cached(patched_backends):
    """비가용 결과는 캐시 X — 다음 호출이 다시 Qwen 시도해야 함."""
    from services.search.synthesis_service import synthesize

    gemma, qwen_holder, swap_qwen_unavailable = patched_backends
    swap_qwen_unavailable()
    qwen = qwen_holder["backend"]

    asyncio.run(
        synthesize(
            query="동일 쿼리",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )
    asyncio.run(
        synthesize(
            query="동일 쿼리",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )

    # 두 번 모두 실제 호출 (캐시 적중 X) — Gemma 는 여전히 0
    assert qwen.generate.call_count == 2
    gemma.generate.assert_not_called()


# ── 정정 4: backend 미지정 → 기존 Gemma path (회귀 0) ─────────────────────


def test_default_backend_calls_gemma_not_qwen(patched_backends):
    """backend 미지정 = 기본 Gemma. Qwen 호출 0."""
    from services.search.synthesis_service import synthesize

    gemma, qwen_holder, _ = patched_backends
    qwen = qwen_holder["backend"]
    gemma.generate.return_value = (
        '{"answer":"Gemma 답변 [1]","confidence":"high","refused":false}'
    )

    result = asyncio.run(
        synthesize(
            query="기본 호출",
            evidence=_make_evidence(),
            backend=None,  # 명시 None = default
        )
    )

    assert result.status == "completed"
    assert result.answer is not None and "Gemma" in result.answer

    # Qwen 은 호출 0
    qwen.generate.assert_not_called()
    # Gemma 는 1회
    assert gemma.generate.call_count == 1


# ── backend="qwen-macbook" + 정상 응답 ──────────────────────────────────────


def test_qwen_success_does_not_call_gemma(patched_backends):
    """Qwen 정상 응답 시 Gemma 는 호출되지 않음 (대칭 invariant)."""
    from services.search.synthesis_service import synthesize

    gemma, qwen_holder, _ = patched_backends
    qwen = qwen_holder["backend"]

    result = asyncio.run(
        synthesize(
            query="정상 호출",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )

    assert result.status == "completed"
    assert result.answer is not None and "Qwen" in result.answer

    # Gemma 는 0회
    gemma.generate.assert_not_called()
    # Qwen 은 1회
    assert qwen.generate.call_count == 1


# ── 캐시 분리 (qwen vs gemma 키 충돌 없음) ─────────────────────────────────


def test_qwen_and_gemma_have_separate_caches(patched_backends):
    """같은 query 라도 backend 다르면 캐시 분리 — Qwen 결과가 Gemma 호출 답으로 둔갑하지 않음."""
    from services.search.synthesis_service import synthesize

    gemma, qwen_holder, _ = patched_backends
    qwen = qwen_holder["backend"]
    gemma.generate.return_value = (
        '{"answer":"GEMMA_ANSWER [1]","confidence":"high","refused":false}'
    )
    qwen.generate.return_value = (
        '{"answer":"QWEN_ANSWER [1]","confidence":"high","refused":false}'
    )

    r_qwen_1 = asyncio.run(
        synthesize(
            query="같은 query",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )
    r_gemma_1 = asyncio.run(
        synthesize(
            query="같은 query",
            evidence=_make_evidence(),
            backend=None,
        )
    )
    r_qwen_2 = asyncio.run(
        synthesize(
            query="같은 query",
            evidence=_make_evidence(),
            backend="qwen-macbook",
        )
    )

    assert "QWEN_ANSWER" in (r_qwen_1.answer or "")
    assert "GEMMA_ANSWER" in (r_gemma_1.answer or "")
    # 두 번째 Qwen 호출은 캐시 적중 — 결과는 동일하지만 generate 추가 호출 X
    assert "QWEN_ANSWER" in (r_qwen_2.answer or "")
    assert r_qwen_2.cache_hit is True

    # generate 호출 횟수: Qwen 1 (두번째는 캐시), Gemma 1
    assert qwen.generate.call_count == 1
    assert gemma.generate.call_count == 1
