#!/usr/bin/env python3
"""
告警调度器 - 阈值判断 + 去重 + 飞书通知

功能:
  1. 从 runner_status 和 runner_metrics 获取数据
  2. 根据阈值判断是否触发告警
  3. 告警去重（同一问题 30 分钟内只报一次）
  4. 飞书卡片通知（复用 chatops FeishuNotifier）
  5. 生成状态快照 JSON（供看板用）

告警规则:
  P1（严重）:
    - Runner 离线超过 5 分钟
    - 磁盘使用率 > 90%

  P2（警告）:
    - 磁盘使用率 > 85%
    - 内存使用率 > 90% 持续 5 分钟
    - CI 队列积压 > 10 个 pending 超过 10 分钟

用法:
  python3 scripts/ci/runner_monitor/alert_manager.py --check
  python3 scripts/ci/runner_monitor/alert_manager.py --daemon  # 持续运行
  python3 scripts/ci/runner_monitor/alert_manager.py --snapshot
"""

import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone

# 复用 chatops 的飞书通知
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CI_DIR = os.path.dirname(_SCRIPT_DIR)
if _CI_DIR not in sys.path:
    sys.path.insert(0, _CI_DIR)

from runner_monitor import config  # noqa: E402
from runner_monitor.runner_metrics import RunnerMetricsCollector  # noqa: E402
from runner_monitor.runner_status import RunnerStatusChecker  # noqa: E402


class Alert:
    """单条告警"""

    def __init__(self, alert_id, level, title, description, details=None, source="runner_monitor"):
        self.alert_id = alert_id  # 唯一标识，用于去重
        self.level = level  # P1 / P2 / INFO
        self.title = title
        self.description = description
        self.details = details or {}
        self.source = source
        self.timestamp = datetime.now(timezone.utc).isoformat()

    def to_dict(self):
        return {
            "alert_id": self.alert_id,
            "level": self.level,
            "title": self.title,
            "description": self.description,
            "details": self.details,
            "source": self.source,
            "timestamp": self.timestamp,
        }


class AlertManager:
    """告警调度器"""

    def __init__(
        self,
        status_checker=None,
        metrics_collector=None,
        dedupe_window=None,
    ):
        self.status_checker = status_checker or RunnerStatusChecker()
        self.metrics = metrics_collector or RunnerMetricsCollector()
        self.dedupe_window = dedupe_window or config.DEDUPE_WINDOW

        # 告警历史: {alert_id: last_triggered_timestamp}
        self._alert_history = {}
        # 内存持续超阈值记录: {host: first_detected_timestamp}
        self._mem_high_since = {}

    # ── 告警检测 ──────────────────────────────────────

    def detect_alerts(self):
        """执行所有检测规则，返回触发的告警列表

        Returns:
            list[Alert]: 新触发的告警（已去重）
        """
        all_alerts = []

        # 1. Runner 离线检测
        all_alerts.extend(self._check_runner_offline())

        # 2. 队列积压检测
        all_alerts.extend(self._check_queue_backlog())

        # 3. 系统指标检测（SSH，可能为空）
        all_alerts.extend(self._check_system_metrics())

        # 去重过滤
        new_alerts = [a for a in all_alerts if self._should_alert(a)]

        # 更新告警历史
        for alert in new_alerts:
            self._alert_history[alert.alert_id] = time.time()

        return new_alerts

    def _check_runner_offline(self):
        """检测离线 runner"""
        offline = self.status_checker.get_offline_runners(offline_minutes=config.RUNNER_OFFLINE_MINUTES)
        alerts = []

        for runner in offline:
            name = runner.get("name", "unknown")
            runner_id = runner.get("id", "?")
            alert_id = f"runner_offline_{runner_id}"

            # Gitea API 没有心跳时间，status != online 就告警（P1）
            alerts.append(
                Alert(
                    alert_id=alert_id,
                    level=config.P1,
                    title=f"Runner 离线: {name}",
                    description=(
                        f"Runner **{name}** (ID: {runner_id}) 状态为 "
                        f"{runner.get('status', 'unknown')}，已离线\n"
                        f"标签: {', '.join(label.get('name') for label in runner.get('labels', [])[:5])}"
                    ),
                    details={
                        "runner_id": runner_id,
                        "runner_name": name,
                        "status": runner.get("status"),
                        "labels": [label.get("name") for label in runner.get("labels", [])],
                    },
                )
            )

        return alerts

    def _check_queue_backlog(self):
        """检测队列积压"""
        backlog = self.status_checker.get_queue_backlog(
            pending_threshold=config.QUEUE_PENDING_COUNT,
            duration_minutes=config.QUEUE_PENDING_MINUTES,
        )

        if not backlog["is_backlogged"]:
            return []

        count = backlog["pending_count"]
        age = backlog["oldest_pending_minutes"]
        alert_id = f"queue_backlog_{int(age // 30)}"  # 每30分钟一个新告警id

        return [
            Alert(
                alert_id=alert_id,
                level=config.P2,
                title="CI 队列积压",
                description=(
                    f"当前有 **{count}** 个 pending run，最老的已等待 **{age:.0f} 分钟**\n"
                    f"阈值: >{config.QUEUE_PENDING_COUNT}个 且 超过{config.QUEUE_PENDING_MINUTES}分钟"
                ),
                details={
                    "pending_count": count,
                    "oldest_pending_minutes": age,
                },
            )
        ]

    def _check_system_metrics(self):
        """检测系统指标（磁盘/内存/CPU）"""
        metrics_list = self.metrics.collect_all()
        if not metrics_list:
            return []

        alerts = []
        now = time.time()

        for m in metrics_list:
            host = m.get("host", "unknown")
            if m.get("status") != "ok":
                continue

            # 磁盘告警
            disk_pct = m.get("disk_percent", 0)
            if disk_pct and disk_pct >= config.DISK_CRIT_PERCENT:
                alerts.append(
                    Alert(
                        alert_id=f"disk_crit_{host}",
                        level=config.P1,
                        title=f"磁盘使用率严重过高: {host}",
                        description=(
                            f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P1阈值: {config.DISK_CRIT_PERCENT}%)\n"
                            f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
                        ),
                        details={"host": host, "disk_percent": disk_pct},
                    )
                )
            elif disk_pct and disk_pct >= config.DISK_WARN_PERCENT:
                alerts.append(
                    Alert(
                        alert_id=f"disk_warn_{host}",
                        level=config.P2,
                        title=f"磁盘使用率过高: {host}",
                        description=(
                            f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P2阈值: {config.DISK_WARN_PERCENT}%)\n"
                            f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
                        ),
                        details={"host": host, "disk_percent": disk_pct},
                    )
                )

            # 内存告警（持续 N 分钟）
            mem_pct = m.get("mem_percent", 0)
            mem_key = f"mem_high_{host}"
            if mem_pct and mem_pct >= config.MEM_WARN_PERCENT:
                if mem_key not in self._mem_high_since:
                    self._mem_high_since[mem_key] = now
                else:
                    duration_min = (now - self._mem_high_since[mem_key]) / 60
                    if duration_min >= config.MEM_DURATION_MINUTES:
                        alerts.append(
                            Alert(
                                alert_id=f"mem_warn_{host}",
                                level=config.P2,
                                title=f"内存使用率持续过高: {host}",
                                description=(
                                    f"服务器 **{host}** 内存使用率 **{mem_pct:.1f}%**，"
                                    f"已持续 **{duration_min:.0f} 分钟**\n"
                                    f"阈值: {config.MEM_WARN_PERCENT}% 持续 {config.MEM_DURATION_MINUTES} 分钟"
                                ),
                                details={"host": host, "mem_percent": mem_pct, "duration_min": duration_min},
                            )
                        )
            else:
                # 恢复了，清除记录
                self._mem_high_since.pop(mem_key, None)

        return alerts

    # ── 去重 ──────────────────────────────────────────

    def _should_alert(self, alert):
        """判断是否应该发送告警（去重 + 等级开关）"""
        # 等级开关
        if alert.level == config.P1 and not config.P1_ENABLED:
            return False
        if alert.level == config.P2 and not config.P2_ENABLED:
            return False

        # 去重窗口
        last = self._alert_history.get(alert.alert_id, 0)
        if time.time() - last < self.dedupe_window:
            return False

        return True

    # ── 通知 ──────────────────────────────────────────

    def send_alerts(self, alerts):
        """发送告警到飞书

        复用 chatops 的 FeishuNotifier，这里直接构造卡片。
        不依赖 FeishuNotifier 实例方法，因为告警卡片格式不同。
        """
        if not alerts:
            return 0

        # 延迟导入
        # 直接用 urllib 发，走同一个 webhook
        import urllib.request

        from chatops.feishu_notify import FeishuNotifier  # noqa: F401

        webhook_url = config.__dict__.get("FEISHU_WEBHOOK_URL", "")
        if not webhook_url:
            # 从 chatops config 拿
            from chatops import config as chatops_config

            webhook_url = chatops_config.FEISHU_WEBHOOK_URL

        if not webhook_url:
            print("[WARN] 未配置飞书 webhook，跳过告警通知")
            return 0

        sent = 0
        for alert in alerts:
            card = self._build_alert_card(alert)
            payload = json.dumps({"msg_type": "interactive", "card": card}).encode("utf-8")
            req = urllib.request.Request(
                webhook_url,
                data=payload,
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            try:
                with urllib.request.urlopen(req, timeout=10) as resp:
                    body = resp.read().decode()
                result = json.loads(body)
                if result.get("code", 0) == 0:
                    sent += 1
                    print(f"[INFO] 告警已发送: [{alert.level}] {alert.title}")
                else:
                    print(f"[WARN] 告警发送失败: {result.get('msg', body)}", file=sys.stderr)
            except Exception as e:
                print(f"[WARN] 告警发送异常: {e}", file=sys.stderr)

        return sent

    @staticmethod
    def _build_alert_card(alert):
        """构建飞书告警卡片"""
        color = config.LEVEL_COLOR.get(alert.level, "blue")
        emoji = config.LEVEL_EMOJI.get(alert.level, "ℹ️")

        fields = [
            {
                "is_short": True,
                "text": {"tag": "lark_md", "content": f"**等级**\n{alert.level}"},
            },
            {
                "is_short": True,
                "text": {"tag": "lark_md", "content": f"**来源**\n{alert.source}"},
            },
            {
                "is_short": False,
                "text": {"tag": "lark_md", "content": f"**详情**\n{alert.description}"},
            },
        ]

        return {
            "header": {
                "title": {"tag": "plain_text", "content": f"{emoji} Runner监控告警: {alert.title}"},
                "status": color,
            },
            "elements": [
                {"tag": "div", "fields": fields},
                {
                    "tag": "note",
                    "elements": [
                        {
                            "tag": "plain_text",
                            "content": f"告警ID: {alert.alert_id} | {alert.timestamp[:19].replace('T', ' ')}",
                        }
                    ],
                },
            ],
        }

    # ── 快照 ──────────────────────────────────────────

    def generate_snapshot(self, alerts=None):
        """生成完整的监控快照

        Returns:
            dict: 快照数据
        """
        status_result = self.status_checker.run_full_check()
        metrics = self.metrics.collect_all()

        if alerts is None:
            alerts = self.detect_alerts()

        snapshot = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "runner_summary": status_result["runner_summary"],
            "offline_runners": status_result["offline_runners"],
            "queue_backlog": status_result["queue_backlog"],
            "system_metrics": metrics,
            "active_alerts": [a.to_dict() for a in alerts],
            "alert_history_count": len(self._alert_history),
        }

        return snapshot

    def save_snapshot(self, output_dir=None):
        """保存快照到文件"""
        from runner_monitor.runner_status import RunnerStatusChecker as RSC

        snapshot = self.generate_snapshot()

        if output_dir is None:
            output_dir = config.OUTPUT_DIR

        os.makedirs(output_dir, exist_ok=True)
        ts = time.strftime("%Y%m%d_%H%M%S")
        filepath = os.path.join(output_dir, f"monitor_snapshot_{ts}.json")

        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(snapshot, f, indent=2, ensure_ascii=False)

        # 清理旧快照
        RSC._cleanup_old_snapshots(output_dir, keep=24)

        return filepath

    # ── 单次检查 ──────────────────────────────────────

    def run_once(self):
        """执行一次完整检查 + 告警 + 快照

        Returns:
            dict: {alerts_count, sent_count, snapshot_path}
        """
        alerts = self.detect_alerts()
        sent = self.send_alerts(alerts)
        snapshot_path = self.save_snapshot()

        return {
            "alerts_detected": len(alerts),
            "alerts_sent": sent,
            "snapshot_path": snapshot_path,
            "alerts": [a.to_dict() for a in alerts],
        }


# ── CLI 入口 ──────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(description="Runner 监控告警调度器")
    parser.add_argument("--check", action="store_true", help="执行一次检查")
    parser.add_argument("--snapshot", action="store_true", help="生成快照")
    parser.add_argument("--daemon", action="store_true", help="持续运行模式")
    parser.add_argument("--dry-run", action="store_true", help="只检测不发通知")
    parser.add_argument("--interval", type=int, help="检测间隔（秒），覆盖环境变量")

    args = parser.parse_args()

    if args.interval:
        config.CHECK_INTERVAL = args.interval

    manager = AlertManager()

    if args.daemon:
        print(f"[INFO] Runner 监控告警服务启动，检测间隔 {config.CHECK_INTERVAL} 秒")
        print(f"[INFO] P1告警: {'开启' if config.P1_ENABLED else '关闭'}")
        print(f"[INFO] P2告警: {'开启' if config.P2_ENABLED else '关闭'}")
        print(f"[INFO] 去重窗口: {config.DEDUPE_WINDOW} 秒")

        while True:
            try:
                result = (
                    manager.run_once()
                    if not args.dry_run
                    else {
                        "alerts_detected": len(manager.detect_alerts()),
                        "alerts_sent": 0,
                    }
                )
                now = time.strftime("%Y-%m-%d %H:%M:%S")
                print(
                    f"[{now}] 检测完成 - "
                    f"发现 {result['alerts_detected']} 个告警, "
                    f"发送 {result['alerts_sent']} 条通知"
                )
            except Exception as e:
                print(f"[ERROR] 检测异常: {e}", file=sys.stderr)

            time.sleep(config.CHECK_INTERVAL)

    elif args.snapshot:
        path = manager.save_snapshot()
        print(f"快照已保存: {path}")

    elif args.check or args.dry_run:
        if args.dry_run:
            alerts = manager.detect_alerts()
            print(f"检测到 {len(alerts)} 个告警（dry-run，不发送）:")
            for a in alerts:
                print(f"  [{a.level}] {a.title}")
                print(f"       {a.description[:100]}")
        else:
            result = manager.run_once()
            print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        parser.print_help()
        return 1

    return 0


if __name__ == "__main__":
    sys.exit(main())
