"""
KM Backfill Reconciler 單元測試
================================
P1-1 C1 修復 2026-04-28 ogt + Claude Sonnet 4.6

測試範圍：
  1. reconciler 從 DLQ 成功補救 → LREM 移除
  2. reconciler DB 失敗 → 保留 DLQ（不移除）
  3. reconciler DLQ 格式錯誤 → 移除（無法補救）
  4. reconciler DLQ 空 → 0 processed
  5. ENABLE_KM_BACKFILL_RECONCILER=false → 跳過
  6. _backfill_path_a_approval_safe — 成功路徑不寫 DLQ
  7. _backfill_path_a_approval_safe — 失敗時寫 km:backfill:dlq

建立：2026-04-28 (台北時區) ogt + Claude Sonnet 4.6
"""

import json
from unittest.mock import AsyncMock, patch

import pytest

import src.jobs.km_backfill_reconciler_job as reconciler_job
from src.jobs.km_backfill_reconciler_job import (
    run_km_backfill_reconciler,
    run_km_backfill_reconciler_loop,
)
from src.services.km_writer import (
    KM_BACKFILL_DLQ_KEY,
    _backfill_path_a_approval_safe,
)

# =============================================================================
# Helper
# =============================================================================

def _make_dlq_record(incident_id: str = "INC-001", approval_id: str = "AP-001") -> bytes:
    return json.dumps({"incident_id": incident_id, "approval_id": approval_id}).encode()


# =============================================================================
# 1. Reconciler 成功補救
# =============================================================================

@pytest.mark.asyncio
async def test_reconciler_success_removes_from_dlq():
    """成功補救後應 LREM 從 DLQ 移除"""
    record = _make_dlq_record("INC-R1", "AP-R1")
    mock_redis = AsyncMock()
    mock_redis.lrange = AsyncMock(return_value=[record])
    mock_redis.lrem = AsyncMock()

    with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \
         patch("src.core.redis_client.get_redis", return_value=mock_redis), \
         patch("src.jobs.km_backfill_reconciler_job._do_backfill", new_callable=AsyncMock) as mock_do:

        mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True
        result = await run_km_backfill_reconciler()

    assert result["processed"] == 1
    assert result["success"] == 1
    assert result["failed"] == 0
    mock_do.assert_called_once_with("INC-R1", "AP-R1")
    mock_redis.lrem.assert_called_once_with(KM_BACKFILL_DLQ_KEY, 1, record)


# =============================================================================
# 2. Reconciler DB 失敗 → 保留 DLQ
# =============================================================================

@pytest.mark.asyncio
async def test_reconciler_db_failure_preserves_dlq():
    """DB 失敗時不應 LREM（保留 DLQ 等下次補救）"""
    record = _make_dlq_record("INC-FAIL", "AP-FAIL")
    mock_redis = AsyncMock()
    mock_redis.lrange = AsyncMock(return_value=[record])
    mock_redis.lrem = AsyncMock()

    with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \
         patch("src.core.redis_client.get_redis", return_value=mock_redis), \
         patch("src.jobs.km_backfill_reconciler_job._do_backfill",
               side_effect=Exception("db connection refused")):

        mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True
        result = await run_km_backfill_reconciler()

    assert result["processed"] == 1
    assert result["success"] == 0
    assert result["failed"] == 1
    # 失敗時不應 LREM
    mock_redis.lrem.assert_not_called()


# =============================================================================
# 3. Reconciler 格式錯誤 → 移除（無法補救）
# =============================================================================

@pytest.mark.asyncio
async def test_reconciler_malformed_record_removed():
    """格式錯誤的 DLQ record 應被移除（不能卡住 DLQ）"""
    malformed = b"not-json-at-all"
    mock_redis = AsyncMock()
    mock_redis.lrange = AsyncMock(return_value=[malformed])
    mock_redis.lrem = AsyncMock()

    with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \
         patch("src.core.redis_client.get_redis", return_value=mock_redis), \
         patch("src.jobs.km_backfill_reconciler_job._do_backfill", new_callable=AsyncMock) as mock_do:

        mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True
        await run_km_backfill_reconciler()

    # 格式錯誤移除
    mock_redis.lrem.assert_called_once_with(KM_BACKFILL_DLQ_KEY, 1, malformed)
    # 不嘗試 DB 補救
    mock_do.assert_not_called()


# =============================================================================
# 4. DLQ 空 → 0 processed
# =============================================================================

@pytest.mark.asyncio
async def test_reconciler_empty_dlq():
    """DLQ 為空時應返回 0 processed"""
    mock_redis = AsyncMock()
    mock_redis.lrange = AsyncMock(return_value=[])

    with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \
         patch("src.core.redis_client.get_redis", return_value=mock_redis):

        mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True
        result = await run_km_backfill_reconciler()

    assert result["processed"] == 0
    assert result["success"] == 0
    assert result["failed"] == 0


@pytest.mark.asyncio
async def test_reconciler_loop_can_sleep(monkeypatch: pytest.MonkeyPatch):
    """loop 必須能 sleep；避免少 import asyncio 導致 background task 啟動即死亡。"""

    class StopLoop(Exception):
        pass

    calls = 0

    async def fake_run_once():
        nonlocal calls
        calls += 1
        return {"processed": 0, "success": 0, "failed": 0}

    async def fake_sleep(_seconds: int):
        raise StopLoop

    monkeypatch.setattr(reconciler_job, "run_km_backfill_reconciler", fake_run_once)
    monkeypatch.setattr(reconciler_job.asyncio, "sleep", fake_sleep)

    with pytest.raises(StopLoop):
        await run_km_backfill_reconciler_loop()

    assert calls == 1


# =============================================================================
# 5. ENABLE_KM_BACKFILL_RECONCILER=false → 跳過
# =============================================================================

@pytest.mark.asyncio
async def test_reconciler_disabled_skips():
    """Feature flag false 時應直接返回 0，不存取 Redis"""
    with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \
         patch("src.core.redis_client.get_redis") as mock_get_redis:

        mock_settings.ENABLE_KM_BACKFILL_RECONCILER = False
        result = await run_km_backfill_reconciler()

    assert result["processed"] == 0
    mock_get_redis.assert_not_called()


# =============================================================================
# 6. _backfill_path_a_approval_safe — 成功路徑不寫 DLQ
# =============================================================================

@pytest.mark.asyncio
async def test_backfill_safe_success_no_dlq():
    """成功時不應寫 km:backfill:dlq"""
    with patch("src.services.km_writer._backfill_path_a_approval", new_callable=AsyncMock) as mock_bf, \
         patch("src.core.redis_client.get_redis") as mock_get_redis:

        await _backfill_path_a_approval_safe("INC-OK", "AP-OK")

    mock_bf.assert_called_once_with("INC-OK", "AP-OK")
    mock_get_redis.assert_not_called()


# =============================================================================
# 7. _backfill_path_a_approval_safe — 失敗時寫 km:backfill:dlq
# =============================================================================

@pytest.mark.asyncio
async def test_backfill_safe_failure_writes_dlq():
    """失敗時應寫 km:backfill:dlq 且不拋例外"""
    captured_keys = []
    mock_redis = AsyncMock()

    async def _capture_lpush(key, value):
        captured_keys.append(key)

    mock_redis.lpush.side_effect = _capture_lpush
    mock_redis.ltrim = AsyncMock()

    with patch("src.services.km_writer._backfill_path_a_approval",
               side_effect=Exception("db error")), \
         patch("src.core.redis_client.get_redis", return_value=mock_redis):

        # 不應拋例外
        await _backfill_path_a_approval_safe("INC-ERR", "AP-ERR")

    assert KM_BACKFILL_DLQ_KEY in captured_keys
