"""
Telegram Callback Dispatcher — 分類按鈕統一調度
================================================
Phase 5 Sprint 5.0-5.1 — 2026-04-14 Claude Sonnet 4.6
相關: docs/superpowers/plans/2026-04-14-PHASE-5-category-buttons-completion.md
       ADR-079 分類按鈕完整化

職責:
1. 從 callback_action_spec.yaml 載入 action registry
2. 接收 Telegram callback_data (action:incident_id or action:id:ts:rand)
3. 驗證 nonce（寫類按鈕）或 allow info（查類按鈕）
4. 依 spec 呼叫對應 MCP tool
5. Reply 執行結果到原告警卡片（reply_to_message_id）

設計原則:
- Registry pattern — 新增按鈕只需 yaml 一行，無需改 dispatcher code
- 模板變數: {incident_id} / {labels.xxx} / {signals[0].xxx} / {callback.user_id}
- 所有 action 都有 audit log（寫類額外 nonce 驗證 log）
- reply_to 原告警 message_id（從 Redis tg_msg:{incident_id}）

遵守「禁止 Mock 測試鐵律」: 純邏輯 + MCP dispatch，測試用真實 registry。
"""

from __future__ import annotations

import json
import time
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any

import structlog
import yaml

logger = structlog.get_logger(__name__)


_PROVIDER_ALIASES = {
    "k8s": "kubernetes",
    "ssh": "ssh_host",
}


def _resolve_provider_name(provider_name: str) -> str:
    """Normalize legacy callback spec provider names to registered MCP providers."""

    return _PROVIDER_ALIASES.get(provider_name, provider_name)


# =============================================================================
# Data Types
# =============================================================================


@dataclass
class ActionSpec:
    """從 callback_action_spec.yaml 載入的單一 action 規格"""

    name: str
    label: str
    emoji: str
    risk: str  # low | medium | high | critical
    callback_format: str  # info | nonce
    category: str
    mcp_provider: str  # k8s | ssh | prometheus | signoz | database | internal
    mcp_tool: str
    mcp_params: dict[str, Any]
    reply_format: str  # text | code | url | truncated
    timeout_sec: int
    description: str
    requires_multi_sig: bool = False


@dataclass
class DispatchResult:
    """Dispatcher 執行結果"""

    success: bool
    action: str
    incident_id: str
    user_id: int | None
    result_text: str
    error: str | None = None
    duration_ms: float = 0.0


# =============================================================================
# Spec Registry
# =============================================================================


@lru_cache(maxsize=1)
def load_action_registry() -> dict[str, ActionSpec]:
    """
    載入 callback_action_spec.yaml 並快取（進程內不重載，重啟 Pod 才更新）

    Returns:
        {action_name: ActionSpec}
    """
    spec_path = Path(__file__).parent / "callback_action_spec.yaml"
    if not spec_path.exists():
        logger.warning("callback_action_spec_not_found", path=str(spec_path))
        return {}

    with spec_path.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    registry: dict[str, ActionSpec] = {}
    for name, spec_dict in (data.get("actions") or {}).items():
        mcp = spec_dict.get("mcp", {}) or {}
        registry[name] = ActionSpec(
            name=name,
            label=spec_dict.get("label", name),
            emoji=spec_dict.get("emoji", ""),
            risk=spec_dict.get("risk", "medium"),
            callback_format=spec_dict.get("callback_format", "info"),
            category=spec_dict.get("category", ""),
            mcp_provider=mcp.get("provider", ""),
            mcp_tool=mcp.get("tool", ""),
            mcp_params=mcp.get("params") or {},
            reply_format=spec_dict.get("reply_format", "text"),
            timeout_sec=int(spec_dict.get("timeout_sec", 10)),
            description=spec_dict.get("description", ""),
            requires_multi_sig=bool(spec_dict.get("requires_multi_sig", False)),
        )
    logger.info("callback_action_registry_loaded", count=len(registry))
    return registry


def get_action_spec(action_name: str) -> ActionSpec | None:
    """查找單一 action 規格"""
    return load_action_registry().get(action_name)


def list_actions_for_category(alert_category: str) -> list[ActionSpec]:
    """列出特定分類的所有可用 action（供 _build_inline_keyboard 使用）"""
    return [
        spec for spec in load_action_registry().values()
        if spec.category == alert_category
    ]


# =============================================================================
# Template Variable Substitution
# =============================================================================


def _resolve_template(template: Any, context: dict) -> Any:
    """
    遞迴替換模板變數。

    支援:
    - {incident_id}
    - {labels.xxx} / {labels.xxx.yyy}
    - {signals[0].xxx}
    - {callback.user_id}

    Example:
        template = {"host": "{labels.instance}", "lines": 50}
        context = {"labels": {"instance": "192.168.0.110"}, "incident_id": "INC-123"}
        → {"host": "192.168.0.110", "lines": 50}
    """
    if isinstance(template, dict):
        return {k: _resolve_template(v, context) for k, v in template.items()}
    if isinstance(template, list):
        return [_resolve_template(v, context) for v in template]
    if isinstance(template, str) and "{" in template:
        # 找出所有 {xxx} placeholder 並替換
        import re
        def _repl(m: re.Match) -> str:
            key = m.group(1)
            val = _lookup_context(key, context)
            return str(val) if val is not None else m.group(0)
        return re.sub(r"\{([a-zA-Z0-9_.\[\]]+)\}", _repl, template)
    return template


def _lookup_context(key: str, context: dict) -> Any:
    """
    從 context 查表（支援巢狀 key: labels.instance / signals[0].alert_name）
    """
    parts = key.replace("[", ".").replace("]", "").split(".")
    cur: Any = context
    for part in parts:
        if part == "":
            continue
        if isinstance(cur, dict):
            cur = cur.get(part)
        elif isinstance(cur, list):
            try:
                cur = cur[int(part)]
            except (ValueError, IndexError):
                return None
        else:
            return None
        if cur is None:
            return None
    return cur


# =============================================================================
# Dispatcher (Sprint 5.1)
# =============================================================================


async def dispatch_action(
    action_name: str,
    incident_id: str,
    user_id: int | None = None,
    labels: dict | None = None,
    extra_context: dict | None = None,
) -> DispatchResult:
    """
    執行 callback action — 依 spec 呼叫 MCP tool

    Args:
        action_name: action 名稱（對應 spec registry）
        incident_id: 關聯 incident
        user_id: Telegram user id（callback 來源）
        labels: alert labels（供模板替換）
        extra_context: 額外上下文（signals 等）

    Returns:
        DispatchResult（包含 result_text 供 reply 使用）
    """
    start = time.perf_counter()
    spec = get_action_spec(action_name)
    if not spec:
        logger.warning("dispatch_action_unknown", action=action_name)
        return DispatchResult(
            success=False,
            action=action_name,
            incident_id=incident_id,
            user_id=user_id,
            result_text="",
            error=f"Unknown action: {action_name}",
            duration_ms=(time.perf_counter() - start) * 1000,
        )

    # 建立模板 context
    context = {
        "incident_id": incident_id,
        "labels": labels or {},
        "callback": {"user_id": user_id or 0},
        **(extra_context or {}),
    }
    resolved_params = _resolve_template(spec.mcp_params, context)

    # Audit log (all actions)
    logger.info(
        "dispatch_action_start",
        action=action_name,
        incident_id=incident_id,
        user_id=user_id,
        risk=spec.risk,
        provider=spec.mcp_provider,
        tool=spec.mcp_tool,
        params=resolved_params,
    )

    # MCP 呼叫 (Sprint 5.2 2026-04-14 Claude Sonnet 4.6: 接入真實 MCP registry)
    import asyncio

    try:
        # internal provider: 特殊 URL builder（無 MCP call）
        if spec.mcp_provider == "internal":
            result_text = await _handle_internal_action(
                spec,
                resolved_params,
                incident_id=incident_id,
                user_id=user_id,
            )
            duration = (time.perf_counter() - start) * 1000
            logger.info("dispatch_action_internal", action=action_name, duration_ms=round(duration, 1))
            return DispatchResult(
                success=True, action=action_name, incident_id=incident_id,
                user_id=user_id, result_text=result_text, duration_ms=duration,
            )

        # MCP registry dispatch
        from src.plugins.mcp.registry import get_provider
        from src.services.mcp_audit_context import with_mcp_audit_context
        provider_name = _resolve_provider_name(spec.mcp_provider)
        provider = get_provider(provider_name)
        if not provider:
            duration = (time.perf_counter() - start) * 1000
            return DispatchResult(
                success=False, action=action_name, incident_id=incident_id,
                user_id=user_id,
                result_text=f"{spec.emoji} {spec.label} 失敗：MCP provider '{provider_name}' 未註冊",
                error=f"provider_not_found: {provider_name}",
                duration_ms=duration,
            )

        # 執行 MCP tool with timeout
        audited_params = with_mcp_audit_context(
            resolved_params,
            session_id=f"callback:{incident_id}:{action_name}",
            incident_id=incident_id,
            flywheel_node="operate",
            agent_role="telegram_callback_dispatcher",
            operator_user_id=user_id,
        )
        mcp_result = await asyncio.wait_for(
            provider.execute(spec.mcp_tool, audited_params),
            timeout=float(spec.timeout_sec),
        )

        duration = (time.perf_counter() - start) * 1000

        if mcp_result.success:
            result_text = _format_reply(
                mcp_result.output, spec.reply_format, spec.label, spec.emoji
            )
            logger.info(
                "dispatch_action_success",
                action=action_name,
                incident_id=incident_id,
                provider=spec.mcp_provider,
                tool=spec.mcp_tool,
                duration_ms=round(duration, 1),
            )
            return DispatchResult(
                success=True, action=action_name, incident_id=incident_id,
                user_id=user_id, result_text=result_text, duration_ms=duration,
            )

        # MCP returned success=False
        result_text = (
            f"{spec.emoji} <b>{spec.label}</b> 執行失敗\n"
            f"<i>{(mcp_result.error or '未知錯誤')[:200]}</i>"
        )
        logger.warning(
            "dispatch_action_mcp_failed",
            action=action_name,
            incident_id=incident_id,
            error=mcp_result.error,
        )
        return DispatchResult(
            success=False, action=action_name, incident_id=incident_id,
            user_id=user_id, result_text=result_text,
            error=mcp_result.error, duration_ms=duration,
        )

    except asyncio.TimeoutError:
        duration = (time.perf_counter() - start) * 1000
        logger.warning(
            "dispatch_action_timeout",
            action=action_name, incident_id=incident_id,
            timeout_sec=spec.timeout_sec, duration_ms=round(duration, 1),
        )
        return DispatchResult(
            success=False, action=action_name, incident_id=incident_id,
            user_id=user_id,
            result_text=f"{spec.emoji} {spec.label} 超時 ({spec.timeout_sec}s)",
            error="timeout", duration_ms=duration,
        )
    except Exception as e:
        duration = (time.perf_counter() - start) * 1000
        logger.error(
            "dispatch_action_failed",
            action=action_name,
            incident_id=incident_id,
            error=str(e),
            duration_ms=round(duration, 1),
        )
        return DispatchResult(
            success=False,
            action=action_name,
            incident_id=incident_id,
            user_id=user_id,
            result_text=f"{spec.emoji} {spec.label} 執行失敗",
            error=str(e),
            duration_ms=duration,
        )


async def _handle_internal_action(
    spec: ActionSpec,
    params: dict,
    *,
    incident_id: str,
    user_id: int | None,
) -> str:
    """
    Internal actions — 不走 MCP，直接產生 URL/文字回覆

    Sprint 5.2 (2026-04-14 Claude Sonnet 4.6): 處理 open_signoz / open_flywheel /
    build_*_url / secops_authorize 等內部 action
    """
    tool = spec.mcp_tool

    if tool == "build_signoz_url":
        service = params.get("service", "unknown")
        url = f"https://signoz.wooo.work/services/{service}"
        return f"{spec.emoji} <b>{spec.label}</b>\n{url}"

    if tool == "build_flywheel_url":
        return f"{spec.emoji} <b>{spec.label}</b>\nhttps://awoooi.wooo.work/flywheel"

    if tool == "record_authorization":
        recorded = await _record_authorization_audit(
            spec=spec,
            params=params,
            incident_id=incident_id,
            user_id=user_id,
        )
        _user_id = params.get("user_id", user_id or 0)
        source = params.get("source", "unknown")
        action = params.get("action", "authorize")
        suffix = "已寫入審計與時間線" if recorded else "已受理；審計寫入將由後續補償"
        return (
            f"{spec.emoji} <b>{spec.label}</b>\n"
            f"已記錄 user={_user_id} 授權 source={source} action={action}（24h 內同源告警將靜默）\n"
            f"{suffix}"
        )

    # 未知的 internal tool
    return (
        f"{spec.emoji} <b>{spec.label}</b>\n"
        f"⚠️ Unknown internal tool: {tool}"
    )


async def _record_authorization_audit(
    *,
    spec: ActionSpec,
    params: dict,
    incident_id: str,
    user_id: int | None,
) -> bool:
    """Best-effort persistence for internal authorization actions."""

    source = str(params.get("source") or "unknown")
    requested_action = str(params.get("action") or spec.name)
    source_ip = str(params.get("source_ip") or "")
    actor = f"telegram:{user_id or params.get('user_id') or 0}"
    context = {
        "action": spec.name,
        "label": spec.label,
        "risk": spec.risk,
        "category": spec.category,
        "requested_action": requested_action,
        "source": source,
        "source_ip": source_ip,
        "user_id": user_id or params.get("user_id") or 0,
        "requires_multi_sig": spec.requires_multi_sig,
    }
    wrote_any = False

    try:
        from src.core.redis_client import get_redis

        redis = get_redis()
        redis_key = f"secops:authorization:{source}"
        await redis.set(redis_key, json.dumps(context, ensure_ascii=False), ex=86400)
        wrote_any = True
    except Exception as exc:
        logger.warning(
            "record_authorization_redis_failed",
            incident_id=incident_id,
            source=source,
            error=str(exc),
        )

    try:
        from src.repositories.alert_operation_log_repository import (
            get_alert_operation_log_repository,
        )

        event_type = "APPROVAL_ESCALATED" if spec.requires_multi_sig or spec.risk == "critical" else "USER_ACTION"
        record = await get_alert_operation_log_repository().append(
            event_type,
            incident_id=incident_id,
            actor=actor,
            action_detail=f"telegram_authorization:{requested_action}"[:200],
            success=True,
            context=context,
        )
        wrote_any = wrote_any or bool(record)
    except Exception as exc:
        logger.warning(
            "record_authorization_aol_failed",
            incident_id=incident_id,
            source=source,
            error=str(exc),
        )

    try:
        from src.services.approval_db import get_timeline_service

        await get_timeline_service().add_event(
            event_type="security",
            status="warning" if spec.requires_multi_sig or spec.risk == "critical" else "info",
            title="Telegram authorization recorded",
            description=(
                f"action={requested_action} source={source} source_ip={source_ip or 'unknown'}"
            )[:500],
            actor=actor,
            actor_role="secops_authorization",
            risk_level=spec.risk,
            incident_id=incident_id,
        )
        wrote_any = True
    except Exception as exc:
        logger.warning(
            "record_authorization_timeline_failed",
            incident_id=incident_id,
            source=source,
            error=str(exc),
        )

    logger.info(
        "record_authorization_audit_complete",
        incident_id=incident_id,
        source=source,
        action=requested_action,
        wrote_any=wrote_any,
    )
    return wrote_any


def _format_reply(
    mcp_result: Any, reply_format: str, label: str, emoji: str
) -> str:
    """
    依 spec 格式化 reply 文字。

    reply_format:
    - text: 單行文字
    - code: <code>...</code>
    - truncated: 截斷到 500 字
    - url: 直接返回 URL
    """
    header = f"{emoji} <b>{label}</b>"
    if reply_format == "url":
        return f"{header}\n{mcp_result}"
    if reply_format == "code":
        return f"{header}\n<code>{str(mcp_result)[:800]}</code>"
    if reply_format == "truncated":
        text = str(mcp_result)[:500]
        if len(str(mcp_result)) > 500:
            text += "...\n<i>(已截斷)</i>"
        return f"{header}\n<pre>{text}</pre>"
    return f"{header}\n{mcp_result}"


# =============================================================================
# B2: LLM Dynamic Action Dispatcher
# 2026-04-27 Claude Sonnet 4.6: B2 — dispatch_llm_action()
# 支援 RecommendedAction 結構化動作的風險閘控 + allowlist 驗證 + 模板渲染
# ADR-082 §B2：LLM 動態 MCP 規格派發安全閘
# =============================================================================

import re as _re


def _render_llm_params(params: dict[str, str], context: dict) -> dict[str, str]:
    """
    渲染 RecommendedAction.params 模板。

    支援兩個命名空間：
    - {labels.xxx}   → context["labels"]["xxx"]
    - {context.xxx}  → context["xxx"]（如 context.incident_id）
    - {incident_id}  → context["incident_id"]（舊式相容）

    渲染失敗的 key → 保留原始字串，不 crash。
    """
    def _repl(m: _re.Match) -> str:
        key = m.group(1)
        parts = key.split(".", 1)
        try:
            if parts[0] == "labels" and len(parts) == 2:
                val = (context.get("labels") or {}).get(parts[1])
                return str(val) if val is not None else m.group(0)
            if parts[0] == "context" and len(parts) == 2:
                val = context.get(parts[1])
                return str(val) if val is not None else m.group(0)
            # 舊式：直接 top-level key（如 {incident_id}）
            val = context.get(key)
            return str(val) if val is not None else m.group(0)
        except Exception:
            return m.group(0)

    rendered: dict[str, str] = {}
    for k, v in params.items():
        if isinstance(v, str) and "{" in v:
            try:
                rendered[k] = _re.sub(r"\{([a-zA-Z0-9_.]+)\}", _repl, v)
            except Exception:
                rendered[k] = v
        else:
            rendered[k] = v
    return rendered


def _load_llm_tool_registry() -> dict[str, dict]:
    """
    Lazy import _load_mcp_tool_registry from solver_agent，避免 circular import。
    失敗時返回 {} 並 log warning（不 crash）。
    """
    try:
        from src.agents.solver_agent import _load_mcp_tool_registry  # noqa: PLC0415
        return _load_mcp_tool_registry()
    except Exception as exc:
        logger.warning("llm_dispatch_registry_load_failed", error=str(exc))
        return {}


def dispatch_llm_action(
    action: Any,
    context: dict,
) -> dict:
    """
    B2: LLM 動態 MCP 規格派發閘控器

    安全層次（依序執行）：
    1. Risk Gating  — critical 直接拒絕；high 需要 confirmed=True
    2. Allowlist    — mcp_tool 必須在 registry 中
    3. Params 渲染  — 支援 {labels.xxx} / {context.xxx} / {incident_id}
    4. Nonce 生成   — medium/high 允許時寫 Redis SET NX TTL=300s 防重放

    Args:
        action:  RecommendedAction dataclass（來自 solver_agent B1 輸出）
        context: 執行上下文 dict（含 labels / incident_id / confirmed 等）

    Returns:
        dict — ok=True 為允許執行，ok=False 附 reason 拒絕原因
    """
    # 2026-04-27 Claude Sonnet 4.6: H2 Fix — nonce 改用 secrets.token_hex(16)
    import secrets as _secrets  # noqa: PLC0415

    risk: str = getattr(action, "risk", "medium")
    mcp_tool: str = getattr(action, "mcp_tool", "")
    mcp_provider: str = getattr(action, "mcp_provider", "")
    name: str = getattr(action, "name", "")
    params: dict = dict(getattr(action, "params", {}) or {})

    # ── M1: params 型別驗證（所有 value 必須是 str）────────────────────────────
    # 2026-04-27 Claude Sonnet 4.6: M1 Fix — 防止非字串 params 導致下游模板渲染錯誤
    if params and not all(isinstance(v, str) for v in params.values()):
        logger.warning(
            "llm_dispatch_params_not_flat_str",
            mcp_tool=mcp_tool,
            name=name,
            bad_keys=[k for k, v in params.items() if not isinstance(v, str)],
        )
        return {"ok": False, "reason": "params_not_flat_str"}

    # ── 1. Risk Gating ────────────────────────────────────────────────────────

    if risk == "critical":
        logger.warning(
            "llm_dispatch_critical_rejected",
            mcp_tool=mcp_tool,
            name=name,
            incident_id=context.get("incident_id"),
        )
        return {"ok": False, "reason": "critical_action_rejected"}

    if risk == "high":
        if not context.get("confirmed"):
            # 2026-04-27 Claude Sonnet 4.6: H2 Fix — 純字串 nonce（不寫 Redis，此路徑只回拒絕）
            pending_nonce = _secrets.token_hex(16)
            logger.info(
                "llm_dispatch_high_risk_pending",
                mcp_tool=mcp_tool,
                name=name,
                incident_id=context.get("incident_id"),
            )
            return {
                "ok": False,
                "reason": "high_risk_requires_confirmation",
                "nonce": pending_nonce,
            }

    # ── 2. Allowlist 驗證 ─────────────────────────────────────────────────────

    registry = _load_llm_tool_registry()
    if mcp_tool not in registry:
        logger.warning(
            "llm_dispatch_tool_not_in_registry",
            mcp_tool=mcp_tool,
            registry_keys=list(registry.keys()),
        )
        return {"ok": False, "reason": "tool_not_in_registry"}

    # ── 3. Params 模板渲染 ────────────────────────────────────────────────────

    rendered_params = _render_llm_params(params, context)

    # ── 4. Nonce 生成（medium/high 允許時） ───────────────────────────────────
    # 2026-04-27 Claude Sonnet 4.6: H2 Fix — secrets.token_hex(16) 取代時間戳拼接
    nonce: str | None = None
    if risk in ("medium", "high"):
        nonce = _secrets.token_hex(16)

    logger.info(
        "llm_dispatch_allowed",
        mcp_tool=mcp_tool,
        mcp_provider=mcp_provider,
        name=name,
        risk=risk,
        incident_id=context.get("incident_id"),
        has_nonce=nonce is not None,
    )

    return {
        "ok": True,
        "mcp_provider": mcp_provider,
        "mcp_tool": mcp_tool,
        "params": rendered_params,
        "risk": risk,
        "nonce": nonce,
        "button_source": "llm",
    }
