"""
Drift Narrator Service - Phase 30
===================================
職責：將 DriftReport 轉為繁體中文人話，推送 Telegram

設計邊界：
- 只負責「敘述」，不做分析、不生成修復指令
- 觸發條件：high_count > 0 or medium_count > 2
- 模型：qwen2.5:7b-instruct (Ollama 111, 90s timeout)
- Redis 快取：drift_narrative:{report_id} TTL 1h，避免重複推送
- HPA replicas 自動調整在白名單，不觸發摘要

版本: v1.0
建立: 2026-04-10 (台北時區)
建立者: Claude Code (Phase 30 ADR-067)
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import structlog

from src.core.redis_client import get_redis
from src.services.model_registry import get_model
from src.services.openclaw import get_openclaw

if TYPE_CHECKING:
    from src.models.drift import DriftInterpretation, DriftReport

logger = structlog.get_logger(__name__)

# ============================================================
# 設定
# ============================================================
# D1 集中化 2026-04-11: 從 models.json providers.ollama.models.drift_summary 讀取
NARRATOR_MODEL = get_model("ollama", "drift_summary")
NARRATOR_TIMEOUT = 90.0  # seconds
CACHE_TTL = 3600  # 1 小時
CACHE_PREFIX = "drift_narrative:"

# HPA 自動調整白名單 field_path，不納入敘述
_HPA_ALLOWLIST_PATHS = {
    "spec.replicas",
}

# 觸發條件
TRIGGER_HIGH_MIN = 1
TRIGGER_MEDIUM_MIN = 3


# ============================================================
# Prompt
# ============================================================
# 2026-04-18 ogt + Claude Opus 4.7: B 方案 — LLM 驅動智能摘要（取代 Python str()[:30] 截斷）
# 架構鐵律: 捨棄 Python 寫死字串解析，結構化 diff 直接餵 LLM,由 LLM 產出繁中 Top 5 摘要
# 2026-04-20 P0.2 ogt + Claude Opus 4.7: 加 recommendation 輸出，LLM 推薦該按哪顆按鈕
#   - action ∈ {adopt, revert, ignore, investigate}
#   - confidence 0.0-1.0（統帥指令：先不 auto-execute，門檻 0.85 保留給未來）
#   - reason 一行繁中解釋
_NARRATIVE_PROMPT = """你是 AWOOOI SRE 維運助理。以下是 K8s Config Drift 報告的原始結構化資料。

## 漂移項目原始資料（JSON）
{drift_items_json}

## 意圖分析
{intent_summary}

## 輸出規格（必須是合法 JSON，不得有任何前後文字）
{{
  "narrative": "4-5 行繁體中文敘述,說明漂移了哪些資源/嚴重程度/可能原因/建議動作",
  "items": [
    {{
      "level": "high 或 medium",
      "field": "簡化後的欄位路徑 (40 字內)",
      "summary": "30 字內繁體中文口語摘要,說明從什麼變成什麼"
    }}
  ],
  "recommendation": {{
    "action": "adopt 或 revert 或 ignore 或 investigate",
    "confidence": 0.85,
    "reason": "一行繁體中文解釋為何推薦此動作（含關鍵證據）"
  }}
}}

## recommendation action 語意
- adopt: 現狀合理，應把 K8s 狀態寫回 Git (例：HPA 自動擴縮、緊急 hotfix 已驗證)
- revert: 漂移有風險，應回滾到 Git 狀態 (例：image tag 被誤改、secret 被外部改)
- ignore: 噪音，K8s controller 自動補齊 (例：空 list/dict 差異)
- investigate: 不確定，需要人工查清楚

## 規則
- 繁體中文
- items 最多挑 5 筆最重要的（HIGH 優先）
- summary 要讓非技術人員看懂「改了什麼」,例如:
  - "新增 repair-ssh-key secret 掛載"（而非 repr 一長串）
  - "(未設) → awoooi-executor"
  - "新增 pod anti-affinity 規則"
- 禁止 markdown、反引號、emoji
- 只輸出純 JSON,不要包在 code block 裡
- recommendation.confidence 要誠實（HIGH drift 且意圖不明 → 0.3-0.5；trivial noise → 0.9）
"""


class DriftNarratorService:
    """
    Drift 報告人話摘要服務

    職責邊界：
    ✅ 呼叫 qwen2.5:7b-instruct 生成繁中摘要
    ✅ Redis 快取（避免重複推送）
    ✅ 推送 Telegram
    ❌ 不做漂移分析
    ❌ 不生成修復指令
    """

    async def narrate_and_notify(
        self,
        report: DriftReport,
        interpretation: DriftInterpretation | None = None,
    ) -> None:
        """
        生成人話摘要並推送 Telegram

        只在 high_count > 0 or medium_count >= TRIGGER_MEDIUM_MIN 時執行
        """
        if not self._should_narrate(report):
            logger.debug(
                "drift_narrator_skip",
                report_id=report.report_id,
                high=report.high_count,
                medium=report.medium_count,
            )
            return

        # Redis 快取檢查（同 report_id 不重複推送）
        cache_key = f"{CACHE_PREFIX}{report.report_id}"
        redis = await get_redis()
        if await redis.exists(cache_key):
            logger.debug("drift_narrator_cache_hit", report_id=report.report_id)
            return

        # 2026-04-18 B 方案: LLM 同時產 narrative + 結構化 items（取代 str()[:30]）
        # 2026-04-20 P0.2: 追加 recommendation（action/confidence/reason）
        narrative, items, recommendation = await self._generate_narrative_and_items(report, interpretation)
        repeat_state = None
        try:
            from src.repositories.drift_repository import get_drift_repository
            repeat_state = await get_drift_repository().get_repeat_state(report)
        except Exception as e:
            logger.warning("drift_repeat_state_lookup_failed", report_id=report.report_id, error=str(e))
        await self._send_telegram(report, narrative, items, recommendation, repeat_state)

        # 寫入 DB narrative_text (Phase 30 ADR-067)
        try:
            from src.repositories.drift_repository import get_drift_repository
            await get_drift_repository().update_narrative(report.report_id, narrative)
        except Exception as e:
            logger.warning("drift_narrator_db_write_failed", error=str(e))

        # 寫入快取
        await redis.set(cache_key, narrative[:500], ex=CACHE_TTL)

        logger.info(
            "drift_narrator_sent",
            report_id=report.report_id,
            high=report.high_count,
            medium=report.medium_count,
        )

    def _should_narrate(self, report: DriftReport) -> bool:
        """觸發條件：high >= 1 or medium >= 3"""
        # 過濾 HPA 白名單後重算
        non_hpa_items = [
            item for item in report.items
            if item.field_path not in _HPA_ALLOWLIST_PATHS
            and not item.is_allowlisted
        ]
        high = sum(1 for i in non_hpa_items if i.drift_level.value == "high")
        medium = sum(1 for i in non_hpa_items if i.drift_level.value == "medium")
        return high >= TRIGGER_HIGH_MIN or medium >= TRIGGER_MEDIUM_MIN

    async def _generate_narrative_and_items(
        self,
        report: DriftReport,
        interpretation: DriftInterpretation | None,
    ) -> tuple[str, list[dict], dict]:
        """
        2026-04-18 ogt + Claude Opus 4.7: B 方案 — LLM 產生 narrative + 結構化 items
        2026-04-20 P0.2 ogt + Claude Opus 4.7: 追加 recommendation（AI 推薦按鈕）

        回傳 (narrative, items, recommendation):
          narrative: 繁中 4-5 行敘述
          items: [{level, field, summary}, ...] 最多 5 筆
          recommendation: {action, confidence, reason}
            action ∈ {adopt, revert, ignore, investigate}
            confidence 0.0-1.0（統帥指令：先不 auto-execute，僅顯示供統帥參考）

        LLM 失敗則 fallback 到 Python 智能截斷（不是 str()[:30] 暴力砍）

        2026-04-18 ADR-090-C: 每次呼叫同步寫入 automation_operation_log +
        ai_collaboration_trace(不論成功或 fallback),完整 L4 稽核。
        """
        import json as _json
        import time

        drift_items_json = self._format_drift_for_llm(report)
        intent_summary = self._format_intent_summary(interpretation)

        prompt = _NARRATIVE_PROMPT.format(
            drift_items_json=drift_items_json,
            intent_summary=intent_summary,
        )

        started_ms = time.time()
        narrative: str = ""
        items: list[dict] = []
        recommendation: dict = {}  # 2026-04-20 P0.2
        raw_response: str | None = None
        provider: str = "unknown"
        status: str = "failed"
        llm_accepted: bool = False

        try:
            openclaw = get_openclaw()
            text, _provider, success = await openclaw.call(prompt)
            provider = _provider or "unknown"
            raw_response = text if text else None

            if success and text and text.strip():
                _raw = text.strip()
                if _raw.startswith("```"):
                    _raw = _raw.strip("`").lstrip("json").strip()

                # 解析策略: 3 路 fallback
                # Path 1: 直接我們的 {narrative, items} 結構 (純 Ollama 或 LLM 守規矩)
                # Path 2: NEMOTRON wrapper {description,...} 且 description 內含我們的結構
                # Path 3: NEMOTRON wrapper,description 是純敘述 → 用它當 narrative + Python fallback items
                _parsed_narrative = None
                _parsed_items = None
                try:
                    _parsed = _json.loads(_raw)
                    if isinstance(_parsed, dict):
                        # Path 1
                        if "narrative" in _parsed and isinstance(_parsed.get("items"), list):
                            _parsed_narrative = str(_parsed["narrative"]).strip()
                            _parsed_items = _parsed["items"]
                        else:
                            # Path 2 / Path 3: NEMOTRON wrapper
                            _desc = (
                                _parsed.get("description")
                                or _parsed.get("action_title")
                                or _parsed.get("reasoning")
                                or ""
                            )
                            _desc = str(_desc).strip()
                            # Path 2: description 本身是巢狀 JSON 含我們結構?
                            if _desc.startswith("{") and "narrative" in _desc:
                                try:
                                    _inner = _json.loads(_desc)
                                    if isinstance(_inner, dict) and "narrative" in _inner:
                                        _parsed_narrative = str(_inner.get("narrative", "")).strip()
                                        _parsed_items = _inner.get("items", []) if isinstance(_inner.get("items"), list) else None
                                except (_json.JSONDecodeError, ValueError):
                                    pass
                            # Path 3: 只有 narrative(來自 description),items 用 Python fallback
                            if _parsed_narrative is None and _desc:
                                _parsed_narrative = _desc
                                _parsed_items = None  # 觸發下方 fallback_items
                except (_json.JSONDecodeError, ValueError) as e:
                    logger.warning("drift_narrator_json_parse_fail", err=str(e),
                                   raw_prefix=_raw[:100], provider=provider)

                # 驗證 + 清洗
                if _parsed_narrative:
                    # 清洗 items (若 LLM 有給)
                    clean_items = []
                    if isinstance(_parsed_items, list):
                        for it in _parsed_items[:5]:
                            if isinstance(it, dict) and it.get("field") and it.get("summary"):
                                clean_items.append({
                                    "level": it.get("level", "medium"),
                                    "field": str(it["field"])[:60],
                                    "summary": str(it["summary"])[:80],
                                })
                    # items 空就用 Python smart fallback (不是 str()[:30])
                    if not clean_items:
                        clean_items = self._fallback_items(report)

                    # 2026-04-20 P0.2: 解析 recommendation（若 LLM 給了）
                    _rec = None
                    try:
                        if isinstance(_parsed, dict):
                            _rec = _parsed.get("recommendation")
                            # Path 2 場景：recommendation 也可能藏在 _inner
                            if _rec is None and _parsed.get("description", "").startswith("{"):
                                _inner_txt = str(_parsed["description"]).strip()
                                _inner = _json.loads(_inner_txt)
                                if isinstance(_inner, dict):
                                    _rec = _inner.get("recommendation")
                    except (_json.JSONDecodeError, ValueError, KeyError):
                        _rec = None
                    if isinstance(_rec, dict) and _rec.get("action"):
                        _act = str(_rec.get("action", "")).strip().lower()
                        if _act in ("adopt", "revert", "ignore", "investigate"):
                            try:
                                _conf = float(_rec.get("confidence", 0.0))
                            except (TypeError, ValueError):
                                _conf = 0.0
                            _conf = max(0.0, min(1.0, _conf))
                            recommendation = {
                                "action": _act,
                                "confidence": _conf,
                                "reason": str(_rec.get("reason", ""))[:200],
                            }

                    narrative = _parsed_narrative
                    items = clean_items
                    status = "success"
                    llm_accepted = True

            if not llm_accepted:
                logger.warning("drift_narrator_openclaw_failed", provider=provider)

        except Exception as e:
            logger.warning("drift_narrator_llm_error", error=str(e))

        # Fallback
        if not llm_accepted:
            narrative = self._fallback_narrative(report, interpretation)
            items = self._fallback_items(report)
            status = "failed"

        # 2026-04-20 P0.2: LLM 未給 recommendation 就走 Python fallback
        if not recommendation:
            recommendation = self._fallback_recommendation(report, interpretation)

        # ADR-090-C: 同步寫 DB 稽核（永不 propagate error,保護主流程）
        duration_ms = int((time.time() - started_ms) * 1000)
        try:
            await self._log_ai_action_to_db(
                report=report,
                prompt=prompt,
                raw_response=raw_response,
                narrative=narrative,
                items=items,
                provider=provider,
                status=status,
                llm_accepted=llm_accepted,
                duration_ms=duration_ms,
            )
        except Exception as e:
            logger.warning("drift_narrator_audit_write_failed", error=str(e))

        return narrative, items, recommendation

    def _fallback_recommendation(
        self,
        report: DriftReport,
        interpretation: DriftInterpretation | None,
    ) -> dict:
        """
        2026-04-20 P0.2 ogt + Claude Opus 4.7: LLM 沒給 recommendation 時的 Python fallback

        規則式推薦（保守）：
          - 全部 trivial/白名單 → ignore (0.8)
          - 有 HIGH drift + intent=emergency_hotfix → adopt (0.5)  (不確定，降信心)
          - 有 HIGH drift + intent=human_error → revert (0.7)
          - 其他 → investigate (0.4)（請人工介入）
        """
        actionable = self._count_nontrivial_drift(report)
        if actionable == 0:
            return {
                "action": "ignore",
                "confidence": 0.8,
                "reason": "全部為白名單或 K8s 預設值補齊，無實質變更。",
            }

        _has_high = report.high_count > 0
        _intent = interpretation.intent.value if interpretation else "unknown"

        if _has_high and _intent == "emergency_hotfix":
            return {
                "action": "adopt",
                "confidence": 0.5,
                "reason": "HIGH drift 但意圖分析為緊急 hotfix，建議採納並補 Git（請人工複核）。",
            }
        if _has_high and _intent == "human_error":
            return {
                "action": "revert",
                "confidence": 0.7,
                "reason": "HIGH drift 且意圖分析為人為誤操作，建議回滾 Git 狀態。",
            }
        return {
            "action": "investigate",
            "confidence": 0.4,
            "reason": f"有 {actionable} 項可操作漂移，意圖={_intent}，需人工查清楚再決定。",
        }

    async def _log_ai_action_to_db(
        self,
        report: DriftReport,
        prompt: str,
        raw_response: str | None,
        narrative: str,
        items: list[dict],
        provider: str,
        status: str,
        llm_accepted: bool,
        duration_ms: int,
    ) -> None:
        """
        ADR-090-C: 把 drift narrator 的 AI 動作寫入 automation_operation_log +
        ai_collaboration_trace(L4 稽核 + 未來 RLHF 語料)

        - op_type='notification_formatted'
        - actor='drift_narrator'
        - 若能找到該 drift 的 incident 關聯,設 parent_op_id
        """
        import json as _json

        from sqlalchemy import text as _sql

        from src.db.base import get_db_context

        input_json = _json.dumps({
            "report_id": report.report_id,
            "namespace": report.namespace,
            "high_count": report.high_count,
            "medium_count": report.medium_count,
            "items_scanned": len(report.items),
        })
        output_json = _json.dumps({
            "narrative": narrative,
            "items": items,
            "items_count": len(items),
        }, ensure_ascii=False)
        trace_response = _json.dumps({
            "narrative": narrative,
            "items": items,
            "raw_prefix": (raw_response or "")[:500],
        }, ensure_ascii=False)

        async with get_db_context() as db:
            # P2.4: 嘗試找 parent_op_id（若未來有 drift→alert_fired 鏈路）
            parent_row = await db.execute(
                _sql("""
                    SELECT op_id FROM automation_operation_log
                    WHERE operation_type='alert_fired'
                      AND (input::jsonb->>'report_id' = :rid
                           OR input::jsonb->>'drift_report_id' = :rid)
                    ORDER BY created_at DESC LIMIT 1
                """),
                {"rid": report.report_id},
            )
            parent_op_id = parent_row.scalar() if parent_row else None

            # 寫 automation_operation_log
            # 2026-04-18 hotfix: tags 要傳 Python list（不是 PG array literal 字串）
            #                   否則 asyncpg 會報 "a sized iterable container expected"
            op_row = await db.execute(
                _sql("""
                    INSERT INTO automation_operation_log (
                        operation_type, actor, status,
                        input, output,
                        duration_ms, parent_op_id, tags
                    ) VALUES (
                        'notification_formatted',
                        'drift_narrator',
                        :status,
                        CAST(:input AS jsonb),
                        CAST(:output AS jsonb),
                        :duration_ms, :parent_op_id,
                        :tags
                    )
                    RETURNING op_id
                """),
                {
                    "status": status,
                    "input": input_json,
                    "output": output_json,
                    "duration_ms": duration_ms,
                    "parent_op_id": parent_op_id,
                    "tags": ["drift", "type4d", "llm_summary"],
                },
            )
            op_id = op_row.scalar()

            # 寫 ai_collaboration_trace
            await db.execute(
                _sql("""
                    INSERT INTO ai_collaboration_trace (
                        op_id, step_order, agent, model,
                        prompt, response, duration_ms, accepted
                    ) VALUES (
                        :op_id, 1, 'drift_narrator', :model,
                        :prompt, CAST(:response AS jsonb), :duration_ms, :accepted
                    )
                """),
                {
                    "op_id": op_id,
                    "model": provider,
                    "prompt": prompt[:8000],
                    "response": trace_response,
                    "duration_ms": duration_ms,
                    "accepted": llm_accepted,
                },
            )
            # get_db_context() 在 exit 時 auto-commit（src/db/base.py:128）
            logger.info(
                "drift_narrator_audit_written",
                op_id=str(op_id),
                parent_op_id=str(parent_op_id) if parent_op_id else None,
                status=status,
                items_count=len(items),
            )

    def _format_drift_for_llm(self, report: DriftReport) -> str:
        """
        2026-04-18 ogt + Claude Opus 4.7: B 方案 — 餵 LLM 用的 JSON 序列化
        保留更多原始 context 給 LLM 推理,不做 30 字元暴力截斷
        """
        import json as _json
        items_for_llm = []
        for item in report.items[:12]:
            if item.is_allowlisted or item.field_path in _HPA_ALLOWLIST_PATHS:
                continue
            items_for_llm.append({
                "level": item.drift_level.value,
                "resource": f"{item.resource_kind}/{item.resource_name}",
                "field": item.field_path,
                "git_value": str(item.git_value)[:200] if item.git_value is not None else None,
                "actual_value": str(item.actual_value)[:200] if item.actual_value is not None else None,
            })
        return _json.dumps(items_for_llm, ensure_ascii=False, indent=2)

    def _smart_shorten(self, val) -> str:
        """型別安全摘要 — dict/list 顯示大小,字串保留頭尾,None 轉「未設」"""
        if val is None:
            return "(未設)"
        s = str(val)
        if s in ("{}", "[]"):
            return "空"
        if s.startswith("[") and s.endswith("]"):
            return f"[清單 {s.count(',')+1 if s != '[]' else 0} 項]"
        if s.startswith("{") and s.endswith("}"):
            return f"{{物件 {s.count(':')} 欄位}}"
        if len(s) > 40:
            return s[:37] + "..."
        return s

    def _is_trivial_drift(self, git_val, actual_val) -> bool:
        """
        判斷是否為 K8s controller 自動補齊的噪音
        (例: None ↔ {} / None ↔ [] / {} ↔ [] 等視為無實質變更)
        """
        def _is_empty(v):
            if v is None:
                return True
            s = str(v).strip()
            return s in ("", "{}", "[]", "null", "None", "false", "False", "0")
        return _is_empty(git_val) and _is_empty(actual_val)

    def _summarize_item(self, item) -> str:
        """
        生成一筆 drift 的人話摘要 (fallback 用)
        - 空 vs 空 → 標註為 controller 自動補齊
        - None → 新增 → 顯示新值摘要
        - 有值 → 有值 → 顯示前後變化
        """
        git_val = item.git_value
        actual_val = item.actual_value

        if self._is_trivial_drift(git_val, actual_val):
            return "K8s 預設值補齊 (無實質變更)"

        from_val = self._smart_shorten(git_val)
        to_val = self._smart_shorten(actual_val)

        # None → 有值: 新增
        if git_val is None and actual_val is not None:
            return f"新增 {to_val}"
        # 有值 → None: 刪除
        if git_val is not None and actual_val is None:
            return f"已刪除 (原: {from_val})"
        # 一般變化
        return f"{from_val} → {to_val}"

    def _fallback_items(self, report: DriftReport) -> list[dict]:
        """
        LLM 失敗時的 Python 智能摘要 (取代舊 str()[:30])
        - 過濾白名單
        - 優先 HIGH
        - trivial drift 標註為「預設值補齊」
        """
        # 按 level 排序 (HIGH 優先) 並過濾白名單
        filtered = [
            it for it in report.items
            if not it.is_allowlisted and it.field_path not in _HPA_ALLOWLIST_PATHS
        ]
        filtered.sort(key=lambda x: 0 if x.drift_level.value == "high" else 1)

        items = []
        for item in filtered[:5]:
            items.append({
                "level": item.drift_level.value,
                "field": item.field_path[:60],
                "summary": self._summarize_item(item),
            })
        return items

    def _format_intent_summary(self, interpretation: DriftInterpretation | None) -> str:
        if not interpretation:
            return "無意圖分析"
        return (
            f"意圖: {interpretation.intent.value} | "
            f"說明: {interpretation.explanation} | "
            f"信心: {interpretation.confidence:.0%}"
        )

    def _fallback_narrative(
        self,
        report: DriftReport,
        interpretation: DriftInterpretation | None,
    ) -> str:
        """LLM 失敗時的結構化 fallback"""
        resources = list({
            f"{i.resource_kind}/{i.resource_name}"
            for i in report.items[:5]
            if not i.is_allowlisted
        })
        resource_str = "、".join(resources) if resources else "未知資源"
        intent_str = interpretation.explanation if interpretation else "意圖分析不可用"
        return (
            f"偵測到 {resource_str} 等資源發生配置漂移。\n"
            f"嚴重度：HIGH {report.high_count} 項、MEDIUM {report.medium_count} 項。\n"
            f"研判原因：{intent_str}\n"
            f"建議：確認是否需要 rollback 回 Git 狀態。"
        )

    async def _send_telegram(
        self,
        report: DriftReport,
        narrative: str,
        items: list[dict],
        recommendation: dict | None = None,
        repeat_state: dict | None = None,
    ) -> None:
        """
        推送 TYPE-4D Config Drift 卡片（ADR-075）+ B 方案智能摘要

        2026-04-18 ogt + Claude Opus 4.7: 改用 LLM 產的結構化 items,
        取代 str()[:30] 暴力截斷產生的亂碼
        2026-04-20 P0.2 ogt + Claude Opus 4.7: recommendation 顯示在卡片頂部
        （統帥指令：先不 auto-execute，純顯示推薦讓人一眼知道按哪顆）
        """
        from src.services.telegram_gateway import get_telegram_gateway

        diff_summary = self._render_telegram_body(report, narrative, items, recommendation, repeat_state)

        try:
            tg = get_telegram_gateway()
            # 2026-04-20 P0.2: 500 → 1500 字上限，讓 AI 推薦 + narrative + items 都能容納
            # （send_drift_card 已同步放寬 HTML 顯示上限至 1500）
            await tg.send_drift_card(
                incident_id=report.report_id,
                approval_id=report.report_id,
                resource_name=report.namespace,
                diff_summary=diff_summary[:1500],
                detected_at="",
            )
        except Exception as e:
            logger.warning("drift_narrator_telegram_error", error=str(e))

    def _count_nontrivial_drift(self, report: DriftReport) -> int:
        """
        計算非白名單、非 trivial (K8s 自動補齊) 的 drift 數
        用於 Telegram 底部「還有 N 項」顯示實際可操作數量
        """
        n = 0
        for item in report.items:
            if item.is_allowlisted or item.field_path in _HPA_ALLOWLIST_PATHS:
                continue
            if self._is_trivial_drift(item.git_value, item.actual_value):
                continue
            n += 1
        return n

    def _shorten_field_path(self, field: str) -> str:
        """
        砍掉常見冗長前綴,讓 Telegram 排版不換行

        處理 2 種場景:
          A. 開頭即前綴: 'spec.template.spec.volumes' → 'volumes'
          B. LLM 雞婆加資源識別符: 'Deployment/awoooi-web: spec.template.spec.containers'
             → 'Deployment/awoooi-web: containers'

        用 replace 比 startswith 更有韌性,包容 LLM 前綴幻覺。
        """
        # 先移除 absolute prefix（若開頭）
        for prefix in ("spec.template.spec.", "spec.template.", "spec."):
            if field.startswith(prefix):
                return field[len(prefix):]
        # 中間出現（LLM 加 'Resource/Name: spec.template.spec.xxx' 場景）
        for prefix in ("spec.template.spec.", "spec.template."):
            if prefix in field:
                return field.replace(prefix, "")
        return field

    def _render_telegram_body(
        self,
        report: DriftReport,
        narrative: str,
        items: list[dict],
        recommendation: dict | None = None,
        repeat_state: dict | None = None,
    ) -> str:
        """
        組裝 Telegram 卡片 body（B 方案格式 + P0.2 AI 推薦）

        範例輸出:
          🎯 AI 建議：⏪ 回滾 (85%) — image tag 被手動改到未驗證版本

          🤖 AI 研判
          volumes 與 affinity 被手動修改...

          📊 漂移明細 (HIGH: 1 | MEDIUM: 29)
          🔴 spec.template.spec.volumes: 新增 2 項 repair-ssh-key 掛載
          🟡 spec.template.spec.serviceAccount: (未設) → awoooi-executor
          ... 還有 27 項
        """
        lines = []

        # 2026-04-20 P0.2 AI 推薦（頂部，純推薦不自動執行）
        if recommendation and recommendation.get("action"):
            _act = recommendation["action"]
            _conf = float(recommendation.get("confidence", 0.0))
            _reason = recommendation.get("reason", "")
            _emoji_action = {
                "adopt": "✅ 採納",
                "revert": "⏪ 回滾",
                "ignore": "🔕 忽略",
                "investigate": "🔍 人工調查",
            }.get(_act, _act)
            lines.append(f"🎯 AI 建議：{_emoji_action} ({int(_conf * 100)}%) — {_reason}\n")

        repeat_line = self._render_repeat_state(repeat_state)
        if repeat_line:
            lines.append(f"{repeat_line}\n")

        lines.append(f"🤖 AI 研判\n{narrative}\n")

        # 用非 trivial + 非白名單 的實際可操作數顯示
        actionable = self._count_nontrivial_drift(report)
        lines.append(f"📊 漂移明細 (HIGH: {report.high_count} | MEDIUM: {report.medium_count} | 可操作: {actionable})")

        if not items:
            lines.append("  (全部為白名單或 K8s 預設值補齊,無實質變更)")
        else:
            for it in items:
                emoji = "🔴" if it.get("level") == "high" else "🟡"
                short_field = self._shorten_field_path(it['field'])
                lines.append(f"{emoji} {short_field}: {it['summary']}")

        shown = len(items)
        if actionable > shown:
            lines.append(f"... 還有 {actionable - shown} 項 (按 🔍 查看 Diff)")

        return "\n".join(lines)

    def _render_repeat_state(self, repeat_state: dict | None) -> str:
        """Render operator-visible repeat/stage metadata for Telegram."""
        if not repeat_state:
            return ""
        fingerprint = str(repeat_state.get("fingerprint") or "unknown")
        occurrences = int(repeat_state.get("occurrences_12h") or 0)
        window_hours = int(repeat_state.get("window_hours") or 12)
        stage = str(repeat_state.get("operator_stage") or "unknown")
        if occurrences <= 1:
            repeat_text = f"{window_hours}h 內首次出現"
        else:
            repeat_text = f"{window_hours}h 內第 {occurrences} 次同指紋"
        return (
            "流程: drift_scanned → ai_analyzed → "
            f"{stage}\n重複: {repeat_text}\n指紋: {fingerprint}"
        )


# ============================================================
# Singleton
# ============================================================

_narrator: DriftNarratorService | None = None


def get_drift_narrator_service() -> DriftNarratorService:
    global _narrator
    if _narrator is None:
        _narrator = DriftNarratorService()
    return _narrator
