"""
AWOOOI AIOps Phase 1 — 決策前情報調查員
==========================================
在 LLM 做出任何決策之前，主動呼叫 MCP 工具蒐集 8D 感官情報，
並將結果封裝為不可變的 EvidenceSnapshot。

設計原則：
1. 工具動態選擇（不 hardcode）— 從 MCPToolRegistry.suggest_tools() 取清單
2. 並行蒐集（asyncio.gather）— 8D 感官同步展開，P99 < 8s
3. 部分失敗不阻塞（Graceful Degradation）— 某感官失敗標 mcp_health[tool]=False，繼續其他
4. Prompt Injection 防護（Sanitization）— 所有文字輸入先過 SanitizationService
5. Redis 快取（30s 滑動窗口）— 防告警風暴重複打 K8s API

快取 Key 格式：
    evidence:{sha256(alertname + namespace + pod_name + severity)[:12]}

P99 延遲目標：< 8000ms（超時個別工具丟棄，不阻塞主路徑）
Token Budget：單次 evidence_summary ≤ 32,000 chars（≈ 8K tokens）

ADR-081: PreDecisionInvestigator + EvidenceSnapshot
MASTER §3.1.3 (A)(B)(C)
2026-04-15 ogt + Claude Sonnet 4.6 (亞太): Phase 1 初始建立
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import time
from typing import TYPE_CHECKING, Any

import structlog

from src.db.base import get_db_context
from src.plugins.mcp.gateway import GatewayContext, McpGateway
from src.plugins.mcp.registry import AuditedMCPToolProvider
from src.services.evidence_snapshot import EvidenceSnapshot
from src.services.mcp_audit_context import with_mcp_audit_context
from src.services.mcp_tool_registry import RegisteredTool, SensorDimension, get_mcp_tool_registry
from src.services.sanitization_service import sanitize, sanitize_dict_values

if TYPE_CHECKING:
    from src.models.incident import Incident

logger = structlog.get_logger(__name__)

# 單一 MCP 工具呼叫的超時（秒）— 超過則丟棄，不阻塞主路徑
MCP_TOOL_TIMEOUT_SEC = 5.0

# 全局 Investigator 超時（P99 目標）
INVESTIGATOR_TIMEOUT_SEC = 8.0

# Redis 快取 TTL（秒）
CACHE_TTL_SEC = 30


class PreDecisionInvestigator:
    """
    決策前情報調查員。

    每個 Incident 在 LLM 推理前，先由此服務蒐集 8D 感官數據，
    產出 EvidenceSnapshot 作為 LLM 的「眼睛」。

    Usage:
        investigator = PreDecisionInvestigator()
        snapshot = await investigator.investigate(incident)
        # snapshot.evidence_summary 可直接貼進 LLM prompt
    """

    def __init__(self) -> None:
        self._registry = get_mcp_tool_registry()

    async def investigate(self, incident: "Incident") -> EvidenceSnapshot:
        """
        主入口：為 Incident 蒐集 8D 感官情報。

        流程：
          1. 計算 fingerprint → 查 Redis cache
          2. cache miss → 並行呼叫 suggest_tools() 回傳的工具
          3. 每個工具結果過 SanitizationService
          4. 組裝 EvidenceSnapshot → 寫 incident_evidence 表
          5. 寫 Redis cache

        Args:
            incident: 目前處理中的 Incident

        Returns:
            EvidenceSnapshot: 含 evidence_summary 的完整快照
                              （即使所有 MCP 失敗也回傳空快照，不 raise）
        """
        start_ms = int(time.monotonic() * 1000)
        incident_id = incident.incident_id if hasattr(incident, "incident_id") else str(incident.id)

        # 1. 計算 fingerprint 並查 cache
        fingerprint = _compute_fingerprint(incident)
        cached = await _get_cache(fingerprint)
        if cached is not None:
            logger.debug("investigator_cache_hit", incident_id=incident_id, fingerprint=fingerprint)
            return cached

        # 2. 取工具清單
        alertname = _get_alertname(incident)
        labels = _get_labels(incident)
        tools = self._registry.suggest_tools(
            alertname=alertname,
            incident_labels=labels,
        )

        snapshot = EvidenceSnapshot(incident_id=incident_id)
        snapshot.sensors_attempted = len(tools)

        # 告警基礎資訊：sensors=0 時 AI 至少知道是什麼告警
        # 2026-04-16 ogt + Claude Sonnet 4.6: 修復空 evidence → ABSTAIN 問題
        sigs = getattr(incident, "signals", []) or []
        sig0 = sigs[0] if sigs else None
        snapshot.alert_info = {
            "alert_name": alertname or getattr(incident, "alertname", "") or "",
            "severity": str(getattr(incident, "severity", "")),
            "affected_services": getattr(incident, "affected_services", []) or [],
            "labels": labels,
            "annotations": (
                ({k: v for k, v in (sig0.annotations or {}).items()} if sig0 else {})
            ),
            "source": getattr(sig0, "source", "") if sig0 else "",
            "incident_id": incident_id,
        }

        # 3. 並行蒐集（整體 INVESTIGATOR_TIMEOUT_SEC 保護）
        try:
            await asyncio.wait_for(
                self._collect_all(snapshot, tools, incident),
                timeout=INVESTIGATOR_TIMEOUT_SEC,
            )
        except asyncio.TimeoutError:
            logger.warning(
                "investigator_global_timeout",
                incident_id=incident_id,
                timeout_sec=INVESTIGATOR_TIMEOUT_SEC,
            )

        # 4. 記錄耗時
        snapshot.collection_duration_ms = int(time.monotonic() * 1000) - start_ms

        # 4.5 Phase 4 8D 感官增強：填入動態異常上下文（非阻塞，失敗不影響主路徑）
        try:
            await asyncio.wait_for(
                self._collect_phase4_anomalies(snapshot),
                timeout=2.0,  # Phase 4 感官最多等 2s，不能拖慢主路徑
            )
        except asyncio.TimeoutError:
            logger.warning("phase4_anomaly_collect_timeout", incident_id=incident_id)
        except Exception:
            logger.exception("phase4_anomaly_collect_error", incident_id=incident_id)

        # 4.6 P3.1-T2 by Claude 2026-04-27 — DiagnosisAggregator Pod 深診斷（守門：ENABLE_DIAGNOSIS_AGGREGATOR）
        # Conservative 策略：預設關閉，避免與 MCP sensor 重複收集 K8s+SignOz 資料。
        # 待重疊分析完成確認互補性後，由統帥設定 ENABLE_DIAGNOSIS_AGGREGATOR=true 啟用。
        try:
            from src.core.config import settings as _settings
            if _settings.ENABLE_DIAGNOSIS_AGGREGATOR:
                await asyncio.wait_for(
                    self._collect_diagnosis_aggregator(snapshot, incident),
                    timeout=3.0,
                )
        except asyncio.TimeoutError:
            logger.warning("diagnosis_aggregator_collect_timeout", incident_id=incident_id)
        except Exception:
            logger.warning("diagnosis_aggregator_collect_failed", incident_id=incident_id)

        # 5. 組裝 summary
        snapshot.evidence_summary = snapshot.build_summary()

        # 6. 持久化（fire-and-await，Phase 3 學習閉環依賴此表）
        try:
            await snapshot.save()
        except Exception:
            logger.exception("investigator_save_failed", incident_id=incident_id)
            # 不 raise：snapshot 仍可用於決策，存儲失敗不阻塞主路徑

        # 7. 寫 cache
        await _set_cache(fingerprint, snapshot)

        logger.info(
            "investigator_done",
            incident_id=incident_id,
            sensors_attempted=snapshot.sensors_attempted,
            sensors_succeeded=snapshot.sensors_succeeded,
            duration_ms=snapshot.collection_duration_ms,
        )
        return snapshot

    async def _collect_diagnosis_aggregator(
        self,
        snapshot: EvidenceSnapshot,
        incident: "Incident",  # noqa: ARG002 — 路徑 A 從 snapshot 取 raw 資料，不需 incident labels
    ) -> None:
        """
        2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層補 PDI

        路徑 A：用 DA 的信號分類補 PDI raw 資料。
        不重複收集 K8s/SignOz，只取 raw 資料（來自 PDI 已收集的 D1/D2/D3）
        丟給 DA.classify_signals_from_raw() 做業務邏輯分類（OOMKilled/CrashLoop/HighLatency 等）。
        結果以結構化 dict 存入 snapshot.extra_diagnosis。
        """
        from src.services.diagnosis_aggregator import get_diagnosis_aggregator

        try:
            aggregator = get_diagnosis_aggregator()

            # 從 snapshot 取 PDI 已收集的 raw 資料（不打外部 API）
            signals = aggregator.classify_signals_from_raw(
                k8s_data=snapshot.k8s_state,
                logs_data=snapshot.recent_logs,
                metrics_data=snapshot.metrics_snapshot,
            )

            result = {
                "signal_count": len(signals),
                "signals": [s.to_dict() if hasattr(s, "to_dict") else str(s) for s in signals],
            }
            snapshot.extra_diagnosis = result

            logger.debug(
                "diagnosis_aggregator_signal_classify_done",
                incident_id=snapshot.incident_id,
                signal_count=len(signals),
            )
        except Exception as e:
            logger.warning("diagnosis_aggregator_signal_classify_failed", error=str(e))

    async def _collect_phase4_anomalies(self, snapshot: EvidenceSnapshot) -> None:
        """
        Phase 4 8D 感官增強：從 ProactiveInspector 快取 + LogAnomalyDetector
        讀取動態異常上下文，填入 snapshot.anomaly_context。

        設計原則：
        - 只讀快取，不觸發新的 Prometheus 查詢（避免延遲）
        - 失敗靜默降級（外層已包 try/except + timeout）
        - Phase 4 Shadow Mode 時資料仍填入（供 LLM 參考，不觸發 Alert）

        2026-04-15 ogt + Claude Sonnet 4.6（亞太）: Phase 4 8D 升級
        """
        from src.services.proactive_inspector import get_proactive_inspector
        from src.services.log_anomaly_detector import get_log_anomaly_detector

        context: dict[str, Any] = {}

        # 1. 讀取最近一次巡檢報告（ProactiveInspector 每 5 分鐘更新一次）
        inspector = get_proactive_inspector()
        last_report = inspector.get_last_report()
        if last_report is not None:
            context["last_inspection_at"] = last_report.finished_at
            context["shadow_mode"] = last_report.shadow_mode

            if last_report.baseline_anomalies > 0:
                context["baseline_anomalies"] = [
                    {
                        "metric": a.metric_name,
                        "severity": a.severity,
                        "description": a.description,
                        "deviation_sigma": a.deviation_sigma,
                    }
                    for a in last_report.alerts
                    if a.alert_type == "dynamic_anomaly"
                ]

            if last_report.trend_breaches > 0:
                context["trend_breaches"] = [
                    {
                        "metric": a.metric_name,
                        "description": a.description,
                        "breach_in_hours": a.predicted_breach_hours,
                    }
                    for a in last_report.alerts
                    if a.alert_type == "trend_breach"
                ]

        # 2. 讀取最近新 log pattern（最多 5 個）
        detector = get_log_anomaly_detector()
        recent_patterns = await detector.get_recent_new_patterns(limit=5)
        if recent_patterns:
            context["recent_log_patterns"] = [
                {
                    "template": p.get("template", "")[:200],
                    "cluster_id": p.get("cluster_id", ""),
                    "source": p.get("source", ""),
                }
                for p in recent_patterns
            ]

        if context:
            snapshot.anomaly_context = context
            logger.debug(
                "phase4_anomaly_context_collected",
                has_baseline=bool(context.get("baseline_anomalies")),
                has_trends=bool(context.get("trend_breaches")),
                log_patterns=len(context.get("recent_log_patterns", [])),
            )

    async def _collect_all(
        self,
        snapshot: EvidenceSnapshot,
        tools: list[RegisteredTool],
        incident: "Incident",
    ) -> None:
        """並行呼叫所有工具，結果填入 snapshot。"""
        params = _build_tool_params(incident)

        tasks = [
            self._collect_one(snapshot, reg, params)
            for reg in tools
        ]
        await asyncio.gather(*tasks, return_exceptions=True)

    async def _collect_one(
        self,
        snapshot: EvidenceSnapshot,
        reg: RegisteredTool,
        params: dict[str, Any],
    ) -> None:
        """執行單一 MCP 工具呼叫，結果填入對應感官維度。"""
        tool_name = reg.tool.name
        snapshot.mcp_health[tool_name] = False  # 預設失敗，成功後覆蓋

        _started = asyncio.get_event_loop().time()
        _mcp_status = "failed"
        _mcp_error = None
        try:
            audited_params = with_mcp_audit_context(
                params,
                session_id=f"incident:{snapshot.incident_id}:pre_decision",
                incident_id=snapshot.incident_id,
                flywheel_node="sense",
                agent_role="pre_decision_investigator",
            )
            result = await asyncio.wait_for(
                self._execute_tool(reg, tool_name, audited_params, snapshot.incident_id),
                timeout=MCP_TOOL_TIMEOUT_SEC,
            )

            if not result.success:
                logger.warning(
                    "investigator_tool_failed",
                    tool=tool_name,
                    error=result.error,
                )
                _mcp_error = str(result.error)[:200] if result.error else "unknown"
                return

            snapshot.mcp_health[tool_name] = True
            snapshot.sensors_succeeded += 1
            _mcp_status = "success"

            # 依感官維度填入對應欄位
            raw = result.output
            _fill_snapshot_dimension(snapshot, reg, raw)

        except asyncio.TimeoutError:
            logger.warning("investigator_tool_timeout", tool=tool_name, timeout=MCP_TOOL_TIMEOUT_SEC)
            _mcp_status = "timeout"
            _mcp_error = f"timeout {MCP_TOOL_TIMEOUT_SEC}s"
        except Exception as _e:
            logger.exception("investigator_tool_error", tool=tool_name)
            _mcp_status = "error"
            _mcp_error = str(_e)[:200]
        finally:
            # 2026-04-18 ADR-090-D: MCP 呼叫入 timeline_events(MASTER §7.1 #4 KPI)
            try:
                _duration_ms = int((asyncio.get_event_loop().time() - _started) * 1000)
                asyncio.create_task(_log_mcp_call_to_timeline(
                    snapshot_incident_id=getattr(snapshot, "incident_id", None),
                    provider_name=reg.provider.name,
                    tool_name=tool_name,
                    status=_mcp_status,
                    error=_mcp_error,
                    duration_ms=_duration_ms,
                ))
            except Exception:
                pass

    async def _execute_tool(
        self,
        reg: RegisteredTool,
        tool_name: str,
        audited_params: dict[str, Any],
        incident_id: str,
    ):
        """Route production audited providers through AwoooP MCP Gateway.

        Tests and manual injections can still pass a raw provider; production
        registry entries are `AuditedMCPToolProvider` and must now leave a
        first-class gateway audit row instead of only a legacy bridge row.
        """
        if not isinstance(reg.provider, AuditedMCPToolProvider):
            return await reg.provider.execute(tool_name, audited_params)

        async with get_db_context("awoooi") as db:
            ctx = GatewayContext(
                project_id="awoooi",
                agent_id="pre_decision_investigator",
                tool_name=tool_name,
                trace_id=incident_id,
                is_shadow=True,
                environment={"env": "prod"},
                required_scope="read",
            )
            return await McpGateway(db).call(ctx, audited_params)


async def _log_mcp_call_to_timeline(
    snapshot_incident_id: str | None,
    provider_name: str,
    tool_name: str,
    status: str,
    error: str | None,
    duration_ms: int,
) -> None:
    """
    2026-04-18 ADR-090-D: MCP 呼叫寫入 timeline_events,支援 MASTER §7.1 #4
    "MCP 呼叫次數/24h > 0" KPI 量測。
    """
    try:
        from sqlalchemy import text as _sql
        from src.db.base import get_db_context
        import json as _json
        _description = _json.dumps({
            "provider": provider_name,
            "tool": tool_name,
            "status": status,
            "error": error,
            "duration_ms": duration_ms,
        }, ensure_ascii=False)
        async with get_db_context() as _db:
            await _db.execute(
                _sql("""
                    INSERT INTO timeline_events (
                        incident_id, event_type, status, title, description, actor,
                        actor_role, created_at
                    ) VALUES (
                        :iid, 'mcp_call', :st, :tl, :desc, :actor,
                        'mcp', NOW()
                    )
                """),
                {
                    "iid": snapshot_incident_id or "unknown",
                    "st": status,
                    "tl": f"MCP {provider_name}.{tool_name}"[:100],
                    "desc": _description[:500],
                    "actor": provider_name[:50],
                },
            )
    except Exception:
        # 靜默失敗,timeline_events 是稽核,不能反噬 MCP 主流程
        pass


# ─────────────────────────────────────────────────────────────────────────────
# Snapshot dimension mapping
# ─────────────────────────────────────────────────────────────────────────────

def _fill_snapshot_dimension(
    snapshot: EvidenceSnapshot,
    reg: RegisteredTool,
    raw: Any,
) -> None:
    """將工具輸出填入 EvidenceSnapshot 對應感官欄位。"""
    if raw is None:
        return

    for dim in reg.dimensions:
        if dim == SensorDimension.D1_K8S_STATE:
            if isinstance(raw, dict):
                snapshot.k8s_state = sanitize_dict_values(raw, "k8s_state")
            else:
                snapshot.k8s_state = {"raw": sanitize(str(raw), "k8s_state")}

        elif dim == SensorDimension.D2_LOGS:
            text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
            snapshot.recent_logs = sanitize(text, "recent_logs")

        elif dim == SensorDimension.D3_METRICS:
            if isinstance(raw, dict):
                snapshot.metrics_snapshot = sanitize_dict_values(raw, "metrics")
            else:
                snapshot.metrics_snapshot = {"raw": str(raw)}

        elif dim == SensorDimension.D4_CHANGES:
            # Gate 1 fix: 過 sanitize_dict_values，ArgoCD diff / Git commit message 可含注入
            if isinstance(raw, list):
                snapshot.recent_deployments = [
                    sanitize_dict_values(item, "d4_changes") if isinstance(item, dict)
                    else {"raw": sanitize(str(item), "d4_changes")}
                    for item in raw
                ]
            elif isinstance(raw, dict):
                snapshot.recent_deployments = [sanitize_dict_values(raw, "d4_changes")]

        elif dim == SensorDimension.D5_BUSINESS:
            # Gate 1 fix: 業務指標可能含 Grafana annotation 等外部字串
            if isinstance(raw, dict):
                snapshot.business_metrics = sanitize_dict_values(raw, "d5_business")

        elif dim == SensorDimension.D6_HISTORY:
            text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
            snapshot.historical_context = sanitize(text, "historical_context")[:2000]

        elif dim == SensorDimension.D7_PEERS:
            # Gate 1 fix: Pod annotation / label 可含注入
            if isinstance(raw, dict):
                snapshot.peer_health = sanitize_dict_values(raw, "d7_peers")

        elif dim == SensorDimension.D8_TOPOLOGY:
            # Gate 1 fix: Istio / service mesh metadata 可含外部字串
            if isinstance(raw, dict):
                snapshot.dependency_topology = sanitize_dict_values(raw, "d8_topology")


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────

def _get_alertname(incident: "Incident") -> str:
    if incident.signals:
        sig = incident.signals[0]
        # alert_name 在 Signal 頂層欄位，labels["alertname"] 是 Prometheus 慣例但可能為空
        return (
            getattr(sig, "alert_name", "")
            or sig.labels.get("alertname", "")
            or getattr(incident, "alertname", "")
            or ""
        )
    return getattr(incident, "alertname", "") or ""


def _get_labels(incident: "Incident") -> dict[str, Any]:
    if incident.signals:
        sig = incident.signals[0]
        labels = sig.labels or {}
        # 若 labels 缺少 alertname，補上頂層的 alert_name
        if "alertname" not in labels and getattr(sig, "alert_name", ""):
            labels = dict(labels)
            labels["alertname"] = sig.alert_name
        return labels
    return {}


_SHORT_HOST_MAP: dict[str, str] = {
    "110": "192.168.0.110",
    "120": "192.168.0.120",
    "121": "192.168.0.121",
    "188": "192.168.0.188",
    "ollama": "192.168.0.188",
    "ai-web": "192.168.0.188",
    "wooo": "192.168.0.110",
    "harbor": "192.168.0.110",
    "gitea": "192.168.0.110",
}
"""
Prometheus instance label 使用短主機名（如 "110:9100"），
SSH_MCP_ALLOWED_HOSTS 使用完整 IP（如 "192.168.0.110"）。
此映射表做轉換，避免 SSH 工具 "Host 'X' not in SSH_MCP_ALLOWED_HOSTS" 失敗。
2026-04-16 ogt + Claude Sonnet 4.6: 修復 sensors 7/8 失敗根因
"""


def _build_prometheus_query(alertname: str, namespace: str, pod_name: str) -> str:
    """依告警類型生成 Prometheus PromQL 查詢（供 prometheus_query tool 使用）。
    2026-04-24 ogt + Claude Sonnet 4.6: P0.4 fix — _build_tool_params 補 query 欄位"""
    an = alertname.lower()
    # CPU / 負載
    if any(k in an for k in ("cpu", "load", "throttl")):
        filter_pod = f',pod=~"{pod_name}.*"' if pod_name else ""
        return f'avg(rate(container_cpu_usage_seconds_total{{namespace="{namespace}"{filter_pod}}}[5m]))'
    # 記憶體
    elif any(k in an for k in ("memory", "mem", "oom")):
        filter_pod = f',pod=~"{pod_name}.*"' if pod_name else ""
        return f'avg(container_memory_working_set_bytes{{namespace="{namespace}"{filter_pod}}}) / 1048576'
    # CrashLoop / 重啟
    elif any(k in an for k in ("crash", "restart", "oom", "backoff")):
        return f'sum(increase(kube_pod_container_status_restarts_total{{namespace="{namespace}"}}[15m]))'
    # 磁碟 / 儲存
    elif any(k in an for k in ("disk", "storage", "pvc", "volume", "capacity")):
        return 'sum(kubelet_volume_stats_used_bytes) by (persistentvolumeclaim)'
    # HTTP / 可用性
    elif any(k in an for k in ("http", "error", "5xx", "probe", "down", "unhealthy")):
        return '1 - avg(probe_success)'
    # Pod / Container 狀態
    elif any(k in an for k in ("pod", "container", "deploy", "replicaset")):
        return f'kube_pod_status_phase{{namespace="{namespace}"}}'
    # 通用 fallback
    else:
        return f'up{{namespace="{namespace}"}}'


def _build_tool_params(incident: "Incident") -> dict[str, Any]:
    """從 Incident 提取 MCP 工具呼叫所需的公共參數。"""
    labels = _get_labels(incident)
    affected_services = getattr(incident, "affected_services", []) or []
    target = (
        labels.get("target")
        or labels.get("node")
        or (affected_services[0] if affected_services else "")
    )
    raw_host = (
        labels.get("instance", "").split(":")[0]
        or labels.get("host", "")
        or labels.get("sensor_ip", "")
        or (target if str(labels.get("alertname", "")).lower().startswith("host") else "")
    )
    host_key = str(raw_host).split(":")[0].strip().lower()
    host = _SHORT_HOST_MAP.get(host_key, raw_host)  # 短名/主機別名 → 完整 IP
    namespace = labels.get("namespace", "awoooi-prod")
    pod_name = labels.get("pod", labels.get("name", ""))
    alertname = labels.get("alertname", "")
    service_name = (
        labels.get("service")
        or labels.get("app")
        or labels.get("container")
        or target
        or pod_name
    )
    container_name = (
        labels.get("container_name")
        or labels.get("container")
        or labels.get("name")
        or service_name
    )
    log_severity = labels.get("log_severity") or labels.get("severity", "")
    if not log_severity and any(k in alertname.lower() for k in ("error", "fatal", "critical")):
        log_severity = "ERROR,FATAL,CRITICAL"

    return {
        "namespace": namespace,
        "pod_name": pod_name,
        "deployment": labels.get("deployment", ""),
        "host": host,
        "container": labels.get("container", labels.get("name", "")),
        "container_name": container_name,
        "filter_name": labels.get("filter_name") or container_name,
        "target": target,
        "service": labels.get("unit") or labels.get("service") or service_name,
        "service_name": service_name,
        "alertname": alertname,
        "search_text": alertname,
        "severity": log_severity,
        "time_window_minutes": 30,
        "limit": 100,
        # P0.4 fix 2026-04-24 ogt + Claude Sonnet 4.6: Prometheus tool 需要 query 欄位
        # 原本缺少此欄位 → prometheus_query/range tool 傳入空 query → 回傳 error dict
        "query": _build_prometheus_query(alertname, namespace, pod_name),
    }


def _compute_fingerprint(incident: "Incident") -> str:
    """計算 cache key 用的 fingerprint。"""
    labels = _get_labels(incident)
    key = ":".join([
        labels.get("alertname", ""),
        labels.get("namespace", ""),
        labels.get("pod", labels.get("name", "")),
        labels.get("severity", ""),
    ])
    return hashlib.sha256(key.encode()).hexdigest()[:16]


async def _get_cache(fingerprint: str) -> EvidenceSnapshot | None:
    """從 Redis 取快取的 EvidenceSnapshot（若存在）。"""
    try:
        from src.core.redis_client import get_redis
        redis = get_redis()
        key = f"evidence:{fingerprint}"
        raw = await redis.get(key)
        if raw is None:
            return None

        data = json.loads(raw)
        snap = EvidenceSnapshot(
            incident_id=data.get("incident_id", ""),
            snapshot_id=data.get("snapshot_id", ""),
        )
        snap.evidence_summary = data.get("evidence_summary", "")
        snap.k8s_state = data.get("k8s_state")
        snap.recent_logs = data.get("recent_logs")
        snap.metrics_snapshot = data.get("metrics_snapshot")
        snap.mcp_health = data.get("mcp_health", {})
        snap.sensors_attempted = data.get("sensors_attempted", 0)
        snap.sensors_succeeded = data.get("sensors_succeeded", 0)
        return snap
    except Exception:
        return None


async def _set_cache(fingerprint: str, snapshot: EvidenceSnapshot) -> None:
    """將 EvidenceSnapshot 寫入 Redis cache。"""
    try:
        from src.core.redis_client import get_redis
        redis = get_redis()
        key = f"evidence:{fingerprint}"
        payload = {
            "incident_id": snapshot.incident_id,
            "snapshot_id": snapshot.snapshot_id,
            "evidence_summary": snapshot.evidence_summary,
            "k8s_state": snapshot.k8s_state,
            "recent_logs": snapshot.recent_logs,
            "metrics_snapshot": snapshot.metrics_snapshot,
            "mcp_health": snapshot.mcp_health,
            "sensors_attempted": snapshot.sensors_attempted,
            "sensors_succeeded": snapshot.sensors_succeeded,
        }
        await redis.set(key, json.dumps(payload, ensure_ascii=False), ex=CACHE_TTL_SEC)
    except Exception:
        pass  # cache 失敗不影響主路徑


# ─────────────────────────────────────────────────────────────────────────────
# Singleton
# ─────────────────────────────────────────────────────────────────────────────

_investigator: PreDecisionInvestigator | None = None


def get_pre_decision_investigator() -> PreDecisionInvestigator:
    """取得 PreDecisionInvestigator Singleton。"""
    global _investigator
    if _investigator is None:
        _investigator = PreDecisionInvestigator()
    return _investigator
