"""
KMWriter 單元測試
=================
P1-1 KMWriter 統一契約重構

測試範圍：
  1. 成功路徑（SUCCESS）
  2. Timeout 路徑（TIMEOUT + DLQ）
  3. 可重試例外（EXCEPTION + 指數退避 + DLQ）
  4. 非可重試例外（立即 DLQ）
  5. 冪等 / 空 payload（SKIPPED_NO_DATA）
  6. M4 反查鏈回填（_backfill_path_a_approval）
  7. feature flag KM_WRITE_AWAIT=false（fire-and-forget 舊行為）

遵循「禁止 Mock 測試鐵律」：
  - KMWriter 本身是純 Python 邏輯 + asyncio
  - 外部服務（get_knowledge_service / get_redis）以 unittest.mock.AsyncMock 替換
    （因為這是 unit 契約測試，不是整合測試）

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

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

import pytest

from src.services.km_writer import (
    KMWriteError,
    KMWritePayload,
    KMWriteResult,
    KMWriter,
    _is_retriable,
    _write_to_dlq,
    km_write_with_flag,
)


# =============================================================================
# Helper fixtures
# =============================================================================

def _make_payload(path_type: str = "approval_manual", incident_id: str | None = "INC-TEST-001",
                  approval_id: str | None = "AP-001") -> KMWritePayload:
    return KMWritePayload(
        path_type=path_type,
        entry_create_kwargs=dict(
            title="Test KM Entry",
            content="Test content",
            entry_type="incident_case",
            category="test",
            tags=["test"],
            source="ai_extracted",
        ),
        incident_id=incident_id,
        approval_id=approval_id,
    )


@pytest.fixture
def writer() -> KMWriter:
    return KMWriter()


# =============================================================================
# 1. 成功路徑
# =============================================================================

@pytest.mark.asyncio
async def test_write_success(writer: KMWriter):
    """成功寫入應返回 KMWriteResult.SUCCESS"""
    mock_svc = AsyncMock()
    mock_svc.create_entry = AsyncMock()

    with patch("src.services.km_writer.get_km_writer", return_value=writer), \
         patch("src.services.knowledge_service.get_knowledge_service", return_value=mock_svc), \
         patch("src.services.km_writer._do_write", new_callable=AsyncMock) as mock_do_write:

        payload = _make_payload()
        result = await writer.write(payload, timeout=5.0)

    assert result == KMWriteResult.SUCCESS
    mock_do_write.assert_called_once_with(payload)


# =============================================================================
# 2. Timeout 路徑
# =============================================================================

@pytest.mark.asyncio
async def test_write_timeout(writer: KMWriter):
    """_do_write 超時應返回 TIMEOUT 且寫 DLQ"""
    async def _slow_write(payload):
        await asyncio.sleep(100)

    dlq_called = []

    async def _mock_dlq(payload, reason):
        dlq_called.append(reason)

    with patch("src.services.km_writer._do_write", side_effect=_slow_write), \
         patch("src.services.km_writer._write_to_dlq", side_effect=_mock_dlq):

        payload = _make_payload()
        result = await writer.write(payload, timeout=0.01)

    assert result == KMWriteResult.TIMEOUT
    assert len(dlq_called) == 1
    assert "timeout" in dlq_called[0]


# =============================================================================
# 3. 可重試例外（指數退避）
# =============================================================================

@pytest.mark.asyncio
async def test_write_retriable_exception_exhausts(writer: KMWriter):
    """OperationalError 應重試 3 次後進 DLQ，返回 EXCEPTION"""
    call_count = {"n": 0}

    async def _fail_write(payload):
        call_count["n"] += 1
        raise Exception("operationalerror: connection refused")

    dlq_called = []

    async def _mock_dlq(payload, reason):
        dlq_called.append(reason)

    with patch("src.services.km_writer._do_write", side_effect=_fail_write), \
         patch("src.services.km_writer._write_to_dlq", side_effect=_mock_dlq), \
         patch("asyncio.sleep", new_callable=AsyncMock):  # 跳過 sleep

        payload = _make_payload()
        result = await writer.write(payload, timeout=5.0)

    assert result == KMWriteResult.EXCEPTION
    assert call_count["n"] == 3  # 3 次嘗試
    assert len(dlq_called) == 1


# =============================================================================
# 4. 非可重試例外（立即 DLQ，只嘗試 1 次）
# =============================================================================

@pytest.mark.asyncio
async def test_write_non_retriable_exception(writer: KMWriter):
    """非可重試例外（如 ValueError）應立即 DLQ，不重試"""
    call_count = {"n": 0}

    async def _fail_write(payload):
        call_count["n"] += 1
        raise ValueError("invalid entry_type")

    dlq_called = []

    async def _mock_dlq(payload, reason):
        dlq_called.append(reason)

    with patch("src.services.km_writer._do_write", side_effect=_fail_write), \
         patch("src.services.km_writer._write_to_dlq", side_effect=_mock_dlq):

        payload = _make_payload()
        result = await writer.write(payload, timeout=5.0)

    assert result == KMWriteResult.EXCEPTION
    assert call_count["n"] == 1  # 只嘗試 1 次（非可重試）
    assert len(dlq_called) == 1


# =============================================================================
# 5. 空 payload（SKIPPED_NO_DATA）
# =============================================================================

@pytest.mark.asyncio
async def test_write_empty_payload(writer: KMWriter):
    """entry_create_kwargs 為空時應返回 SKIPPED_NO_DATA"""
    payload = KMWritePayload(
        path_type="approval_manual",
        entry_create_kwargs={},  # 空
        incident_id="INC-001",
    )
    result = await writer.write(payload, timeout=5.0)
    assert result == KMWriteResult.SKIPPED_NO_DATA


# =============================================================================
# 6. M4 反查鏈回填
# =============================================================================

@pytest.mark.asyncio
async def test_backfill_path_a_approval_called_on_success():
    """
    寫入成功且 approval_id + incident_id 都有時，應 schedule _backfill_path_a_approval task
    """
    backfill_args = []

    async def _mock_backfill(incident_id: str, approval_id: str):
        backfill_args.append((incident_id, approval_id))

    async def _mock_do_write(payload):
        # 模擬 _do_write 內部的 backfill 呼叫
        if payload.approval_id and payload.incident_id:
            await _mock_backfill(payload.incident_id, payload.approval_id)

    writer = KMWriter()
    with patch("src.services.km_writer._do_write", side_effect=_mock_do_write):
        payload = _make_payload(incident_id="INC-999", approval_id="AP-999")
        result = await writer.write(payload, timeout=5.0)

    assert result == KMWriteResult.SUCCESS
    assert ("INC-999", "AP-999") in backfill_args


# =============================================================================
# 7. Feature Flag KM_WRITE_AWAIT=false（fire-and-forget）
# =============================================================================

@pytest.mark.asyncio
async def test_km_write_with_flag_await_false():
    """
    C2 修復後，KM_WRITE_AWAIT=false 應 await writer.write(retry=1, timeout=2.0)
    而非 fire-and-forget。確保有一次寫入嘗試（降級但不全拋棄）。
    2026-04-28 ogt + Claude Sonnet 4.6: 同步更新（原測試驗證 ensure_future，現已不適用）
    """
    write_args = {}

    async def _mock_write(payload, *, mode="sync", timeout=None, retry=None, on_failure="dlq"):
        write_args["retry"] = retry
        write_args["timeout"] = timeout
        return KMWriteResult.SUCCESS

    mock_writer = AsyncMock()
    mock_writer.write.side_effect = _mock_write

    with patch("src.services.km_writer.settings") as mock_settings, \
         patch("src.services.km_writer.get_km_writer", return_value=mock_writer):

        mock_settings.KM_WRITE_AWAIT = False
        mock_settings.KM_WRITE_TIMEOUT_SECONDS = 5.0

        payload = _make_payload()
        result = await km_write_with_flag(payload)

    assert result == KMWriteResult.SUCCESS
    # C2 修法：retry=1, timeout=2.0（降級但仍 await 一次）
    assert write_args["retry"] == 1
    assert write_args["timeout"] == 2.0


# =============================================================================
# 8. _is_retriable 輔助函式
# =============================================================================

def test_is_retriable_operational_error():
    assert _is_retriable(Exception("OperationalError: too many connections")) is True


def test_is_retriable_connection_refused():
    assert _is_retriable(Exception("connection refused")) is True


def test_is_retriable_timeout():
    assert _is_retriable(Exception("connection timed out")) is True


def test_is_retriable_value_error():
    assert _is_retriable(ValueError("invalid field")) is False


def test_is_retriable_permission_denied():
    assert _is_retriable(Exception("permission denied")) is False


# =============================================================================
# 9. DLQ 寫入（Redis 失敗時只 log，不拋例外）
# =============================================================================

@pytest.mark.asyncio
async def test_write_to_dlq_redis_failure_does_not_raise():
    """Redis DLQ 寫入失敗時不應 raise（只 log error）"""
    mock_redis = AsyncMock()
    mock_redis.lpush.side_effect = Exception("redis unavailable")

    with patch("src.core.redis_client.get_redis", return_value=mock_redis):
        payload = _make_payload()
        # 不應拋出例外
        await _write_to_dlq(payload, "test_reason")


# =============================================================================
# 10. 冪等：同 incident_id + path_type 寫入兩次，結果均為 SUCCESS（冪等由下層保証）
# =============================================================================

@pytest.mark.asyncio
async def test_idempotency_same_incident_path():
    """
    同 incident_id + path_type 呼叫兩次 write()，兩次均應返回 SUCCESS。
    冪等防重由 knowledge_service.create_entry() 的 DB-level UPSERT 保証；
    KMWriter 本身不拒絕重複，確保不在 writer 層誤攔。
    """
    write_calls = {"n": 0}

    async def _mock_do_write(payload):
        write_calls["n"] += 1

    writer = KMWriter()
    payload = _make_payload(path_type="approval_manual", incident_id="INC-IDEM-001")

    with patch("src.services.km_writer._do_write", side_effect=_mock_do_write):
        result1 = await writer.write(payload, timeout=5.0)
        result2 = await writer.write(payload, timeout=5.0)

    assert result1 == KMWriteResult.SUCCESS
    assert result2 == KMWriteResult.SUCCESS
    assert write_calls["n"] == 2  # 兩次都進 _do_write（UPSERT 由下層處理）


# =============================================================================
# 11. DLQ payload 結構驗證
# =============================================================================

@pytest.mark.asyncio
async def test_dlq_payload_structure():
    """
    DLQ record 必須包含 path_type / incident_id / approval_id / reason / entry_title。
    驗證 _write_to_dlq 寫入 Redis 的 JSON 結構符合規格。
    """
    import json as json_mod

    captured_records = []
    mock_redis = AsyncMock()

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

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

    with patch("src.core.redis_client.get_redis", return_value=mock_redis):
        payload = KMWritePayload(
            path_type="approval_auto_ok",
            incident_id="INC-DLQ-001",
            approval_id="AP-DLQ-001",
            entry_create_kwargs={"title": "DLQ Structure Test"},
        )
        await _write_to_dlq(payload, "test_dlq_reason")

    assert len(captured_records) == 1
    record = json_mod.loads(captured_records[0])
    assert record["path_type"] == "approval_auto_ok"
    assert record["incident_id"] == "INC-DLQ-001"
    assert record["approval_id"] == "AP-DLQ-001"
    assert record["reason"] == "test_dlq_reason"
    assert record["entry_title"] == "DLQ Structure Test"


# =============================================================================
# 12. KMWriteError exception class 結構驗證
# =============================================================================

def test_km_write_error_has_payload_summary():
    """KMWriteError 應帶有 payload_summary 欄位，供 caller 記錄上下文"""
    err = KMWriteError("timeout", {"path_type": "approval_manual", "incident_id": "INC-X"})
    assert str(err) == "timeout"
    assert err.payload_summary["path_type"] == "approval_manual"
    assert err.payload_summary["incident_id"] == "INC-X"


def test_km_write_error_default_payload_summary():
    """KMWriteError payload_summary 預設為空 dict（不為 None）"""
    err = KMWriteError("some error")
    assert err.payload_summary == {}


# =============================================================================
# 13. on_failure="raise" 模式：timeout 時拋 KMWriteError
# =============================================================================

@pytest.mark.asyncio
async def test_on_failure_raise_timeout():
    """on_failure='raise' 時，timeout 應拋 KMWriteError 而非返回 TIMEOUT"""

    async def _slow_write(payload):
        await asyncio.sleep(100)

    writer = KMWriter()
    with patch("src.services.km_writer._do_write", side_effect=_slow_write):
        payload = _make_payload()
        with pytest.raises(KMWriteError) as exc_info:
            await writer.write(payload, timeout=0.01, on_failure="raise")

    assert "timeout" in str(exc_info.value).lower()
