# -*- coding: utf-8 -*-
"""
report_service.py — 巡检报告管理

报告存储：service_monitor/data/reports/{report_id}.json
提供：保存 / 读取 / 列表 / 导出(MD/JSON) / 过期清理
"""

from __future__ import annotations

import json
import logging
import secrets
import threading
import uuid
from datetime import datetime, timedelta
from typing import List, Optional

try:
    from pytz import timezone as pytz_timezone
    SHANGHAI_TZ = pytz_timezone("Asia/Shanghai")
except ImportError:
    SHANGHAI_TZ = None

from ..utils.paths import REPORTS_DIR, COOLDOWN_FILE, REPORT_INDEX_FILE, ensure_dirs

logger = logging.getLogger("service_monitor.report_service")

# 报告默认保留天数（可被配置覆盖）
DEFAULT_RETENTION_DAYS = 14

# Token 访问有效期（天）
ACCESS_TOKEN_DAYS = 7

NORMAL, WARNING, CRITICAL = "正常", "警告", "严重"

# ============================================================
# 报告索引（元数据缓存，替代全量扫描）
# ============================================================

_index_lock = threading.Lock()


def _load_index() -> dict:
    """加载报告索引。返回 {report_id: summary_dict}。"""
    if not REPORT_INDEX_FILE.exists():
        return {}
    try:
        return json.loads(REPORT_INDEX_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        logger.warning("读取报告索引失败，重建: %s", e)
        return {}


def _save_index(index: dict) -> None:
    """持久化报告索引。"""
    ensure_dirs()
    REPORT_INDEX_FILE.write_text(
        json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def _index_entry_for(report: dict) -> dict:
    """从完整报告提取索引条目（仅摘要字段）。"""
    return {
        "id": report.get("id"),
        "target_id": report.get("target_id"),
        "target_name": report.get("target_name"),
        "target_type": report.get("target_type"),
        "suite": report.get("suite"),
        "started_at": report.get("started_at"),
        "finished_at": report.get("finished_at"),
        "summary": report.get("summary", {}),
    }


def _add_to_index(report: dict) -> None:
    """添加/更新索引条目。"""
    rid = report.get("id")
    if not rid:
        return
    with _index_lock:
        index = _load_index()
        index[rid] = _index_entry_for(report)
        _save_index(index)


def _remove_from_index(report_id: str) -> None:
    """从索引中移除条目。"""
    with _index_lock:
        index = _load_index()
        index.pop(report_id, None)
        _save_index(index)


def rebuild_index() -> int:
    """从磁盘所有报告重建索引，返回索引的报告数。"""
    ensure_dirs()
    index = {}
    for p in REPORTS_DIR.glob("*.json"):
        try:
            r = json.loads(p.read_text(encoding="utf-8"))
            rid = r.get("id")
            if rid:
                index[rid] = _index_entry_for(r)
        except (json.JSONDecodeError, OSError):
            continue
    _save_index(index)
    logger.info("重建报告索引: %d 份报告", len(index))
    return len(index)


def _now() -> str:
    return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")


def _report_path(report_id: str):
    return REPORTS_DIR / f"{report_id}.json"


def save(target: dict, suite: str, modules_result: list,
         started_at: str, finished_at: Optional[str] = None) -> str:
    """保存巡检报告，返回 report_id。

    modules_result: [
      {"id","name","category","items":[{key,name,value,threshold,status}], "summary":{...}}
    ]
    """
    ensure_dirs()
    report_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + str(uuid.uuid4())[:6]

    # 汇总
    total = {NORMAL: 0, WARNING: 0, CRITICAL: 0, "total": 0}
    for m in modules_result:
        for it in m.get("items", []):
            total["total"] += 1
            st = it.get("status", NORMAL)
            if st in total:
                total[st] += 1

    # Token 过期时间
    now = datetime.now(SHANGHAI_TZ) if SHANGHAI_TZ else datetime.now()
    expires_at = (now + timedelta(days=ACCESS_TOKEN_DAYS)).isoformat()

    report = {
        "id": report_id,
        "target_id": target.get("id"),
        "target_name": target.get("name"),
        "target_type": target.get("type"),
        "suite": suite,
        "started_at": started_at,
        "finished_at": finished_at or _now(),
        "summary": total,
        "modules": modules_result,
        "access_token": f"rpt_{secrets.token_hex(32)}",
        "access_token_expires_at": expires_at,
    }

    _report_path(report_id).write_text(
        json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    _add_to_index(report)
    logger.info("保存报告: %s (目标=%s, 套件=%s, 项=%d)",
                report_id, target.get("name"), suite, total["total"])
    return report_id


def get_report(report_id: str) -> Optional[dict]:
    """读取报告。"""
    p = _report_path(report_id)
    if not p.exists():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        logger.error("读取报告失败 %s: %s", report_id, e)
        return None


def list_reports(target_id: Optional[str] = None, limit: int = 50) -> List[dict]:
    """列出报告摘要（按时间倒序），使用索引避免全量扫描。"""
    ensure_dirs()
    index = _load_index()
    if not index:
        # 索引为空时降级全量扫描并重建索引
        return _list_reports_fallback(target_id, limit)

    summaries = []
    for rid in sorted(index.keys(), reverse=True):
        entry = index[rid]
        if target_id and entry.get("target_id") != target_id:
            continue
        summaries.append(entry)
        if len(summaries) >= limit:
            break
    return summaries


def _list_reports_fallback(target_id: Optional[str] = None, limit: int = 50) -> List[dict]:
    """全量扫描降级（索引不存在/为空时），同时重建索引。"""
    summaries = []
    index = {}
    for p in sorted(REPORTS_DIR.glob("*.json"), reverse=True):
        try:
            r = json.loads(p.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        if target_id and r.get("target_id") != target_id:
            continue
        entry = _index_entry_for(r)
        rid = r.get("id")
        if rid:
            index[rid] = entry
        summaries.append(entry)
        if len(summaries) >= limit:
            break
    # 异步重建索引（不阻塞返回）
    if index:
        _save_index(index)
    return summaries


def delete_report(report_id: str) -> bool:
    """删除单份报告。"""
    p = _report_path(report_id)
    if p.exists():
        p.unlink()
        _remove_from_index(report_id)
        logger.info("删除报告: %s", report_id)
        return True
    return False


def cleanup_expired(retention_days: int = DEFAULT_RETENTION_DAYS) -> int:
    """清理过期报告，返回清理数量。"""
    ensure_dirs()
    cutoff = datetime.now() - timedelta(days=retention_days)
    removed = 0
    removed_ids = []
    for p in REPORTS_DIR.glob("*.json"):
        try:
            r = json.loads(p.read_text(encoding="utf-8"))
            finished = r.get("finished_at") or r.get("started_at")
            if finished:
                dt = datetime.strptime(finished[:19], "%Y-%m-%dT%H:%M:%S")
                if dt < cutoff:
                    p.unlink()
                    rid = r.get("id")
                    if rid:
                        removed_ids.append(rid)
                    removed += 1
        except (json.JSONDecodeError, OSError, ValueError):
            continue
    if removed_ids:
        with _index_lock:
            index = _load_index()
            for rid in removed_ids:
                index.pop(rid, None)
            _save_index(index)
    if removed:
        logger.info("清理过期报告 %d 份（保留 %d 天）", removed, retention_days)
    return removed


# ============================================================
# 导出
# ============================================================

def export_json(report_id: str) -> Optional[dict]:
    """导出 JSON（即报告本身）。"""
    return get_report(report_id)


def export_markdown(report_id: str) -> Optional[str]:
    """导出 Markdown 报告。"""
    r = get_report(report_id)
    if not r:
        return None

    s = r.get("summary", {})
    lines = [
        f"# 服务巡检报告",
        "",
        f"- **目标**：{r.get('target_name')}（{r.get('target_type')}）",
        f"- **套件**：{'快速巡检' if r.get('suite') == 'quick' else '全量巡检'}",
        f"- **开始时间**：{r.get('started_at')}",
        f"- **完成时间**：{r.get('finished_at')}",
        "",
        f"## 汇总",
        "",
        f"| 总项数 | 正常 | 警告 | 严重 |",
        f"|--------|------|------|------|",
        f"| {s.get('total', 0)} | {s.get(NORMAL, 0)} | {s.get(WARNING, 0)} | {s.get(CRITICAL, 0)} |",
        "",
    ]

    # 异常项置顶
    abnormal = []
    for m in r.get("modules", []):
        for it in m.get("items", []):
            if it.get("status") in (WARNING, CRITICAL):
                abnormal.append((m.get("name"), it))
    if abnormal:
        lines += ["## ⚠️ 异常项", "", "| 模块 | 检测项 | 当前值 | 阈值 | 状态 |",
                  "|------|--------|--------|------|------|"]
        for mod_name, it in abnormal:
            lines.append(
                f"| {mod_name} | {it.get('name')} | {it.get('value')} | "
                f"{it.get('threshold') or '-'} | {it.get('status')} |"
            )
        lines.append("")

    # 各模块明细
    lines += ["## 检测明细", ""]
    for m in r.get("modules", []):
        lines += [f"### {m.get('name')}", "",
                  "| 检测项 | 当前值 | 阈值 | 状态 |",
                  "|--------|--------|------|------|"]
        for it in m.get("items", []):
            lines.append(
                f"| {it.get('name')} | {it.get('value')} | "
                f"{it.get('threshold') or '-'} | {it.get('status')} |"
            )
        lines.append("")

    return "\n".join(lines)


def export_excel(report_id: str) -> Optional[bytes]:
    """导出 Excel (XLSX) 格式报告。

    Returns:
        Excel 二进制数据，报告不存在返回 None
    """
    from io import BytesIO
    from openpyxl import Workbook
    from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

    r = get_report(report_id)
    if not r:
        return None

    wb = Workbook()

    # ---- Sheet 1：汇总 ----
    ws_summary = wb.active
    ws_summary.title = "汇总"

    # 标题样式
    title_font = Font(name="Microsoft YaHei", size=14, bold=True, color="1a73e8")
    header_font = Font(name="Microsoft YaHei", size=11, bold=True, color="333333")
    normal_font = Font(name="Microsoft YaHei", size=11)
    header_fill = PatternFill(start_color="f5f5f5", end_color="f5f5f5", fill_type="solid")
    thin_border = Border(
        left=Side(style="thin", color="d9d9d9"),
        right=Side(style="thin", color="d9d9d9"),
        top=Side(style="thin", color="d9d9d9"),
        bottom=Side(style="thin", color="d9d9d9"),
    )

    s = r.get("summary", {})

    # 标题行
    ws_summary.merge_cells("A1:B1")
    ws_summary["A1"] = "服务巡检报告"
    ws_summary["A1"].font = title_font
    ws_summary.row_dimensions[1].height = 30

    # 信息行
    info_rows = [
        ("目标", f"{r.get('target_name', '-')}（{r.get('target_type', '-')}）"),
        ("套件", "快速巡检" if r.get("suite") == "quick" else "全量巡检"),
        ("开始时间", (r.get("started_at") or "").replace("T", " ")),
        ("完成时间", (r.get("finished_at") or "").replace("T", " ")),
        ("报告ID", r.get("id", "-")),
        ("总检测项", s.get("total", 0)),
        ("正常", s.get(NORMAL, 0)),
        ("警告", s.get(WARNING, 0)),
        ("严重", s.get(CRITICAL, 0)),
    ]

    for idx, (label, value) in enumerate(info_rows, start=2):
        cell_a = ws_summary.cell(row=idx, column=1, value=label)
        cell_a.font = header_font
        cell_a.fill = header_fill
        cell_a.border = thin_border
        cell_a.alignment = Alignment(horizontal="right")

        cell_b = ws_summary.cell(row=idx, column=2, value=value)
        cell_b.font = normal_font
        cell_b.border = thin_border

        # 状态数字着色
        if label == "正常" and isinstance(value, (int, float)):
            cell_b.font = Font(name="Microsoft YaHei", size=11, color="389e0d", bold=True)
        elif label == "警告" and isinstance(value, (int, float)):
            cell_b.font = Font(name="Microsoft YaHei", size=11, color="d48806", bold=True)
        elif label == "严重" and isinstance(value, (int, float)):
            cell_b.font = Font(name="Microsoft YaHei", size=11, color="cf1322", bold=True)

    ws_summary.column_dimensions["A"].width = 14
    ws_summary.column_dimensions["B"].width = 40

    # ---- Sheet 2：检测明细 ----
    ws_detail = wb.create_sheet(title="检测明细")

    # 表头
    headers = ["模块", "检测项KEY", "检测项名称", "当前值", "阈值", "状态", "类别"]
    col_widths = [18, 24, 22, 20, 16, 10, 10]

    for col_idx, (header, width) in enumerate(zip(headers, col_widths), start=1):
        cell = ws_detail.cell(row=1, column=col_idx, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.border = thin_border
        cell.alignment = Alignment(horizontal="center")
        ws_detail.column_dimensions[cell.column_letter].width = width

    # 冻结首行
    ws_detail.freeze_panes = "A2"

    # 条件格式填充
    fill_normal = PatternFill(start_color="f0fff0", end_color="f0fff0", fill_type="solid")
    fill_warning = PatternFill(start_color="fffbe6", end_color="fffbe6", fill_type="solid")
    fill_critical = PatternFill(start_color="fff0f0", end_color="fff0f0", fill_type="solid")

    font_normal = Font(name="Microsoft YaHei", size=10, color="389e0d")
    font_warning = Font(name="Microsoft YaHei", size=10, color="d48806", bold=True)
    font_critical = Font(name="Microsoft YaHei", size=10, color="cf1322", bold=True)

    # 数据行
    row_idx = 2
    for m in r.get("modules", []):
        mod_name = m.get("name", "")
        category = m.get("category", "")
        for it in m.get("items", []):
            status = it.get("status", NORMAL)

            row_data = [
                mod_name,
                it.get("key", ""),
                it.get("name", ""),
                it.get("value", ""),
                it.get("threshold") or "-",
                status,
                category,
            ]

            for col_idx, val in enumerate(row_data, start=1):
                cell = ws_detail.cell(row=row_idx, column=col_idx, value=val)
                cell.font = normal_font
                cell.border = thin_border
                cell.alignment = Alignment(vertical="center", wrap_text=(col_idx == 4))

            # 状态列条件格式
            status_cell = ws_detail.cell(row=row_idx, column=6)
            if status == CRITICAL:
                status_cell.font = font_critical
                status_cell.fill = fill_critical
            elif status == WARNING:
                status_cell.font = font_warning
                status_cell.fill = fill_warning
            elif status == NORMAL:
                status_cell.font = font_normal
                status_cell.fill = fill_normal

            row_idx += 1

    # 写入缓冲区
    buf = BytesIO()
    wb.save(buf)
    buf.seek(0)
    return buf.read()


def export_pdf(report_id: str) -> Optional[bytes]:
    """导出 PDF 格式报告。

    使用 weasyprint 将 HTML 转为 PDF。如 weasyprint 不可用，
    则降级返回可打印的 HTML（浏览器打开后可手动打印为 PDF）。

    Returns:
        PDF 二进制数据，报告不存在返回 None
    """
    r = get_report(report_id)
    if not r:
        return None

    html_content = _build_report_html(r)

    try:
        from weasyprint import HTML as WeasyHTML
        from io import BytesIO
        buf = BytesIO()
        WeasyHTML(string=html_content).write_pdf(buf)
        buf.seek(0)
        return buf.read()
    except ImportError:
        logger.info("weasyprint 不可用，降级返回可打印 HTML")
        # 降级：返回 HTML（Content-Type 由路由层判断）
        return html_content.encode("utf-8")
    except Exception as e:
        logger.error("PDF 生成失败: %s，降级返回 HTML", e)
        return html_content.encode("utf-8")


def _build_report_html(r: dict) -> str:
    """构建报告的打印友好 HTML（用于 PDF 生成或浏览器打印）。"""
    s = r.get("summary", {})

    # 异常项收集
    abnormal = []
    for m in r.get("modules", []):
        for it in m.get("items", []):
            if it.get("status") in (WARNING, CRITICAL):
                abnormal.append((m.get("name"), it))

    html_parts = [
        "<!DOCTYPE html><html><head><meta charset='utf-8'>",
        f"<title>巡检报告 - {r.get('target_name', '-')}</title>",
        "<style>",
        "body{font-family:'Microsoft YaHei','SimHei',sans-serif;max-width:960px;margin:0 auto;padding:20px;color:#333;font-size:12px}",
        "h1{color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:6px;font-size:18px}",
        "h2{color:#333;margin-top:20px;font-size:14px}",
        "h3{color:#555;font-size:13px}",
        "table{border-collapse:collapse;width:100%;margin:8px 0;font-size:11px}",
        "th,td{border:1px solid #ddd;padding:6px 10px;text-align:left}",
        "th{background:#f5f5f5;font-weight:600}",
        ".stt-normal{color:#389e0d;font-weight:600}",
        ".stt-warning{color:#d48806;font-weight:600}",
        ".stt-critical{color:#cf1322;font-weight:600}",
        ".summary-cards{display:flex;gap:10px;margin:12px 0}",
        ".card{flex:1;padding:10px;border-radius:6px;text-align:center;border:1px solid #ddd}",
        ".card .num{font-size:20px;font-weight:700}",
        ".card .label{font-size:11px;color:#666}",
        ".card-normal{background:#f0fff0;border-color:#b7eb8f}",
        ".card-normal .num{color:#389e0d}",
        ".card-warning{background:#fffbe6;border-color:#ffe58f}",
        ".card-warning .num{color:#d48806}",
        ".card-critical{background:#fff0f0;border-color:#ffccc7}",
        ".card-critical .num{color:#cf1322}",
        "@page{size:A4;margin:15mm 12mm;@top-center{content:'服务巡检报告';font-size:9px;color:#999}",
        "  @bottom-center{content:'第 ' counter(page) ' 页';font-size:9px;color:#999}}",
        "@media print{body{padding:0}h1{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}",
        "</style></head><body>",

        # 标题
        f"<h1>巡检报告</h1>",
        f"<p><b>目标：</b>{r.get('target_name', '-')}（{r.get('target_type', '-')}）"
        f"&nbsp;&nbsp;<b>套件：</b>{'快速巡检' if r.get('suite') == 'quick' else '全量巡检'}</p>",
        f"<p><b>时间：</b>{(r.get('started_at') or '').replace('T', ' ')} ~ "
        f"{(r.get('finished_at') or '').replace('T', ' ')}</p>",

        # 汇总卡片
        '<div class="summary-cards">',
        f'<div class="card"><div class="num">{s.get("total", 0)}</div><div class="label">总检测项</div></div>',
        f'<div class="card card-normal"><div class="num">{s.get(NORMAL, 0)}</div><div class="label">正常</div></div>',
        f'<div class="card card-warning"><div class="num">{s.get(WARNING, 0)}</div><div class="label">警告</div></div>',
        f'<div class="card card-critical"><div class="num">{s.get(CRITICAL, 0)}</div><div class="label">严重</div></div>',
        '</div>',
    ]

    # 异常项
    if abnormal:
        html_parts.append("<h2>⚠️ 异常项</h2>")
        html_parts.append("<table><tr><th>模块</th><th>检测项</th><th>当前值</th><th>阈值</th><th>状态</th></tr>")
        for mod_name, it in abnormal:
            status = it.get("status", "")
            css_cls = "stt-critical" if status == CRITICAL else "stt-warning"
            html_parts.append(
                f"<tr><td>{mod_name}</td><td>{it.get('name', '-')}</td>"
                f"<td>{it.get('value', '-')}</td><td>{it.get('threshold') or '-'}</td>"
                f"<td class='{css_cls}'>{status}</td></tr>"
            )
        html_parts.append("</table>")

    # 各模块明细
    html_parts.append("<h2>检测明细</h2>")
    for m in r.get("modules", []):
        html_parts.append(f"<h3>{m.get('name', '')}</h3>")
        html_parts.append("<table><tr><th>检测项</th><th>当前值</th><th>阈值</th><th>状态</th></tr>")
        for it in m.get("items", []):
            status = it.get("status", NORMAL)
            if status == CRITICAL:
                css_cls = "stt-critical"
            elif status == WARNING:
                css_cls = "stt-warning"
            else:
                css_cls = "stt-normal"
            html_parts.append(
                f"<tr><td>{it.get('name', '-')}</td><td>{it.get('value', '-')}</td>"
                f"<td>{it.get('threshold') or '-'}</td><td class='{css_cls}'>{status}</td></tr>"
            )
        html_parts.append("</table>")

    html_parts.append("</body></html>")
    return "\n".join(html_parts)


# ============================================================
# Token 验证
# ============================================================

def validate_access_token(report_id: str, token: str) -> bool:
    """验证报告访问 token 是否有效。

    Args:
        report_id: 报告 ID
        token: 访问 token（rpt_xxx 格式）

    Returns:
        True=token 有效，False=无效或过期
    """
    if not token or not token.startswith("rpt_"):
        return False

    report = get_report(report_id)
    if not report:
        return False

    stored_token = report.get("access_token", "")
    if not stored_token or stored_token != token:
        return False

    expires_at_str = report.get("access_token_expires_at", "")
    if not expires_at_str:
        return False

    try:
        expires_at = datetime.fromisoformat(expires_at_str)
        now = datetime.now(SHANGHAI_TZ) if SHANGHAI_TZ else datetime.now()
        # 如果 expires_at 无时区信息，假设为本地时间
        if expires_at.tzinfo is None and now.tzinfo is not None:
            expires_at = expires_at.replace(tzinfo=now.tzinfo)
        elif expires_at.tzinfo is not None and now.tzinfo is None:
            now = now.replace(tzinfo=expires_at.tzinfo)
        if now > expires_at:
            return False
    except (ValueError, TypeError):
        return False

    return True


# ============================================================
# 连续异常告警
# ============================================================

from typing import List as _List

# ============================================================
# 冷却期持久化（文件-backed，重启不丢失）
# ============================================================

_cooldown_lock = threading.Lock()
COOLDOWN_EXPIRY_HOURS = 24


def _load_cooldowns() -> dict:
    """从磁盘加载冷却期记录。"""
    if not COOLDOWN_FILE.exists():
        return {}
    try:
        return json.loads(COOLDOWN_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        logger.warning("读取冷却期文件失败，重置: %s", e)
        return {}


def _save_cooldowns(data: dict) -> None:
    """持久化冷却期记录到磁盘。"""
    ensure_dirs()
    COOLDOWN_FILE.write_text(
        json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
    )


def _clean_expired_cooldowns(data: dict) -> dict:
    """清理已过期的冷却期条目。"""
    from datetime import timedelta
    now = datetime.now()
    fresh = {}
    for key, ts in data.items():
        try:
            dt = datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S")
            if now - dt < timedelta(hours=COOLDOWN_EXPIRY_HOURS):
                fresh[key] = ts
        except (ValueError, TypeError):
            continue
    return fresh


def _get_cooldown(key: str) -> str:
    """从持久化存储查询冷却期时间，空字符串表示不在冷却中。"""
    with _cooldown_lock:
        data = _load_cooldowns()
        return data.get(key, "")


def _set_cooldown(key: str) -> None:
    """设置冷却期并持久化。"""
    ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
    with _cooldown_lock:
        data = _load_cooldowns()
        data[key] = ts
        # 顺便清理过期数据，避免文件无限膨胀
        data = _clean_expired_cooldowns(data)
        _save_cooldowns(data)


def _in_cooldown(key: str) -> bool:
    """检查是否在冷却期内。"""
    from datetime import timedelta
    last_alert = _get_cooldown(key)
    if not last_alert:
        return False
    try:
        last_dt = datetime.strptime(last_alert[:19], "%Y-%m-%dT%H:%M:%S")
        if datetime.now() - last_dt < timedelta(hours=COOLDOWN_EXPIRY_HOURS):
            return True
    except (ValueError, TypeError):
        pass
    return False


# 旧版内存冷却期（保留为空，确保导入旧代码的模块不报错）
_ALERT_COOLDOWN: Dict[str, str] = {}


def check_consecutive_abnormal(target_id: str, current_report: dict) -> _List[dict]:
    """检查连续异常检测项，返回需告警列表。

    Args:
        target_id: 目标 ID
        current_report: 当前报告数据

    Returns:
        连续异常检测项列表 [
            {
                "key": "CPU_USAGE",
                "name": "CPU使用率",
                "consecutive_count": 3,
                "first_seen": "2026-07-21T10:00:00",
                "last_value": "78%",
                "last_status": "warning"
            }
        ]
    """
    # 获取告警配置
    from . import notification_service
    config = notification_service.get_config()
    alert_config = config.get("alert_on_consecutive", {})

    if not alert_config.get("enabled"):
        return []

    threshold = alert_config.get("consecutive_threshold", 3)
    alert_level = alert_config.get("alert_level", "critical")  # "critical" or "all"

    # 提取当前报告异常项
    current_abnormal = _get_abnormal_items(current_report, alert_level)
    if not current_abnormal:
        return []

    # 查询最近 N-1 份报告
    recent_reports = list_reports(target_id=target_id, limit=threshold)
    # 排除当前报告
    current_id = current_report.get("id", "")
    recent_reports = [r for r in recent_reports if r.get("id") != current_id]

    if len(recent_reports) < threshold - 1:
        # 历史报告不足，无法判断连续性
        return []

    # 检查连续性
    alert_items = []
    for item in current_abnormal:
        key = item.get("key", "")
        if _is_consecutive_abnormal(key, recent_reports, threshold - 1, alert_level):
            # 检查冷却期
            cooldown_key = f"{target_id}:{key}"
            if not _in_cooldown(cooldown_key):
                # 查找首次异常时间
                first_seen = _find_first_abnormal_time(key, recent_reports, threshold - 1)
                item["consecutive_count"] = threshold
                item["first_seen"] = first_seen
                alert_items.append(item)
                _set_cooldown(cooldown_key)

    return alert_items


def _get_abnormal_items(report: dict, alert_level: str) -> _List[dict]:
    """提取报告中的异常检测项。"""
    items = []
    for mod in report.get("modules", []):
        for it in mod.get("items", []):
            status = it.get("status", "")
            if alert_level == "critical" and status == CRITICAL:
                items.append({
                    "key": it.get("key", ""),
                    "name": it.get("name", ""),
                    "value": it.get("value", ""),
                    "status": status,
                    "module_name": mod.get("name", ""),
                })
            elif alert_level == "all" and status in (WARNING, CRITICAL):
                items.append({
                    "key": it.get("key", ""),
                    "name": it.get("name", ""),
                    "value": it.get("value", ""),
                    "status": status,
                    "module_name": mod.get("name", ""),
                })
    return items


def _is_consecutive_abnormal(item_key: str, recent_reports: _List[dict],
                              required_count: int, alert_level: str) -> bool:
    """检查检测项是否连续异常。"""
    count = 0
    for report_summary in recent_reports[:required_count]:
        report_id = report_summary.get("id", "")
        if not report_id:
            continue
        report = get_report(report_id)
        if not report:
            continue
        # 检查该检测项是否异常
        abnormal_items = _get_abnormal_items(report, alert_level)
        if any(it.get("key") == item_key for it in abnormal_items):
            count += 1
        else:
            break  # 非连续
    return count >= required_count


def check_missing_reports(missing_days: int = 2) -> list:
    """检查长时间无报告的目标。

    对每个目标查询最新报告时间，如果超过 missing_days 天未生成报告，
    则返回告警列表。

    Args:
        missing_days: 缺失天数阈值（默认 2 天）

    Returns:
        缺失报告的目标列表 [
            {
                "target_name": str,
                "target_id": str,
                "last_report_at": str or None,
                "missing_days": int,
            },
            ...
        ]
    """
    from . import target_service

    missing = []
    now = datetime.now()
    targets = target_service.list_targets(role="admin")

    for target in targets:
        target_id = target.get("id", "")
        target_name = target.get("name", "未知")

        # 获取最新一份报告
        reports = list_reports(target_id=target_id, limit=1)
        if not reports:
            # 从未生成过报告
            missing.append({
                "target_name": target_name,
                "target_id": target_id,
                "last_report_at": None,
                "missing_days": missing_days,
            })
            continue

        latest = reports[0]
        finished_at = latest.get("finished_at") or latest.get("started_at", "")
        if not finished_at:
            missing.append({
                "target_name": target_name,
                "target_id": target_id,
                "last_report_at": None,
                "missing_days": missing_days,
            })
            continue

        try:
            last_dt = datetime.strptime(finished_at[:19], "%Y-%m-%dT%H:%M:%S")
            gap_days = (now - last_dt).days
            if gap_days >= missing_days:
                missing.append({
                    "target_name": target_name,
                    "target_id": target_id,
                    "last_report_at": finished_at,
                    "missing_days": gap_days,
                })
        except ValueError:
            continue

    return missing


def _find_first_abnormal_time(item_key: str, recent_reports: _List[dict],
                               count: int) -> str:
    """查找首次异常时间（从最近第 N 份报告）。"""
    for report_summary in reversed(recent_reports[:count]):
        report_id = report_summary.get("id", "")
        if not report_id:
            continue
        report = get_report(report_id)
        if not report:
            continue
        abnormal_items = _get_abnormal_items(report, "all")  # 任意级别
        if any(it.get("key") == item_key for it in abnormal_items):
            return report.get("finished_at") or report.get("started_at", "")
    return ""


