"""
AI SLO Watchdog Job — 系統自健診（每 15 分鐘）
=============================================
MASTER §1.1 AI 自主化方向：系統必須能感知自身故障。
ADR-092 (2026-04-20 ogt + Claude Opus 4.7 Asia/Taipei)
ADR-092 B3 (2026-04-24 ogt + Claude Sonnet 4.6 Asia/Taipei)：
  W-2 修復：改用 telegram_message_id IS NULL 判斷真正靜默，排除 tg_sent TTL 過期誤判
  W-5 新增：Agent Debate 失敗導致告警卡在分析中（description='待分析'）
ADR-092 B4 (2026-05-05 ogt + Claude Sonnet 4.6 Asia/Taipei)：
  A2 修復：新 Pod 啟動後 90s leading sleep，避免 rollout 時立即觸發告警
  A3 修復：grace period 改為 Redis cluster-shared（watchdog:cluster_grace），
          消除 replicas=2 時 Pod 間 grace period 不一致造成 violation_codes 分歧
  W6 修復：dedup key 移除動態 low_count，改為穩定 "W6:trust_drift"

檢查項目：
  W-1  AI SLO 違反（決策品質，7d 滾動）
  W-2  Telegram 靜默偵測（PENDING 告警 telegram_message_id IS NULL 超過 30 分鐘）
  W-3  飛輪 execution_success_rate 低落（< 30%）
  W-4  無 APPROVED Playbook（自動修復鏈路斷裂）
  W-5  Agent Debate 失敗（PENDING 告警 description='待分析' 超過 1 小時）
  W-6  Trust Drift 偵測（Playbook 信任度漂移）

任一異常 → send_meta_alert（TYPE-8M，flywheel_health）
去重：Redis watchdog:alert:{dedup_hash} TTL 1h，避免每 15 分鐘重複洗版
"""
from __future__ import annotations

import asyncio
import time
import uuid
from datetime import UTC, datetime, timedelta

import structlog
from sqlalchemy import and_, func, select

from src.core.redis_client import get_redis
from src.db.base import get_db_context
from src.db.models import ApprovalRecord
from src.models.approval import ApprovalStatus
from src.utils.timezone import now_taipei

logger = structlog.get_logger(__name__)

_INTERVAL_SEC = 900      # 每 15 分鐘
_DEDUP_TTL_SEC = 3600    # 同一告警 1 小時內不重複發送
_TG_SILENCE_THRESHOLD = 2    # PENDING telegram_message_id IS NULL 告警門檻
_FLYWHEEL_SUCCESS_MIN = 0.30  # 執行成功率下限
_STUCK_ANALYSIS_THRESHOLD = 3  # Agent Debate 失敗導致卡住的告警門檻
_TRUST_DRIFT_META_MIN_RATIO = 0.20  # 低於此比例只記治理事件，不升 Meta System

# 2026-05-03 ogt + Claude Opus 4.7 — feedback_silencing_alerts_recurring_violation
# 啟動寬限期：30 分鐘內可 skip「資料還沒到」噪音；超過寬限期仍空 = 真資料管線斷，必須告警
# 不可單獨用 skip 吞告警 — 一定要配對打「初始化期過、資料應該來但沒來」新告警
_INIT_GRACE_SEC = 1800
# 2026-05-05 ogt A3：_PROCESS_START 僅作 Redis 故障時的 fallback
_PROCESS_START = time.monotonic()

# 2026-05-05 ogt A2：新 Pod 啟動 leading sleep，避免 rollout 時立即觸發告警
# 90s < dedup TTL（3600s），不影響正常告警時效
_STARTUP_SLEEP_SEC = 90

# Redis key for cluster-shared grace period（A3）
_GRACE_REDIS_KEY = "watchdog:cluster_grace"


async def _is_grace_active() -> bool:
    """
    叢集級別啟動寬限期（A3 修復）。
    第一個 Pod 執行時 SET nx=True，後續 Pod SET 失敗但 key 仍存在。
    key TTL = _INIT_GRACE_SEC（30min）；到期後 grace 結束。
    Redis 故障時降級為 process-local monotonic 判斷（fail-safe）。
    2026-05-05 ogt + Claude Sonnet 4.6 — ADR-092 B4
    """
    try:
        redis = get_redis()
        await redis.set(_GRACE_REDIS_KEY, "1", nx=True, ex=_INIT_GRACE_SEC)
        return bool(await redis.exists(_GRACE_REDIS_KEY))
    except Exception:
        return (time.monotonic() - _PROCESS_START) < _INIT_GRACE_SEC


async def run_ai_slo_watchdog_loop() -> None:
    """
    永久迴圈：每 15 分鐘自健診，異常時發送 TYPE-8M Meta-System 告警。
    由 main.py lifespan 透過 asyncio.create_task() 啟動。
    A2：先 sleep 90s 再開始第一次 check，避免新 Pod 上線立即觸發告警。
    """
    logger.info(
        "ai_slo_watchdog_started",
        interval_sec=_INTERVAL_SEC,
        startup_sleep_sec=_STARTUP_SLEEP_SEC,
    )
    # A2 修復：Leading sleep — 讓服務先穩定，避免 rollout 時立即觸發
    await asyncio.sleep(_STARTUP_SLEEP_SEC)
    while True:
        try:
            await _check_once()
        except Exception as e:
            logger.warning("ai_slo_watchdog_error", error=str(e))
        await asyncio.sleep(_INTERVAL_SEC)


async def _check_once() -> None:
    # violations = 顯示用（含動態數值，送 Telegram）
    # violation_codes = dedup 用（穩定 W-code，不含動態數值）
    # 2026-05-04 ogt: 分離 dedup key 與顯示字串
    #   根因：W-2/3/5/6 字串含動態數字（count/ratio/score），每次微變 → 不同 SHA256 → dedup 失效
    #   修法：dedup 用穩定 violation_codes（W-N:type 格式），Telegram 照常顯示動態值
    violations: list[str] = []
    violation_codes: list[str] = []
    probable_causes: list[str] = []
    # A3 修復：cluster-shared grace period，單次查詢供所有 W-check 使用，避免 Pod 間不一致
    grace = await _is_grace_active()

    # W-1: AI SLO 違反（決策品質 7d 滾動）
    try:
        from src.services.ai_slo_calculator import AiSloCalculator
        report = await AiSloCalculator().calculate()
        if report.any_violated:
            violated = [m.name for m in report.metrics if m.violated]
            if _is_observation_only_slo_violation(report, violated):
                logger.info(
                    "watchdog_w1_slo_observation_only",
                    violated=violated,
                    reason="sealed_waiting_rolling_window",
                )
            else:
                w1_line, w1_cause = _format_slo_violation_for_alert(report, violated)
                violations.append(w1_line)
                if w1_cause:
                    probable_causes.append(w1_cause)
                violation_codes.append(f"W1:slo_violated:{','.join(sorted(violated))}")
    except Exception as e:
        logger.warning("watchdog_w1_slo_check_failed", error=str(e))

    # W-2: Telegram 靜默偵測（PENDING 告警 telegram_message_id IS NULL 超過 30 分鐘）
    # ADR-092 B3 (2026-04-24 ogt + Claude Sonnet 4.6 Asia/Taipei)：
    # 修復誤判：改用 telegram_message_id IS NULL 判斷，排除 tg_sent Redis TTL 過期導致的誤報。
    # 有 telegram_message_id = Telegram 已發過，tg_sent key 過期不算靜默；
    # telegram_message_id IS NULL = Telegram 從未發過，才是真正靜默。
    try:
        silent_count = await _count_pending_no_tg_sent()
        if silent_count >= _TG_SILENCE_THRESHOLD:
            violations.append(
                f"{silent_count} 個 PENDING 告警超 30 分鐘未送達 Telegram（未曾發送，非 TTL 過期）"
            )
            violation_codes.append("W2:tg_silence")
    except Exception as e:
        logger.warning("watchdog_w2_tg_silence_check_failed", error=str(e))

    # W-3a: 飛輪執行成功率過低（有樣本但低於門檻）
    # W-3b: 啟動寬限期過後仍無樣本 = 飛輪資料管線斷流（rate=None > 30min）
    # 2026-05-03 ogt + Claude Opus 4.7（亞太）— feedback_silencing_alerts_recurring_violation
    #   2026-05-02 的 W-3 修復用 `rate is None: skip` 把告警吞了，違反「禁消音化解法」鐵律。
    #   修正：分流 — 啟動 30 分鐘內 skip（避免 fresh deploy 噪音），超過寬限期仍 None
    #   改打「資料管線無流量」告警，補回故障可見性。
    try:
        from src.services.flywheel_stats_service import FlywheelStatsService
        metrics = await FlywheelStatsService().compute()
        if metrics and metrics.execution_success_rate is None:
            if grace:
                logger.debug(
                    "watchdog_w3_init_grace_skip",
                    reason="execution_sample_below_min",
                )
            else:
                violations.append(
                    "飛輪執行成功率資料管線無流量（uptime > 30min 仍無樣本）"
                )
                violation_codes.append("W3:flywheel_no_data")
        elif metrics and metrics.execution_success_rate < _FLYWHEEL_SUCCESS_MIN:
            violations.append(
                f"飛輪執行成功率 {metrics.execution_success_rate:.1%} < {_FLYWHEEL_SUCCESS_MIN:.0%}"
            )
            violation_codes.append("W3:flywheel_low_rate")
    except Exception as e:
        logger.warning("watchdog_w3_flywheel_check_failed", error=str(e))

    # W-4a: 無 APPROVED Playbook（total > 0 但 approved=0，evolver 全封存自動修復斷鏈）
    # W-4b: 啟動寬限期過後 playbooks 表仍空（migration 沒跑 / 表被清空）
    # 2026-05-03 ogt + Claude Opus 4.7（亞太）— feedback_silencing_alerts_recurring_violation
    #   2026-05-02 的 W-4 修復用 `total==0: skip` 把告警吞了，violates 同樣鐵律。
    #   修正：分流 — 啟動 30 分鐘內 skip，超過寬限期仍 0 改打「Playbook 表初始化失敗」告警。
    try:
        approved_count, total_playbook_count = await _count_approved_playbooks()
        if total_playbook_count == 0:
            if grace:
                logger.info(
                    "watchdog_w4_init_grace_skip",
                    reason="playbook_table_empty_likely_initializing",
                )
            else:
                violations.append(
                    "Playbook 表為空 — 初始化失敗或表被清空（uptime > 30min 仍 0 筆）"
                )
                violation_codes.append("W4:playbook_table_empty")
        elif approved_count == 0:
            violations.append("無 APPROVED Playbook — 自動修復鏈路斷裂（evolver 可能全部封存）")
            violation_codes.append("W4:no_approved_playbook")
    except Exception as e:
        logger.warning("watchdog_w4_playbook_check_failed", error=str(e))

    # W-5: Agent Debate 失敗（PENDING 告警 description='待分析' 超過 1 小時）
    # ADR-092 B3 (2026-04-24 ogt + Claude Sonnet 4.6 Asia/Taipei)：
    # telegram_push_suppressed_no_analysis 那類案例在 DB 留下 PENDING + description='待分析'，
    # 查 DB 比查 structlog 更可靠。門檻 > 3（1h 內若偶有 1-2 筆超時可接受）。
    try:
        stuck_count = await _count_pending_stuck_analysis()
        if stuck_count > _STUCK_ANALYSIS_THRESHOLD:
            violations.append(
                f"Agent Debate 失敗導致 {stuck_count} 個告警分析卡住（PENDING + description='待分析' 超過 1 小時）"
            )
            violation_codes.append("W5:stuck_analysis")
    except Exception as e:
        logger.warning("watchdog_w5_stuck_analysis_check_failed", error=str(e))

    # W-6: Trust Drift 偵測（Playbook 信任度漂移）
    # 2026-05-02 ogt + Claude Sonnet 4.6（亞太）: 整併雙寫路徑
    # 2026-05-05 Codex: Watchdog 仍透過 governance_agent 單一入口，
    # 但用 emit_alert=False 只取統計，避免與 hourly self-check 發出雙重 Telegram。
    try:
        from src.services.governance_agent import get_governance_agent
        trust_result = await get_governance_agent().check_trust_drift(emit_alert=False)
        drifted = trust_result.get("drifted", 0)
        drift_ratio = float(trust_result.get("drift_ratio") or 0.0)
        if drifted > 0 and drift_ratio >= _TRUST_DRIFT_META_MIN_RATIO:
            auto_deprecated = trust_result.get("auto_deprecated", 0)
            kept = trust_result.get("kept", 0)
            violations.append(
                f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落"
                f"（auto-deprecated: {auto_deprecated}，待人工審核: {kept}）"
            )
            # 2026-05-05 ogt W6 修復：移除動態 low_count，避免 count 微變繞過 dedup
            violation_codes.append("W6:trust_drift")
        elif drifted > 0:
            logger.info(
                "watchdog_w6_trust_drift_below_meta_threshold",
                drifted=drifted,
                drift_ratio=round(drift_ratio, 3),
                threshold=_TRUST_DRIFT_META_MIN_RATIO,
            )
    except Exception as e:
        logger.warning("watchdog_w6_trust_drift_check_failed", error=str(e))

    if not violations:
        logger.debug("ai_slo_watchdog_all_ok", checks=6)
        return

    # 去重：用穩定 violation_codes 計算 SHA256，避免動態數值（ratio/score）造成每次不同 hash
    # 2026-05-04 ogt: dedup 分離顯示字串與 dedup key
    #   根因：violations 字串含動態數字（count/ratio/score），每次微變 → SHA256 不同 → dedup 失效
    #   修法：violation_codes 只含 W-code + 穩定類型，不含浮點數值
    import hashlib
    _content = "|".join(sorted(violation_codes))
    dedup_hash = hashlib.sha256(_content.encode()).hexdigest()[:12]
    dedup_key = f"watchdog:alert:{dedup_hash}"
    redis = get_redis()
    # setnx atomic — 同時多個 pod 只有第一個能 set，避免並發多發
    set_ok = await redis.set(dedup_key, "1", ex=_DEDUP_TTL_SEC, nx=True)
    if not set_ok:
        logger.debug("ai_slo_watchdog_deduped", key=dedup_key)
        return

    violation_lines = [
        f"{idx + 1}. {item}" for idx, item in enumerate(violations)
    ]
    diagnosis = "AI 自健診異常"
    system_impact = "\n".join(
        [
            f"檢出 {len(violations)} 項 KPI 異常（W-1~W-6）",
            "關鍵影響：飛輪自動化能力可能降級",
            *violation_lines,
        ]
    )
    probable_cause = "\n".join(probable_causes) if probable_causes else (
        "治理異常與執行資料同時異常，建議先核對 AI SLO 指標與最近自修復任務執行紀錄"
    )

    # 發送 TYPE-8M Meta-System 告警
    # 重大異常：超過 2 項即升為 critical，便於前線分流；1-2 項走 warning
    severity = "critical" if len(violations) >= 2 else "warning"
    incident_id = f"META-{now_taipei().strftime('%Y%m%d%H%M%S')}"
    try:
        from src.services.telegram_gateway import get_telegram_gateway

        await get_telegram_gateway().send_meta_alert(
            incident_id=incident_id,
            approval_id=str(uuid.uuid4()),
            alertname="AI 自健診異常",
            alert_category="flywheel_health",
            diagnosis=diagnosis,
            severity_level=severity,
            system_impact=system_impact,
            probable_cause=probable_cause,
        )
        logger.warning(
            "ai_slo_watchdog_alert_sent",
            incident_id=incident_id,
            violation_count=len(violations),
            violations=violations,
        )
    except Exception as e:
        logger.error("ai_slo_watchdog_telegram_failed", error=str(e), violations=violations)


def _format_slo_violation_for_alert(report, violated: list[str]) -> tuple[str, str | None]:
    """把 W-1 診斷資料壓成 Telegram 可讀摘要，dedup key 仍沿用穩定 code。"""
    if "auto_execute_success_rate" not in violated:
        return f"SLO 違反: {', '.join(violated)}", None

    diagnostics = getattr(report, "diagnostics", {}) or {}
    diag = diagnostics.get("auto_execute_success_rate") or {}
    summary = diag.get("summary") or {}
    total = int(summary.get("total") or 0)
    success = int(summary.get("success") or 0)
    rate = summary.get("rate")
    threshold = summary.get("threshold")
    sealed = int(diag.get("sealed_failure_group_count") or 0)
    open_groups = int(diag.get("open_failure_group_count") or 0)
    needed = int(diag.get("immediate_successes_needed") or 0)
    projected = _short_taipei_time(diag.get("projected_green_at"))

    if isinstance(rate, (int, float)) and isinstance(threshold, (int, float)):
        line = (
            f"SLO 違反: auto_execute_success_rate "
            f"({success}/{total}={rate:.1%}，門檻 {threshold:.0%}；"
            f"已封口群組 {sealed}，待查群組 {open_groups}"
        )
        if projected:
            line += f"；預估 {projected} 回綠"
        elif needed:
            line += f"；需新增成功 {needed} 次"
        line += ")"
    else:
        line = "SLO 違反: auto_execute_success_rate（診斷資料不足）"

    groups = diag.get("top_failure_groups") or []
    group_lines = []
    for group in groups[:3]:
        label = group.get("closure_status") or "unknown"
        group_lines.append(
            f"{group.get('alertname', 'unknown')}/{group.get('playbook_id', 'unknown')}"
            f"×{group.get('count', 0)}={label}"
        )

    cause_parts = [
        f"auto_execute_success_rate 仍在 7 日滾動窗內偏低：{success}/{total}"
        if total else "auto_execute_success_rate 診斷資料不足",
    ]
    if group_lines:
        cause_parts.append("Top failure groups: " + "；".join(group_lines))
    if sealed and not open_groups:
        cause_parts.append("目前已知失敗來源已封口，狀態是等待舊失敗滾出 7 日視窗。")
    if projected:
        cause_parts.append(f"若沒有新失敗，預估 {projected} 自然回綠；不需要重啟服務或改寫歷史資料。")
    elif needed:
        cause_parts.append(f"若要立即回綠，需要新增 {needed} 次真實成功自動修復樣本。")
    if open_groups:
        cause_parts.append("仍有未封口失敗群組，請反查 truth-chain、PlayBook 與 MCP 執行紀錄。")

    return line, "\n".join(cause_parts)


def _is_observation_only_slo_violation(report, violated: list[str]) -> bool:
    """已封口且只等 rolling window 的 W-1，不再升成 Meta System 告警。"""
    if set(violated) != {"auto_execute_success_rate"}:
        return False

    diagnostics = getattr(report, "diagnostics", {}) or {}
    diag = diagnostics.get("auto_execute_success_rate") or {}
    try:
        open_groups = int(diag.get("open_failure_group_count") or 0)
    except (TypeError, ValueError):
        open_groups = 0
    return (
        diag.get("status") == "sealed_waiting_window"
        and open_groups == 0
    )


def _short_taipei_time(value: str | None) -> str | None:
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=UTC)
        taipei = parsed.astimezone(now_taipei().tzinfo)
        return taipei.strftime("%m/%d %H:%M")
    except Exception:
        return None


async def _count_pending_no_tg_sent() -> int:
    """
    查詢真正靜默的 PENDING 告警：PENDING 超過 30 分鐘且 telegram_message_id IS NULL。

    ADR-092 B3 修復（2026-04-24 ogt + Claude Sonnet 4.6 Asia/Taipei）：
    舊版只查 Redis tg_sent:{fp} key，但該 key TTL 僅 24h；
    PENDING 超過 24h 後 key 自然過期 → 誤判為靜默故障（W-2 誤報根因）。

    修復方案：改用 PG 欄位 telegram_message_id IS NULL 作為判斷依據：
      - telegram_message_id IS NOT NULL = Telegram 確實已發過（不算靜默）
      - telegram_message_id IS NULL     = Telegram 從未發過（才是真正靜默）
    tg_sent Redis key 不再用於 W-2 判斷。
    """
    cutoff = datetime.now(UTC) - timedelta(minutes=30)

    async with get_db_context() as db:
        result = await db.execute(
            select(ApprovalRecord.id)
            .where(
                and_(
                    ApprovalRecord.status == ApprovalStatus.PENDING,
                    ApprovalRecord.created_at <= cutoff,
                    ApprovalRecord.telegram_message_id.is_(None),
                )
            )
            .limit(20)
        )
        rows = result.all()

    return len(rows)


async def _count_approved_playbooks() -> tuple[int, int]:
    """查詢 APPROVED Playbook 數量 + 全表總數，兩者均回傳。

    2026-05-02 ogt + Claude Sonnet 4.6 — Bug 4 修復（全封存初始化誤報）
    加回傳 total count：若 total==0 代表表初始化中，W-4 應 skip 而非告警。
    回傳：(approved_count, total_count)
    """
    from sqlalchemy import text as sa_text
    async with get_db_context() as db:
        approved_result = await db.execute(
            sa_text("SELECT COUNT(*) FROM playbooks WHERE status = 'approved'")
        )
        approved = approved_result.scalar() or 0

        total_result = await db.execute(
            sa_text("SELECT COUNT(*) FROM playbooks")
        )
        total = total_result.scalar() or 0

    return approved, total


async def _count_pending_stuck_analysis() -> int:
    """
    查詢 Agent Debate 失敗導致卡住的 PENDING 告警數。

    ADR-092 B3（2026-04-24 ogt + Claude Sonnet 4.6 Asia/Taipei）：
    decision_manager.py 第 279-286 行：當 description='待分析' 且 action 屬於
    失敗狀態集合時，直接 return 不推送 Telegram，記錄 telegram_push_suppressed_no_analysis。
    這類告警在 DB 中留下 PENDING + description='待分析' 的特徵。

    查詢條件：
      - status = PENDING
      - description = '待分析'（Agent Debate 無有效輸出）
      - created_at <= now - 1h（給一定緩衝，排除剛建立的正常案例）
    """
    cutoff = datetime.now(UTC) - timedelta(hours=1)

    async with get_db_context() as db:
        # 用 action IS NULL/空 比 description 字串比對更可靠：
        # decision_manager 超時降級後 description 文字已改，但 action 仍為空
        result = await db.execute(
            select(func.count())
            .select_from(ApprovalRecord)
            .where(
                and_(
                    ApprovalRecord.status == ApprovalStatus.PENDING,
                    ApprovalRecord.created_at <= cutoff,
                    ApprovalRecord.action.in_(["", "待分析", "NO_ACTION"]),
                    ApprovalRecord.telegram_message_id.is_(None),
                )
            )
        )
        return result.scalar() or 0
