#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成「定时任务 + 看板」总览报告 (MD + HTML 双份) -> /root/web_reports/"""
import json, sqlite3, subprocess, html, datetime as dt, os

OUT_DIR = "/root/web_reports"
STAMP = dt.datetime.now().strftime("%Y%m%d")
BASE = f"cron_kanban_overview_{STAMP}"
TZ = dt.timezone(dt.timedelta(hours=8))

def fmt(ts):
    if not ts:
        return "—"
    try:
        d = dt.datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        return d.astimezone(TZ).strftime("%m-%d %H:%M")
    except Exception:
        return str(ts)[:16]

def fmt_epoch(ts):
    if not ts:
        return "—"
    return dt.datetime.fromtimestamp(int(ts), TZ).strftime("%m-%d %H:%M")

# ---------- 1. Hermes cron ----------
jobs_raw = json.load(open("/root/.hermes/cron/jobs.json"))
jobs = jobs_raw["jobs"] if isinstance(jobs_raw, dict) else jobs_raw
DOW = {"0": "日", "1": "一", "2": "二", "3": "三", "4": "四", "5": "五", "6": "六"}
def human_sched(j):
    expr = j.get("schedule_display") or (j.get("schedule") or {}).get("expr") or ""
    if j.get("schedule", {}).get("kind") == "interval":
        return f"每 {j['schedule'].get('minutes','?')} 分钟"
    try:
        mi, ho, dom, mon, dow = expr.split()
    except Exception:
        return expr
    hh = "/".join(f"{int(x):02d}" for x in ho.split(","))
    mm = "/".join(f"{int(x):02d}" for x in mi.split(","))
    t = f"{hh}:{mm}"
    if dow == "*":
        day = "每天"
    elif dow == "1-5":
        day = "工作日"
    elif "," in dow and dom == "*":
        day = "、".join(f"周{DOW.get(x, x)}" for x in dow.split(","))
    else:
        day = f"周{DOW.get(dow, dow)}"
    if dom.isdigit():
        day = f"每月{int(dom)}日"
    return f"{day} {t}"

for j in jobs:
    j["_sched"] = human_sched(j)
    j["_last"] = fmt(j.get("last_run_at"))
    j["_next"] = fmt(j.get("next_run_at"))
    j["_deliver"] = {"feishu": "飞书", "local": "仅落盘", "origin": "本会话"}.get(j.get("deliver"), j.get("deliver") or "—")
    j["_kind"] = "脚本(无LLM)" if j.get("no_agent") else ("Agent" + (f" · {j.get('skill')}" if j.get("skill") else ""))
    if j.get("no_agent"):
        j["_model"] = "—"
    elif j.get("model"):
        j["_model"] = str(j["model"]) + "（钉死）"
    else:
        j["_model"] = f"{j.get('model_snapshot') or '全局默认'} · 跟随主模型"
    j["_status"] = {"ok": "正常", "error": "异常", "failed": "失败"}.get(j.get("last_status"), j.get("last_status") or "未跑")
    j["_runs"] = (j.get("repeat") or {}).get("completed") or 0

cron_ok = sum(1 for j in jobs if j.get("last_status") == "ok")
cron_bad = len(jobs) - cron_ok
cron_feishu = sum(1 for j in jobs if j.get("deliver") == "feishu")

# ---------- 2. 系统 crontab ----------
sys_cron = []
try:
    out = subprocess.run(["crontab", "-l"], capture_output=True, text=True).stdout
    for line in out.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split(None, 5)
        if len(parts) >= 6:
            sys_cron.append({"expr": " ".join(parts[:5]), "cmd": parts[5]})
except Exception:
    pass

# ---------- 3. 看板 ----------
con = sqlite3.connect("/root/.hermes/kanban.db")
con.row_factory = sqlite3.Row
tasks = [dict(r) for r in con.execute("SELECT * FROM tasks ORDER BY created_at DESC")]
by_status = {}
for t in tasks:
    by_status[t["status"]] = by_status.get(t["status"], 0) + 1
STATUS_CN = {"done": "已完成", "blocked": "阻塞", "running": "执行中", "ready": "待执行",
             "todo": "待办", "triage": "待分诊", "scheduled": "已排期", "archived": "归档",
             "review": "待审核"}
for t in tasks:
    t["_ctime"] = fmt_epoch(t.get("created_at"))
    t["_dtime"] = fmt_epoch(t.get("completed_at"))
    t["_dur"] = "—"
    if t.get("started_at") and t.get("completed_at"):
        t["_dur"] = f"{int((t['completed_at'] - t['started_at']) / 60)}分"
    t["_s"] = STATUS_CN.get(t["status"], t["status"])
    t["_t"] = t["title"]

diags = []
try:
    for r in con.execute("SELECT * FROM tasks WHERE consecutive_failures > 0 AND status IN ('blocked','ready','todo') "):
        diags.append(dict(r))
except Exception:
    pass
runs = []
try:
    runs = [dict(r) for r in con.execute("SELECT * FROM task_runs ORDER BY id DESC LIMIT 6")]
except Exception:
    pass
con.close()

ran_today = [t for t in tasks if t["_ctime"].startswith(dt.datetime.now(TZ).strftime("%m-%d"))]

# ---------- 4. MD ----------
now = dt.datetime.now(TZ).strftime("%Y-%m-%d %H:%M")
md = [f"# 定时任务 · 看板总览（{now}）", ""]
md.append(f"## 一、Hermes 定时任务（{len(jobs)} 个，正常 {cron_ok}）")
md.append("")
md.append("| 任务 | 时间 | 类型 | 模型 | 交付 | 最近运行 | 状态 | 下次 |")
md.append("|---|---|---|---|---|---|---|---|")
for j in sorted(jobs, key=lambda x: x["_sched"]):
    md.append(f"| {j['name']} | {j['_sched']} | {j['_kind']} | {j['_model']} | {j['_deliver']} | {j['_last']} | {j['_status']} | {j['_next']} |")
md += ["", f"## 二、系统 crontab（{len(sys_cron)} 条）", ""]
for s in sys_cron:
    md.append(f"- `{s['expr']}` — {s['cmd']}")
md += ["", f"## 三、看板 default 板（共 {len(tasks)} 张卡）", ""]
md.append("- 状态分布：" + "，".join(f"{STATUS_CN.get(k,k)} {v}" for k, v in sorted(by_status.items(), key=lambda x: -x[1])))
md.append("")
md.append("| 卡片 | 状态 | 创建 | 完成 | 耗时 |")
md.append("|---|---|---|---|---|")
for t in tasks[:20]:
    md.append(f"| {t['_t']} | {t['_s']} | {t['_ctime']} | {t['_dtime']} | {t['_dur']} |")
if diags:
    md += ["", "### 阻塞/异常"]
    for d in diags:
        md.append(f"- `{d['id']}` {d['title']}：连续失败 {d['consecutive_failures']} 次 — {(d.get('last_failure_error') or '')[:160]}")
md += ["", f"> 生成 {now} · MD/HTML 双份 · HTML: http://47.113.231.110/{BASE}.html"]
md_text = "\n".join(md)
open(f"{OUT_DIR}/{BASE}.md", "w", encoding="utf-8").write(md_text)

# ---------- 5. HTML ----------
e = html.escape
def row_job(j):
    dot = "#0a7d4d" if j["_status"] == "正常" else "#c0392b"
    return f"""<tr>
<td class="tname">{e(j['name'])}<span class="jid">{j['id']}</span></td>
<td class="mono">{e(j['_sched'])}</td>
<td>{e(j['_kind'])}</td>
<td class="mono">{e(j['_model'])}</td>
<td>{e(j['_deliver'])}</td>
<td class="mono">{e(j['_last'])}</td>
<td><span class="dot" style="background:{dot}"></span>{e(j['_status'])}</td>
<td class="mono faint">{e(j['_next'])}</td></tr>"""

def row_task(t):
    cls = {"done": "ok", "blocked": "bad", "archived": "faint"}.get(t["status"], "")
    return f"""<tr class="{cls}">
<td class="tname">{e(t['_t'])}<span class="jid">{t['id']}</span></td>
<td>{e(t['_s'])}</td>
<td class="mono">{e(t['_ctime'])}</td>
<td class="mono">{e(t['_dtime'])}</td>
<td class="mono">{e(t['_dur'])}</td></tr>"""

kpi = [
    ("定时任务", len(jobs), ""), ("运行正常", cron_ok, "green"), ("异常", cron_bad, "red" if cron_bad else ""),
    ("推送飞书", cron_feishu, ""), ("系统 crontab", len(sys_cron), ""),
    ("看板卡片", len(tasks), ""), ("已完成", by_status.get("done", 0), "green"),
    ("阻塞", by_status.get("blocked", 0), "red" if by_status.get("blocked") else ""),
]
kpi_html = "".join(
    f'<div class="kpi"><div class="v {c}">{v}</div><div class="k">{e(k)}</div></div>' for k, v, c in kpi)

diag_html = ""
if diags:
    items = "".join(
        f'<li><b>{e(d["title"])}</b> <span class="jid">{d["id"]}</span><br>连续失败 <b>{d["consecutive_failures"]}</b> 次 · '
        f'<span class="faint">{e((d.get("last_failure_error") or "")[:220])}</span></li>' for d in diags)
    diag_html = f'<div class="warn"><div class="warn-t">⚠ 需要处理</div><ul>{items}</ul></div>'

html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>定时任务 · 看板总览 · 钱小兜</title>
<style>
  *{{box-sizing:border-box;margin:0;padding:0}}
  html{{-webkit-text-size-adjust:100%}}
  body{{background:#fff;color:#1a1a1a;font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif;line-height:1.7;font-size:15px}}
  .wrap{{max-width:1060px;margin:0 auto;padding:48px 36px 72px}}
  .num,.mono{{font-family:"Helvetica Neue",Arial,"PingFang SC",sans-serif;font-variant-numeric:tabular-nums;letter-spacing:.01em}}
  .masthead{{border-top:4px double #1a1a1a;border-bottom:1px solid #1a1a1a;padding:24px 0 16px;text-align:center;margin-bottom:30px}}
  .masthead .tag{{font-size:11px;letter-spacing:.42em;color:#6b7280;margin-bottom:12px}}
  .masthead h1{{font-family:Georgia,"Songti SC","Noto Serif SC",serif;font-size:44px;font-weight:700;letter-spacing:.1em;text-indent:.1em;line-height:1.18}}
  .masthead .meta{{display:flex;justify-content:space-between;margin-top:14px;padding-top:10px;border-top:1px solid #e5e5e5;font-size:12px;color:#6b7280;letter-spacing:.06em;flex-wrap:wrap;gap:6px}}
  .kpis{{display:grid;grid-template-columns:repeat(8,1fr);border:1px solid #d9d9d9;border-radius:14px;overflow:hidden;margin-bottom:38px}}
  .kpi{{padding:14px 8px 12px;text-align:center;border-right:1px solid #e5e5e5}}
  .kpi:last-child{{border-right:none}}
  .kpi .v{{font-family:"Helvetica Neue",Arial,sans-serif;font-size:28px;font-weight:800;line-height:1.2}}
  .kpi .v.red{{color:#c0392b}} .kpi .v.green{{color:#0a7d4d}}
  .kpi .k{{font-size:11px;color:#6b7280;margin-top:2px;letter-spacing:.04em}}
  h2{{font-family:Georgia,"Songti SC",serif;font-size:22px;margin:34px 0 6px;padding-bottom:8px;border-bottom:2px solid #1a1a1a;display:flex;align-items:baseline;gap:10px}}
  h2 .idx{{font-size:12px;color:#b8893f;letter-spacing:.2em;font-weight:700}}
  h2 .cnt{{margin-left:auto;font-size:12px;color:#6b7280;font-family:"Helvetica Neue",Arial,sans-serif;font-weight:600}}
  .tscroll{{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:14px 0 8px;border:1px solid #e5e5e5;border-radius:10px}}
  table{{border-collapse:collapse;width:100%;min-width:680px;font-size:13.5px}}
  th,td{{padding:10px 12px;text-align:left;border-bottom:1px solid #eee;vertical-align:top}}
  th{{background:#faf8f5;font-size:11.5px;color:#6b7280;letter-spacing:.08em;font-weight:700;white-space:nowrap}}
  tr:last-child td{{border-bottom:none}}
  tbody tr:nth-child(even){{background:#fcfbf9}}
  td.tname{{font-weight:600;min-width:190px}}
  .jid{{display:block;font-size:10.5px;color:#9ca3af;font-weight:400;font-family:"Helvetica Neue",Arial,sans-serif;letter-spacing:.04em}}
  .faint{{color:#9ca3af}}
  tr.ok td:first-child{{border-left:3px solid #0a7d4d}}
  tr.bad td:first-child{{border-left:3px solid #c0392b;background:#fdf4f3}}
  tr.archived{{color:#9ca3af}}
  .dot{{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:1px}}
  .note{{font-size:12.5px;color:#6b7280;margin:6px 0 0}}
  .chips{{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 4px}}
  .chip{{border:1px solid #e5e5e5;border-radius:999px;padding:5px 13px;font-size:12.5px;background:#faf8f5}}
  .chip b{{font-family:"Helvetica Neue",Arial,sans-serif;font-size:15px;margin-left:5px}}
  .chip.g b{{color:#0a7d4d}} .chip.r b{{color:#c0392b}}
  .warn{{margin:16px 0 0;padding:16px 18px;border-left:4px solid #c0392b;background:#fdf6ef;border-radius:0 10px 10px 0}}
  .warn-t{{font-weight:700;color:#c0392b;margin-bottom:8px;font-size:13.5px}}
  .warn ul{{margin-left:18px;font-size:13px;line-height:1.75}}
  .warn li{{margin-bottom:6px}}
  .syslist{{list-style:none;margin:12px 0 0}}
  .syslist li{{padding:10px 13px;border:1px solid #e5e5e5;border-radius:9px;margin-bottom:8px;font-size:13px;background:#faf8f5}}
  .syslist code{{font-family:"SF Mono",Menlo,Consolas,monospace;font-size:12.5px;color:#2f6f9f}}
  footer{{margin-top:40px;padding-top:14px;border-top:1px solid #e5e5e5;font-size:12px;color:#9ca3af;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}}
  @media (max-width:860px){{
    .kpis{{grid-template-columns:repeat(4,1fr)}}
    .kpi:nth-child(4n){{border-right:none}}
    .kpi:nth-child(-n+4){{border-bottom:1px solid #e5e5e5}}
  }}
  @media (max-width:600px){{
    .wrap{{padding:28px 16px 48px}}
    .masthead h1{{font-size:30px;letter-spacing:.06em}}
    .masthead .meta{{font-size:11px;justify-content:center;text-align:center}}
    .kpis{{grid-template-columns:repeat(2,1fr);border-radius:12px}}
    .kpi{{border-right:1px solid #e5e5e5;border-bottom:1px solid #e5e5e5}}
    .kpi:nth-child(2n){{border-right:none}}
    h2{{font-size:19px}}
    table{{min-width:620px}}
    th:first-child,td:first-child{{position:sticky;left:0;background:#fff;z-index:1;box-shadow:1px 0 0 #e5e5e5}}
    thead th:first-child{{background:#faf8f5}}
    tbody tr:nth-child(even) td:first-child{{background:#fcfbf9}}
    tr.bad td:first-child{{background:#fdf4f3}}
  }}
</style>
</head>
<body>
<div class="wrap">
  <div class="masthead">
    <div class="tag">QIANXIAODOU · SYSTEM INVENTORY</div>
    <h1>定时任务 · 看板</h1>
    <div class="meta"><span>数据源：cron/jobs.json · kanban.db · crontab</span><span>生成 {now} CST</span></div>
  </div>

  <div class="kpis">{kpi_html}</div>

  <h2><span class="idx">01</span>Hermes 定时任务<span class="cnt">{len(jobs)} 个 · 正常 {cron_ok}</span></h2>
  <div class="tscroll"><table>
    <thead><tr><th>任务</th><th>时间</th><th>类型</th><th>模型</th><th>交付</th><th>最近运行</th><th>状态</th><th>下次</th></tr></thead>
    <tbody>{"".join(row_job(j) for j in sorted(jobs, key=lambda x: x['_sched']))}</tbody>
  </table></div>
  <p class="note">交付「飞书」= 结果推送本会话；「仅落盘」= 静默执行，产出留本地。模型列：脚本任务无 LLM；「跟随主模型」= 未钉死、随全局默认（换主模型后跑 <code>hermes cron resnap &lt;job_id&gt;</code> 刷新）。</p>

  <h2><span class="idx">02</span>系统 crontab<span class="cnt">{len(sys_cron)} 条</span></h2>
  <ul class="syslist">{"".join(f'<li><code>{e(s["expr"])}</code> &nbsp;{e(s["cmd"])}</li>' for s in sys_cron)}</ul>

  <h2><span class="idx">03</span>看板 · default 板<span class="cnt">共 {len(tasks)} 张卡</span></h2>
  <div class="chips">{"".join(f'<span class="chip {"g" if k=="done" else ("r" if k=="blocked" else "")}">{STATUS_CN.get(k,k)}<b>{v}</b></span>' for k, v in sorted(by_status.items(), key=lambda x: -x[1]))}</div>
  <div class="tscroll"><table>
    <thead><tr><th>卡片</th><th>状态</th><th>创建</th><th>完成</th><th>耗时</th></tr></thead>
    <tbody>{"".join(row_task(t) for t in tasks[:22])}</tbody>
  </table></div>
  <p class="note">展示最近 22 张（按创建倒序）；今日新增 {len(ran_today)} 张。</p>
  {diag_html}
  <footer><span>钱小兜引擎 · 定时任务与看板总览</span><span>http://47.113.231.110/{BASE}.html</span></footer>
</div>
</body>
</html>"""
open(f"{OUT_DIR}/{BASE}.html", "w", encoding="utf-8").write(html_doc)
print("OK", BASE)
print("cron:", len(jobs), "ok", cron_ok, "bad", cron_bad, "| syscron:", len(sys_cron), "| tasks:", len(tasks), by_status)
print("blocked:", [(d["id"], d["title"]) for d in diags])
