"""
KMWriter - Knowledge Memory 統一寫入契約
=========================================
P1-1 KMWriter 統一契約重構
2026-04-28 ogt + Claude Sonnet 4.6

設計動機：
  原本 5 條 KM 寫入入口各自決定同步/異步/錯誤處理，
  fire-and-forget 在 Pod recycle 時被殺，導致 KM 條目遺失。
  此模組統一契約，確保所有路徑有一致的 await / retry / DLQ 行為。

7 條契約強制：
  1. 同步底線：強制 await asyncio.wait_for(timeout)
  2. 重試：3 次指數退避 1s/2s/4s，僅針對 OperationalError / 網路類例外
  3. 失敗回收：3 次後寫 Redis DLQ km:dlq + log
  4. 觀測：structlog event + 預留 metric hook
  5. 冪等：incident_id + path_type 為 unique key（避免重複）
  6. 禁止吞例外：except 必須 log + DLQ
  7. M4 反查鏈：payload 含 approval_id 時自動填 related_approval_id 並回填 Path A 條目

Feature Flag：
  KM_WRITE_AWAIT=true（預設）→ await 強制模式
  KM_WRITE_AWAIT=false → 舊 fire-and-forget（回滾用）
"""

import asyncio
import json
from enum import Enum
from typing import Any, Literal, Protocol, runtime_checkable

import structlog
from pydantic import BaseModel, Field

from src.core.config import settings

logger = structlog.get_logger(__name__)

# DLQ Redis keys
KM_DLQ_KEY = "km:dlq"                  # 主寫入 DLQ
KM_BACKFILL_DLQ_KEY = "km:backfill:dlq"  # C1 backfill 獨立 DLQ（路徑 A approval 回填）
KM_DLQ_MAX_LEN = 1000  # 防止 DLQ 無限膨脹

# 重試設定
_RETRY_MAX = 3
_RETRY_BASE_DELAY = 1.0  # 指數退避基底（秒）

# 可重試的例外 patterns（OperationalError + 網路類）
_RETRIABLE_ERRORS = (
    "operationalerror",
    "connection refused",
    "connection reset",
    "connection timed out",
    "broken pipe",
    "network",
    "timeout",
    "too many connections",
    "deadlock",
)


class KMWriteResult(str, Enum):
    """KM 寫入結果"""
    SUCCESS = "success"
    TIMEOUT = "timeout"
    EXCEPTION = "exception"
    SKIPPED_NO_DATA = "skipped_no_data"
    DLQ_WRITTEN = "dlq_written"  # P1-1: 明確表示 DLQ 已接管（供 caller 判斷）


class KMWriteError(Exception):
    """
    KMWriter 寫入例外（非可重試類，直接 DLQ）

    區別於可重試的 OperationalError / 網路類例外；
    caller 若需要主動識別 KM 寫入失敗，可 catch 此類別。

    P1-1 2026-04-28 ogt + Claude Sonnet 4.6
    """

    def __init__(self, message: str, payload_summary: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.payload_summary = payload_summary or {}


@runtime_checkable
class KMWriterProtocol(Protocol):
    """
    KM 寫入服務協議介面（供 DI / mock 使用）

    P1-1 擴充參數（2026-04-28 ogt + Claude Sonnet 4.6）：
      mode        — "sync"（預設，強制 await）；預留 "async" 供未來非同步管線
      timeout     — override settings.KM_WRITE_TIMEOUT_SECONDS（None = 使用設定）
      retry       — 最大重試次數（None = 使用模組常數 _RETRY_MAX=3）
      on_failure  — "dlq"（預設，寫 DLQ）；"raise" = 拋 KMWriteError 給 caller
    """

    async def write(
        self,
        payload: "KMWritePayload",
        *,
        mode: Literal["sync", "async"] = "sync",
        timeout: float | None = None,
        retry: int | None = None,
        on_failure: Literal["dlq", "raise"] = "dlq",
    ) -> KMWriteResult:
        ...


class KMWritePayload(BaseModel):
    """
    KM 寫入載體（統一所有路徑的資料格式）

    P1-1 2026-04-28 ogt + Claude Sonnet 4.6：
      從 __slots__ class 升級為 Pydantic BaseModel，保留所有欄位向下相容，
      新增型別驗證與 metadata 欄位。

    path_type：用於冪等 key + 日誌標記
      - "incident_resolve"   ← Path A
      - "approval_manual"    ← Path B-人工
      - "approval_auto_ok"   ← Path B-自動成功
      - "approval_auto_fail" ← Path B-自動失敗
      - "playbook_extract"   ← Path C

    冪等 key：(incident_id, path_type) — 同組合的重複寫入由 knowledge_service.create_entry() UPSERT 處理
    """

    model_config = {"arbitrary_types_allowed": True}

    path_type: str = Field(..., description="寫入路徑類型，構成冪等 key 的一部分")
    incident_id: str | None = Field(None, description="關聯告警 ID（Path A/B/C 均可帶入）")
    approval_id: str | None = Field(None, description="關聯審核 ID（Path B 帶入，觸發 M4 反查鏈）")
    entry_create_kwargs: dict[str, Any] = Field(
        default_factory=dict,
        description="傳入 KnowledgeEntryCreate 的全部欄位（title / content / entry_type / ...）",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="額外觀測元資料（不寫 DB，僅用於 structlog / DLQ record）",
    )


def _is_retriable(exc: Exception) -> bool:
    """判斷例外是否屬於可重試類型"""
    msg = str(exc).lower()
    return any(pattern in msg for pattern in _RETRIABLE_ERRORS)


async def _write_to_dlq(payload: KMWritePayload, reason: str) -> None:
    """將失敗載體寫入 Redis DLQ，確保不遺失"""
    try:
        from src.core.redis_client import get_redis
        redis = get_redis()
        record = json.dumps({
            "path_type": payload.path_type,
            "incident_id": payload.incident_id,
            "approval_id": payload.approval_id,
            "reason": reason,
            "entry_title": payload.entry_create_kwargs.get("title", "")[:100],
        }, ensure_ascii=False)
        # LPUSH + LTRIM 防止 DLQ 無限膨脹
        await redis.lpush(KM_DLQ_KEY, record)
        await redis.ltrim(KM_DLQ_KEY, 0, KM_DLQ_MAX_LEN - 1)
        logger.warning(
            "km_write_dlq",
            path_type=payload.path_type,
            incident_id=payload.incident_id,
            approval_id=payload.approval_id,
            reason=reason,
        )
    except Exception as dlq_exc:
        # DLQ 本身失敗只 log，不再遞迴
        logger.error(
            "km_dlq_write_failed",
            path_type=payload.path_type,
            error=str(dlq_exc),
        )


async def _backfill_path_a_approval(incident_id: str, approval_id: str) -> None:
    """
    M4 反查鏈回填：當 approval_id 已知，回填 Path A（incident_resolve）的 KM 條目。

    UPDATE knowledge_entries
    SET related_approval_id = :aid
    WHERE related_incident_id = :iid AND related_approval_id IS NULL
    """
    from sqlalchemy import text as sa_text

    from src.db.base import get_db_context
    async with get_db_context() as db:
        await db.execute(
            sa_text(
                "UPDATE knowledge_entries "
                "SET related_approval_id = :aid "
                "WHERE related_incident_id = :iid "
                "  AND related_approval_id IS NULL"
            ),
            {"aid": approval_id, "iid": incident_id},
        )
        await db.commit()
    logger.info(
        "km_backfill_approval_done",
        incident_id=incident_id,
        approval_id=approval_id,
    )


async def _backfill_path_a_approval_safe(incident_id: str, approval_id: str) -> None:
    """
    C1 修復 2026-04-28 ogt + Claude Sonnet 4.6：
    _backfill_path_a_approval 的安全 wrapper，失敗時寫獨立 DLQ（km:backfill:dlq）。

    由 _do_write 同步 await 呼叫（取代原裸 asyncio.create_task）。
    backfill 失敗不影響主寫入結果，但確保有跡可查供 reconciler 補救。
    """
    try:
        await _backfill_path_a_approval(incident_id, approval_id)
    except Exception as e:
        logger.warning(
            "km_backfill_approval_failed",
            incident_id=incident_id,
            approval_id=approval_id,
            error=str(e),
        )
        # 寫獨立 DLQ 供 KMBackfillReconciler 補救
        try:
            from src.core.redis_client import get_redis
            import json
            redis = get_redis()
            record = json.dumps({
                "incident_id": incident_id,
                "approval_id": approval_id,
                "error": str(e),
            }, ensure_ascii=False)
            await redis.lpush(KM_BACKFILL_DLQ_KEY, record)
            await redis.ltrim(KM_BACKFILL_DLQ_KEY, 0, KM_DLQ_MAX_LEN - 1)
        except Exception as dlq_exc:
            logger.error(
                "km_backfill_dlq_write_failed",
                incident_id=incident_id,
                error=str(dlq_exc),
            )


async def _do_write(payload: KMWritePayload) -> None:
    """
    實際執行 KM 寫入（含 M4 反查鏈填充）

    - 若 payload.approval_id 存在，自動填入 entry 的 related_approval_id
    - 若 payload.incident_id + approval_id 均存在，寫完後同步 await backfill Path A 條目
    - path_type 傳入 KnowledgeEntryCreate，觸發 UPSERT 冪等（M3）
    """
    from src.models.knowledge import KnowledgeEntryCreate
    from src.services.knowledge_service import get_knowledge_service

    kwargs = dict(payload.entry_create_kwargs)

    # M4 反查鏈：自動填入 related_approval_id
    if payload.approval_id and "related_approval_id" not in kwargs:
        kwargs["related_approval_id"] = payload.approval_id

    # M3 冪等：注入 path_type + related_incident_id，觸發 UPSERT
    if payload.path_type and "path_type" not in kwargs:
        kwargs["path_type"] = payload.path_type
    if payload.incident_id and "related_incident_id" not in kwargs:
        kwargs["related_incident_id"] = payload.incident_id

    entry_data = KnowledgeEntryCreate(**kwargs)
    await get_knowledge_service().create_entry(entry_data)

    # C1 修復 2026-04-28 ogt + Claude Sonnet 4.6:
    # M4 反查鏈回填：同步 await（原為裸 create_task，Pod recycle 時被丟棄）
    # 回填失敗 → 寫獨立 DLQ key km:backfill:dlq（不影響主寫入結果）
    if payload.approval_id and payload.incident_id:
        await _backfill_path_a_approval_safe(payload.incident_id, payload.approval_id)

    logger.info(
        "km_write_success",
        path_type=payload.path_type,
        incident_id=payload.incident_id,
        approval_id=payload.approval_id,
        title=kwargs.get("title", "")[:80],
    )


class KMWriter:
    """
    KM 統一寫入服務（預設實作）

    所有路徑統一透過此類呼叫，確保：
    - await 強制（KM_WRITE_AWAIT=true）
    - 指數退避重試
    - DLQ 失敗回收
    - M4 反查鏈
    """

    async def write(
        self,
        payload: KMWritePayload,
        *,
        mode: Literal["sync", "async"] = "sync",
        timeout: float | None = None,
        retry: int | None = None,
        on_failure: Literal["dlq", "raise"] = "dlq",
    ) -> KMWriteResult:
        """
        寫入 KM 條目。

        Args:
            payload:    KM 寫入載體
            mode:       "sync"（預設強制 await）；"async" 預留未來管線用
            timeout:    override KM_WRITE_TIMEOUT_SECONDS（None = 使用 settings）
            retry:      最大重試次數（None = 使用模組常數 _RETRY_MAX=3）
            on_failure: "dlq"（預設寫 DLQ）；"raise" = 拋 KMWriteError 給 caller

        Returns:
            KMWriteResult
        """
        if not payload.entry_create_kwargs:
            return KMWriteResult.SKIPPED_NO_DATA

        effective_timeout = timeout if timeout is not None else settings.KM_WRITE_TIMEOUT_SECONDS
        effective_retry = retry if retry is not None else _RETRY_MAX
        last_exc: Exception | None = None

        for attempt in range(1, effective_retry + 1):
            try:
                await asyncio.wait_for(_do_write(payload), timeout=effective_timeout)
                return KMWriteResult.SUCCESS

            except asyncio.TimeoutError:
                logger.warning(
                    "km_write_timeout",
                    path_type=payload.path_type,
                    incident_id=payload.incident_id,
                    attempt=attempt,
                    timeout_sec=effective_timeout,
                )
                # Timeout 不重試（重試也會 timeout，且會阻塞更久）
                if on_failure == "raise":
                    raise KMWriteError(
                        f"KM write timeout after {effective_timeout}s",
                        {"path_type": payload.path_type, "incident_id": payload.incident_id},
                    )
                await _write_to_dlq(payload, f"timeout_{effective_timeout}s")
                return KMWriteResult.TIMEOUT

            except Exception as exc:
                last_exc = exc
                if attempt < effective_retry and _is_retriable(exc):
                    delay = _RETRY_BASE_DELAY * (2 ** (attempt - 1))  # 1s / 2s / 4s
                    logger.warning(
                        "km_write_retry",
                        path_type=payload.path_type,
                        incident_id=payload.incident_id,
                        attempt=attempt,
                        delay_sec=delay,
                        error=str(exc),
                    )
                    await asyncio.sleep(delay)
                else:
                    # 非可重試錯誤，或已達最大重試次數
                    break

        # 所有重試耗盡
        logger.error(
            "km_write_failed_all_retries",
            path_type=payload.path_type,
            incident_id=payload.incident_id,
            approval_id=payload.approval_id,
            error=str(last_exc),
        )
        if on_failure == "raise":
            raise KMWriteError(
                str(last_exc),
                {"path_type": payload.path_type, "incident_id": payload.incident_id},
            )
        await _write_to_dlq(payload, str(last_exc))
        return KMWriteResult.EXCEPTION


# ---------------------------------------------------------------------------
# 模組級單例（與 knowledge_service / decision_manager 等同模式）
# ---------------------------------------------------------------------------

_km_writer: KMWriter | None = None


def get_km_writer() -> KMWriter:
    """取得 KMWriter 單例"""
    global _km_writer
    if _km_writer is None:
        _km_writer = KMWriter()
    return _km_writer


# ---------------------------------------------------------------------------
# 便利函式：包裹舊式 fire-and-forget → 統一契約呼叫
# 供 incident_service / decision_manager / playbook_service 使用
# ---------------------------------------------------------------------------

async def km_write_with_flag(
    payload: KMWritePayload,
    *,
    timeout: float | None = None,
) -> KMWriteResult:
    """
    統一入口：根據 KM_WRITE_AWAIT feature flag 決定執行模式。

    KM_WRITE_AWAIT=true（預設）：await 強制，確保寫入可靠性。
    KM_WRITE_AWAIT=false：緊急降級模式（回滾用）。
      C2 修復 2026-04-28 ogt + Claude Sonnet 4.6：
      不再用 ensure_future 裸丟（Pod recycle 時 task 被丟棄且比舊同步行為更差）。
      改為 await writer.write(retry=1, timeout=2.0)：仍然等待一次嘗試完成，
      但只試一次、timeout 短，失敗寫 DLQ 後繼續。
      docstring 明確標註：KM_WRITE_AWAIT=false 不保證可靠性，僅供緊急回滾用。
    """
    writer = get_km_writer()

    if settings.KM_WRITE_AWAIT:
        return await writer.write(payload, timeout=timeout)
    else:
        # 緊急降級：await 一次嘗試（timeout 縮短為 2s），失敗寫 DLQ，不阻塞太久
        # 注意：此路徑不保證可靠性，KM_WRITE_AWAIT=false 僅供緊急回滾用
        return await writer.write(payload, retry=1, timeout=2.0)
