#!/usr/bin/env python3
"""cron_health_monitor.py — 全局cron job健康监控 v2

每10分钟跑一次，检查：
1. 所有启用cron job的last_status是否为failed
2. 关键job的last_run_at是否超过预期空闲时间
3. price_monitor等核心管道是否活着
4. 自己是否正常跑（自检）

发现新问题 → 推XMPP告警（同类问题2小时内不重复推）
"""
import json, os, sys, re
from pathlib import Path
from datetime import datetime, timezone, timedelta
from urllib.request import Request, urlopen

XMPP_BRIDGE = "http://127.0.0.1:5805/"
XMPP_USER = "hmo@yoin.fun"
STATE_FILE = Path.home() / ".hermes" / ".cron_health_state.json"
COOLDOWN_HOURS = 2  # 同类告警2小时内不重复

# 关键job及其最大允许空闲分钟
# 只监控实时管道（每分~每小时跑的关键任务）
# 日/周任务不在此列（它们不耽误实时推荐也不需要秒级响应）
CRITICAL_JOBS = {
    "price_monitor": {"max_idle_min": 15},            # 每2分
    "MoFin盘前中监控": {"max_idle_min": 90},          # 每25分（盘中）
    "自选买入区提醒": {"max_idle_min": 90},           # 每30分
    "自选买入区提醒-盘前午间尾盘": {"max_idle_min": 185}, # 每3小时(9/12/15点)，允许185分
    "宏观风险信号消费": {"max_idle_min": 40},          # 每15分
    "重评管道审计": {"max_idle_min": 90},              # 每30分
    "全局cron健康监控": {"max_idle_min": 30},          # 每10分 ← 自己
    "实时消息中继": {"max_idle_min": 20},
    "宏观上下文刷新": {"max_idle_min": 90},              # 每5分
}

def xmpp_push(text):
    try:
        payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode()
        req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"})
        urlopen(req, timeout=5)
        return True
    except Exception as e:
        print(f"[XMPP推送失败] {e}", file=sys.stderr)
        return False


def _create_cron_todo(tname, desc):
    """为故障cron创建TODO，fix_action带自动重试+修复逻辑"""
    try:
        # 找这个cron对应的脚本
        script_path = None
        try:
            import json as _j
            with open("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") as _f:
                _crons = _j.load(_f).get("jobs", [])
            for _c in _crons:
                if _c.get("name") == tname:
                    _s = _c.get("script", "")
                    if _s:
                        _sp = f"/home/hmo/.hermes/profiles/position-analyst/scripts/{_s.split()[0]}"
                        if __import__('os').path.exists(_sp):
                            script_path = _sp
                    break
        except Exception:
            pass
        
        if script_path:
            fix = (
                f"cd /home/hmo/MoFin && timeout 60 python3 {script_path} 2>&1 "
                f"| tail -5; if [ $? -eq 0 ]; then "
                f"python3 -c \"import json; p='/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'; "
                f"d=json.load(open(p)); "
                f"for j in d['jobs']: "
                f"  if j.get('name')=='{tname}': j['last_status']='ok'; "
                f"json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)\" "
                f"&& echo 'AUTO_FIXED'; "
                f"else echo 'AUTO_FAIL'; fi"
            )
        else:
            fix = (
                f"cd /home/hmo/MoFin && echo 'check cron: {tname}' && "
                f"python3 scripts/preflight_verify.py --cron-check"
            )
        
        _c = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
        _c.execute(
            "INSERT OR REPLACE INTO todos (id, title, description, status, priority, source, fix_action, created_at) "
            "VALUES (?, ?, ?, 'pending', 'high', 'cron_health', ?, datetime('now','localtime'))",
            (f"cron_err_{tname.replace(' ','_')}", f"[CRON_ERROR] {tname}", desc, fix)
        )
        _c.commit()
        _c.close()
    except Exception:
        pass


def parse_time(ts_str):
    if not ts_str:
        return None
    try:
        dt = datetime.fromisoformat(ts_str)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except Exception:
        return None

def get_idle_minutes(last_run_str):
    """计算上次运行到现在过了多少分钟"""
    last = parse_time(last_run_str)
    if not last:
        return None
    now = datetime.now(timezone.utc)
    return (now - last).total_seconds() / 60

def scan_jobs_files():
    """扫描所有cron jobs.json，合并返回全量job列表"""
    jobs = []
    seen = set()
    for jf in [
        "/home/hmo/.hermes/cron/jobs.json",
        "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json",
    ]:
        try:
            data = json.load(open(jf))
            for job in data.get("jobs", []):
                jid = job.get("id", "")
                if jid in seen:
                    continue
                seen.add(jid)
                jobs.append({
                    "id": jid,
                    "name": job.get("name", jid[:12]) or jid[:12],
                    "status": job.get("last_status", ""),
                    "last_run": job.get("last_run_at", ""),
                    "enabled": job.get("enabled", True),
                    "no_agent": job.get("no_agent", False),
                    "schedule": job.get("schedule", {}),
                })
        except Exception:
            pass
    return jobs

def check_idle(jobs):
    """检查关键job是否超过最大空闲时间"""
    stale = []
    for j in jobs:
        if not j["enabled"]:
            continue
        name = j["name"]
        rule = None
        for key, r in CRITICAL_JOBS.items():
            if key.lower() in name.lower():
                rule = r
                break
        if not rule:
            continue
        idle = get_idle_minutes(j["last_run"])
        if idle is None:
            stale.append((j, "无运行记录"))
        elif idle > rule["max_idle_min"]:
            idle_str = f"{idle:.0f}分"
            stale.append((j, f"超过{rule['max_idle_min']}分未运行(已{idle_str})"))
    return stale

def load_state():
    try:
        with open(STATE_FILE) as f:
            return json.load(f)
    except Exception:
        return {}

def save_state(state):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)

def is_new_alert(alert_key, state):
    """检查同类告警是否在cooldown期内"""
    now = datetime.now().timestamp()
    last = state.get(alert_key, {}).get("last_alerted", 0)
    return (now - last) > COOLDOWN_HOURS * 3600

def main():
    now_str = datetime.now().strftime("%H:%M")
    jobs = scan_jobs_files()
    state = load_state()
    alerts = []

    # ── 检查1：last_status=failed ──
    failed_jobs = [j for j in jobs if j["status"] == "failed" and j["enabled"]]
    if failed_jobs:
        for j in failed_jobs:
            tag = "no_agent" if j["no_agent"] else "LLM"
            key = f"failed_{j['id']}"
            if is_new_alert(key, state):
                alerts.append(f"❌ {j['name']}({j['id'][:8]}) [{tag}] {j['status']} @ {j['last_run'][:19]}")
                state[key] = {"last_alerted": datetime.now().timestamp()}
                # 同时写TODO供自愈系统处理
                _create_cron_todo(j['name'], f"[CRON_ERROR] {j['name']} 状态={j['status']} 最后运行={j['last_run'][:19]}")

    # ── 检查2：关键job空闲超时 ──
    stale_jobs = check_idle(jobs)
    if stale_jobs:
        for j, reason in stale_jobs:
            key = f"stale_{j['id']}"
            if is_new_alert(key, state):
                alerts.append(f"⏰ {j['name']}({j['id'][:8]}) {reason}")
                state[key] = {"last_alerted": datetime.now().timestamp()}

    # ── 检查3：XMPP bridge是否在线（TCP端口检测，不发消息到Dad） ─
    bridge_key = "bridge_down"
    try:
        import socket
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(3)
        result = sock.connect_ex(("127.0.0.1", 5805))
        sock.close()
        bridge_ok = (result == 0)
        if not bridge_ok and is_new_alert(bridge_key, state):
            alerts.append("🔴 XMPP bridge(5805)端口无响应")
            state[bridge_key] = {"last_alerted": datetime.now().timestamp()}
    except Exception as e:
        if is_new_alert(bridge_key, state):
            alerts.append(f"🔴 XMPP bridge检测失败: {e}")
            state[bridge_key] = {"last_alerted": datetime.now().timestamp()}

    # ── 检查4：gateway LLM推理是否正常（发真实请求测响应时间） ─
    gateway_key = "gateway_down"
    try:
        import urllib.request, json, time
        payload = json.dumps({
            "model": "position-analyst",
            "messages": [{"role": "user", "content": "ok"}],
            "max_tokens": 5
        }).encode()
        req = urllib.request.Request("http://127.0.0.1:8643/v1/chat/completions",
            data=payload,
            headers={"Authorization": "Bearer hermes123", "Content-Type": "application/json"})
        t0 = time.time()
        resp = urllib.request.urlopen(req, timeout=30)
        elapsed = time.time() - t0
        data = json.loads(resp.read())
        usage = data.get("usage", {})
        tok = usage.get("total_tokens", 0)
        if elapsed > 25 and is_new_alert(gateway_key, state):
            alerts.append(f"🔴 gateway响应慢: {elapsed:.0f}s ({tok}tok)")
            state[gateway_key] = {"last_alerted": datetime.now().timestamp()}
    except Exception as e:
        if is_new_alert(gateway_key, state):
            alerts.append(f"🔴 gateway推理失败: {e}")
            state[gateway_key] = {"last_alerted": datetime.now().timestamp()}

    # ── 检查5：price_monitor数据新鲜度（直接查DB，不依赖job记录）─
    price_key = "price_stale"
    try:
        from mofin_db import get_conn
        conn = get_conn()
        lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0]
        conn.close()
        if lp:
            lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp
            if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None:
                mins = (datetime.now() - lp_dt).total_seconds() / 60
                if mins > 15 and is_new_alert(price_key, state):
                    alerts.append(f"🔴 price_monitor {mins:.0f}分未更新数据")
                    state[price_key] = {"last_alerted": datetime.now().timestamp()}
    except Exception as e:
        if is_new_alert(price_key, state):
            alerts.append(f"🔴 price_monitor查询失败: {e}")
            state[price_key] = {"last_alerted": datetime.now().timestamp()}

    # ── 本脚本自检：检查自己是否在job列表里且有正常last_run ──
    my_name = "全局cron健康监控"
    myself = [j for j in jobs if my_name in j["name"]]
    if myself:
        m = myself[0]
        idle = get_idle_minutes(m["last_run"])
        if idle is None or idle > 30:
            # 自己异常但可能还在跑？记录但不推（否则死循环）
            print(f"[SELF_CHECK] 自身last_run={m.get('last_run','?')} {idle:.0f}分前", file=sys.stderr)

    save_state(state)

    if not alerts:
        print("[SILENT]")
        return

    # 推告警
    text = f"🔴 {len(alerts)}条健康告警 ({now_str}):\n" + "\n".join(alerts)
    print(text, file=sys.stderr)
    xmpp_push(text)

if __name__ == "__main__":
    main()
