# apps/api/tests/test_model_version_tracker.py
# 2026-04-27 P3.2.2 by Claude
"""
ModelVersionTracker 單元測試
==============================
測試覆蓋：
- 第一次寫入：5 row，全部 changed=True（prev_version=None）
- 同樣資料重入：5 row，全部 changed=False
- digest 變更：該 provider changed=True，其餘 changed=False
- run_probe_cycle 回傳 dict 格式正確
- probe_all_providers 回傳空列表 → tracker 不 crash，probed=0

patch 策略：
  - probe_all_providers → patch 原始模組 src.services.model_version_probe.probe_all_providers
  - get_db_context     → patch 原始模組 src.db.base.get_db_context
  （tracker 用 lazy import，每次 import 拿到 same module object，patch 原始模組有效）

測試分類：unit（無 DB / Redis 依賴）
"""
from __future__ import annotations

from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest

from src.services.model_version_probe import ProviderVersionInfo
from src.services.model_version_tracker import ModelVersionTracker

TAIPEI_TZ = timezone(timedelta(hours=8))


# =============================================================================
# Helpers
# =============================================================================

def _make_info(provider: str, version: str = "v1", digest: str | None = "sha256:abc") -> ProviderVersionInfo:
    return ProviderVersionInfo(
        provider=provider,
        model=f"model-{provider}",
        version=version,
        digest=digest,
        captured_at=datetime.now(TAIPEI_TZ),
    )


def _make_five() -> list[ProviderVersionInfo]:
    return [
        _make_info("ollama"),
        _make_info("ollama_local"),
        _make_info("gemini", digest=None),
        _make_info("claude", digest=None),
        _make_info("openclaw_nemo"),
    ]


def _make_fake_ctx(last_fn):
    """回傳一個 asynccontextmanager，db.execute 呼叫 last_fn(index) 取 last record"""
    added_rows: list = []
    call_idx = [0]

    @asynccontextmanager
    async def fake_ctx():
        class FakeDB:
            async def execute(self, stmt):
                result = MagicMock()
                last = last_fn(call_idx[0])
                call_idx[0] += 1
                result.scalar_one_or_none = MagicMock(return_value=last)
                return result

            def add(self, obj):
                added_rows.append(obj)

            async def commit(self):
                pass

        yield FakeDB()

    return fake_ctx, added_rows


# =============================================================================
# Test Cases
# =============================================================================

class TestModelVersionTracker:
    @pytest.mark.asyncio
    async def test_first_write_all_changed(self):
        """第一次寫入（DB 無歷史）→ 5 row 全部 changed=True"""
        five = _make_five()
        tracker = ModelVersionTracker()
        fake_ctx, added_rows = _make_fake_ctx(lambda _: None)  # last=None

        async def _probe():
            return five

        with patch("src.services.model_version_probe.probe_all_providers", side_effect=_probe), \
             patch("src.db.base.get_db_context", fake_ctx):
            result = await tracker.run_probe_cycle()

        assert result["probed"] == 5
        assert len(result["changed"]) == 5
        assert len(added_rows) == 5
        for row in added_rows:
            assert row.changed is True
            assert row.prev_version is None

    @pytest.mark.asyncio
    async def test_same_data_no_change(self):
        """DB 有相同版本記錄 → changed=False"""
        five = _make_five()
        tracker = ModelVersionTracker()

        def _make_last_same(idx):
            info = five[idx % len(five)]
            last = MagicMock()
            last.version = info.version
            last.digest = info.digest
            return last

        fake_ctx, added_rows = _make_fake_ctx(_make_last_same)

        async def _probe():
            return five

        with patch("src.services.model_version_probe.probe_all_providers", side_effect=_probe), \
             patch("src.db.base.get_db_context", fake_ctx):
            result = await tracker.run_probe_cycle()

        assert result["probed"] == 5
        assert len(result["changed"]) == 0
        assert len(added_rows) == 5
        for row in added_rows:
            assert row.changed is False

    @pytest.mark.asyncio
    async def test_digest_change_detected(self):
        """ollama provider digest 改變 → changed=True，其餘 changed=False"""
        five = _make_five()
        tracker = ModelVersionTracker()
        changed_provider = "ollama"

        def _make_last(idx):
            info = five[idx % len(five)]
            last = MagicMock()
            last.version = info.version
            last.digest = "sha256:OLD_DIGEST" if info.provider == changed_provider else info.digest
            return last

        fake_ctx, added_rows = _make_fake_ctx(_make_last)

        async def _probe():
            return five

        with patch("src.services.model_version_probe.probe_all_providers", side_effect=_probe), \
             patch("src.db.base.get_db_context", fake_ctx):
            result = await tracker.run_probe_cycle()

        assert result["probed"] == 5
        assert changed_provider in result["changed"]
        assert len(result["changed"]) == 1

    @pytest.mark.asyncio
    async def test_empty_probe_results_no_crash(self):
        """probe_all_providers 回傳空列表 → tracker 不 crash，probed=0"""
        tracker = ModelVersionTracker()
        fake_ctx, added_rows = _make_fake_ctx(lambda _: None)

        async def _probe():
            return []

        with patch("src.services.model_version_probe.probe_all_providers", side_effect=_probe), \
             patch("src.db.base.get_db_context", fake_ctx):
            result = await tracker.run_probe_cycle()

        assert result["probed"] == 0
        assert result["changed"] == []
        assert len(added_rows) == 0
