# -*- coding: utf-8 -*-
"""
dashboard.py - AgentsMeeting management dashboard backend
=========================================================
Flask app on :5803. Monitors agents across platforms via:
  - SSH + ejabberdctl connected_users (cross-platform, authoritative)
  - xmpp_bot HTTP API :5807 (/health, /muc - fallback)
  - Local process/port checks (Windows only)

Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
"""
import os, sys, re, json, time, socket, subprocess, logging, urllib.request, sqlite3, shutil, threading
from pathlib import Path
from datetime import datetime, timedelta
from flask import Flask, jsonify, request, send_from_directory

# ---- Paths (platform-agnostic: auto-detect from script location) ----
_SCRIPT_DIR = Path(__file__).resolve().parent                    # gateway/scripts/
_GATEWAY_DIR = _SCRIPT_DIR.parent                                # gateway/
_PROJECT_DIR = _GATEWAY_DIR.parent                               # AgentsMeeting/
TEMPLATES_DIR = _SCRIPT_DIR / "templates"
CONFIG_DIR = _PROJECT_DIR / "config"
LOGS_DIR = _GATEWAY_DIR / "logs"
TEMP_DIR = _GATEWAY_DIR / "temp"
XMPP_BRIDGE_URL = os.environ.get("XMPP_BRIDGE_URL", "http://192.168.1.16:5807")
EJABBERD_HOST = os.environ.get("EJABBERD_HOST", "192.168.1.246")

# On Linux (mohe), dashboard runs from systemd with different paths. Support explicit override.
_PROJECT_OVERRIDE = os.environ.get("AGENTSMEETING_ROOT")
if _PROJECT_OVERRIDE:
    _PROJECT_DIR = Path(_PROJECT_OVERRIDE)
    _GATEWAY_DIR = _PROJECT_DIR / "gateway"
    TEMPLATES_DIR = _GATEWAY_DIR / "scripts" / "templates"
    CONFIG_DIR = _PROJECT_DIR / "config"
    LOGS_DIR = _GATEWAY_DIR / "logs"
    TEMP_DIR = _GATEWAY_DIR / "temp"

# Auto-recovery script name map (used by /api/agents)
SCRIPT_NAMES = {
    "xmpp_bot": "xmpp_bot.py",
    "wechat_bridge": "wechat_agent.py",
    "api_proxy": "api_proxy.py",
    "health_check": "health_check_xxm.py",
    "mohe_watcher": "mohe_watcher.py",
    "watchdog": "xmpp_watchdog.py",
}

sys.path.insert(0, str(_GATEWAY_DIR / "scripts"))
from proc_guard import guard

app = Flask(__name__, template_folder=str(TEMPLATES_DIR))

# ---- Logging ----
LOG_FILE = LOGS_DIR / "dashboard.log"
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
    filename=str(LOG_FILE),
    level=logging.INFO,
    format="%(asctime)s [dashboard] %(message)s",
)
log = logging.getLogger("dashboard")

# ---- Constants ----
AGENTS_YAML = CONFIG_DIR / "agents.yaml"
PYTHON = os.environ.get("PYTHON", sys.executable)
SCRIPTS_DIR = _GATEWAY_DIR / "scripts"

# Linux agents on local 246 — use systemctl
LOCAL_LINUX_HOSTS = ("192.168.1.246", "127.0.0.1", "localhost")


def _is_local_linux(agent):
    """Check if agent is on the same Linux machine (manageable via systemctl)."""
    return agent.get("platform") == "linux" and agent.get("host", "") in LOCAL_LINUX_HOSTS


def _agent_bot_service(agent):
    """Derive systemd service name from agent JID localpart (e.g. mohe → xmpp-mohe)."""
    jid = agent.get("jid", "")
    localpart = jid.split("@")[0] if "@" in jid else ""
    return f"xmpp-{localpart}" if localpart else ""


# Auto-recovery: restart after this many consecutive offline checks
AUTO_RECOVER_THRESHOLD = 3
_offline_counter: dict[str, int] = {}


# ============================================================
#  Config
# ============================================================

def load_agents_config():
    if AGENTS_YAML.exists():
        import yaml
        with open(AGENTS_YAML, "r", encoding="utf-8") as f:
            return yaml.safe_load(f).get("agents", [])
    return _default_agents()


def _default_agents():
    return [
        {
            "id": "agent-001", "name": "R&D Assistant", "display_name": "xxm",
            "jid": "xxm@yoin.fun", "platform": "windows", "host": "192.168.1.16",
            "bot_type": "xmpp", "provider": "volcengine",
            "services": [{"type": "xmpp_bot", "port": 5807}],
        },
        {
            "id": "agent-002", "name": "Automation Manager", "display_name": "mohe",
            "jid": "mohe@yoin.fun", "platform": "linux", "host": "192.168.1.246",
            "bot_type": "hermes", "provider": "ocg-new",
            "services": [{"type": "hermes_gateway", "port": 8646}, {"type": "xmpp_bot"}],
        },
        {
            "id": "agent-004", "name": "Position Analyst", "display_name": "zhiwei",
            "jid": "zhiwei@yoin.fun", "platform": "linux", "host": "192.168.1.246",
            "bot_type": "hermes", "provider": "ocg-old",
            "services": [{"type": "hermes_gateway", "port": 8643}, {"type": "xmpp_bot"}],
        },
    ]


# ============================================================
#  Cross-platform monitoring (XMPP + SSH)
# ============================================================

def _xmpp_health():
    """Query xmpp_bot /health for XMPP connection and ejabberd status."""
    try:
        req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/health")
        with urllib.request.urlopen(req, timeout=5) as resp:
            return json.loads(resp.read())
    except Exception as e:
        return {"ok": False, "error": str(e), "xmpp_connected": False, "ejabberd_alive": False}


def _ejabberd_online_jids():
    """Query ejabberdctl connected_users.
    On Linux (local): direct docker exec.
    On Windows (remote): SSH to EJABBERD_HOST.
    Returns set of bare JIDs currently connected to ejabberd."""
    try:
        if sys.platform == "win32":
            # Windows: SSH to Linux server
            ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
                       "-o", "BatchMode=yes", f"hmo@{EJABBERD_HOST}",
                       "docker exec ejabberd ejabberdctl connected_users"]
            result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=15)
        else:
            # Linux: direct docker exec
            result = subprocess.run(
                ["docker", "exec", "ejabberd", "ejabberdctl", "connected_users"],
                capture_output=True, text=True, timeout=10)
        if result.returncode != 0:
            log.debug(f"ejabberdctl failed (exit={result.returncode}): {result.stderr[:100]}")
            return set()
        jids = set()
        for line in result.stdout.strip().split("\n"):
            line = line.strip()
            if line and "@" in line:
                jids.add(line.split("/")[0])
        return jids
    except Exception as e:
        log.debug(f"ejabberd query failed: {e}")
        return set()


def _muc_participants():
    """Fallback: query xmpp_bot /muc for room participants.
    Currently unreliable due to MUC join timeout (R01)."""
    try:
        req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/muc")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
    except Exception:
        return set()
    participants = set()
    for room_data in data.get("rooms", {}).values():
        for p in room_data.get("participants", []):
            jid = p.get("jid", "")
            if jid:
                participants.add(jid)
            nick = p.get("nick", "")
            if nick and "@" in nick:
                participants.add(nick)
    return participants


# ============================================================
#  Local process detection (Windows only)
# ============================================================

def _get_local_processes():
    processes = []
    try:
        result = subprocess.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
        for line in result.stdout.split("\n"):
            if "xmpp_bot" in line or "wechat_agent" in line or "dashboard" in line:
                parts = line.split()
                if len(parts) >= 11:
                    processes.append({"pid": int(parts[1]), "cmdline": " ".join(parts[10:])})
    except Exception as e:
        log.error(f"Process scan failed: {e}")
    return processes


# ============================================================
#  Log helpers
# ============================================================

def _tail_logs(max_lines=50):
    all_lines = []
    log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
    for lf in log_files:
        try:
            with open(lf, "r", encoding="utf-8", errors="replace") as f:
                f.seek(0, os.SEEK_END)
                size = f.tell()
                read_size = min(size, max_lines * 500)
                if read_size > 0:
                    f.seek(max(0, size - read_size))
                    for line in f.read().strip().split("\n"):
                        if line.strip():
                            all_lines.append(f"[{lf.name}] {line}")
        except Exception:
            pass
    return all_lines[-max_lines:] if len(all_lines) > max_lines else all_lines


def _count_recent_messages(minutes=5):
    log_path = LOGS_DIR / "xmpp_bot.log"
    if not log_path.exists():
        return 0
    try:
        with open(log_path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
        cutoff = datetime.now() - timedelta(minutes=minutes)
        count = 0
        pattern = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
        for line in reversed(lines):
            m = pattern.search(line)
            if m:
                try:
                    if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < cutoff:
                        break
                    count += 1
                except ValueError:
                    pass
        return count
    except Exception:
        return 0


# ============================================================
#  Routes
# ============================================================

@app.route("/")
def index():
    return send_from_directory(str(TEMPLATES_DIR), "dashboard.html")


@app.route("/api/agents")
def api_agents():
    agents_config = load_agents_config()
    local_procs = _get_local_processes()
    message_count = _count_recent_messages(5)

    # Primary: SSH ejabberdctl for cross-platform presence
    online_jids = _ejabberd_online_jids()
    if not online_jids:
        online_jids = _muc_participants()  # fallback

    result = []
    for agent in agents_config:
        agent_id = agent["id"]
        jid = agent.get("jid", "")
        platform = agent.get("platform", "")
        host = agent.get("host", "")

        # --- Presence ---
        ejabberd_query_ok = online_jids is not None and len(online_jids) > 0
        xmpp_in_ejabberd = jid in online_jids if online_jids else None

        # --- Local process (Windows only) ---
        local_pid = None
        xmpp_connected = False
        if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
            for proc in local_procs:
                if "xmpp_bot.py" in proc.get("cmdline", ""):
                    local_pid = proc["pid"]
                    break
            health = _xmpp_health()
            xmpp_connected = health.get("xmpp_connected", False)

        # --- Status reason ---
        status_reason = ""
        if xmpp_in_ejabberd is None and not online_jids:
            status_reason = "ejabberd SSH 查询失败，MUC 降级检测也无结果"
        elif xmpp_in_ejabberd is False:
            status_reason = "XMPP JID 未登录 ejabberd"
        elif xmpp_in_ejabberd is None:
            status_reason = "无法检测 XMPP 状态"
        elif xmpp_in_ejabberd is True:
            status_reason = "OK"
        if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
            if status_reason == "OK":
                status_reason = "OK"
            elif xmpp_connected:
                status_reason = "Bot 运行中，但 XMPP 未登录 ejabberd"
            elif local_pid:
                status_reason = "Bot 进程存在但 XMPP 连接断开"
            else:
                status_reason = "Bot 进程不存在"

        # --- Service status ---
        services = []
        for svc in agent.get("services", []):
            svc_type = svc.get("type", "")
            svc_port = svc.get("port")

            if svc_type == "xmpp_bot":
                if platform == "windows" and local_pid:
                    svc_status = "running" if xmpp_connected else "degraded"
                    svc_pid = local_pid
                elif xmpp_in_ejabberd is True:
                    svc_status = "running"
                    svc_pid = None
                elif xmpp_in_ejabberd is False:
                    svc_status = "stopped"
                    svc_pid = None
                else:
                    svc_status = "unknown"
                    svc_pid = None
            elif svc_type in ("hermes_gateway", "omlx_server"):
                svc_status = "unknown"
                svc_pid = None
            else:
                svc_status = "stopped"
                svc_pid = None
                for proc in local_procs:
                    script_name = SCRIPT_NAMES.get(svc_type, "")
                    if script_name and script_name in proc.get("cmdline", ""):
                        svc_status = "running"
                        svc_pid = proc["pid"]
                        break

            services.append({
                "type": svc_type,
                "port": svc_port,
                "status": svc_status,
                "pid": svc_pid,
            })

        # --- Overall status ---
        if xmpp_in_ejabberd is True:
            status = "online"
        elif xmpp_in_ejabberd is False:
            status = "offline"
        elif platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
            if xmpp_connected:
                status = "online"
            elif local_pid:
                status = "degraded"
            else:
                status = "offline"
        else:
            status = "unknown"

        # --- Auto-recovery ---
        if status == "offline" and (platform == "windows" or (platform == "linux" and host in LOCAL_LINUX_HOSTS)):
            _offline_counter[agent_id] = _offline_counter.get(agent_id, 0) + 1
            if _offline_counter[agent_id] >= AUTO_RECOVER_THRESHOLD:
                log.warning(f"Auto-recovery: restarting {agent_id}")
                _try_auto_recover(agent)
        else:
            _offline_counter[agent_id] = 0

        result.append({
            "id": agent_id,
            "name": agent.get("name", ""),
            "display_name": agent.get("display_name", ""),
            "jid": jid,
            "platform": platform,
            "host": host,
            "status": status,
            "status_reason": status_reason,
            "xmpp_connected": xmpp_connected,
            "pid": local_pid,
            "last_message": None,
            "message_count_5min": message_count,
            "errors": 0,
            "offline_checks": _offline_counter.get(agent_id, 0),
            "restartable": (platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost")) or
                          (platform == "linux" and host in LOCAL_LINUX_HOSTS),
            "services": services,
        })

    return jsonify(result)


def _try_auto_recover(agent):
    agent_id = agent["id"]
    platform = agent.get("platform", "")
    host = agent.get("host", "")

    # Linux local → systemctl start
    if platform == "linux" and host in LOCAL_LINUX_HOSTS:
        svc = _agent_bot_service(agent)
        if not svc:
            return
        try:
            subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
            log.info(f"Auto-restarted {svc} for {agent_id}")
        except Exception as e:
            log.error(f"Auto-restart failed for {svc}: {e}")
        return

    # Windows local → subprocess
    if platform != "windows" or host not in ("192.168.1.16", "127.0.0.1", "localhost"):
        return
    for svc in agent.get("services", []):
        script_name = SCRIPT_NAMES.get(svc.get("type", ""))
        if not script_name:
            continue
        script_path = SCRIPTS_DIR / script_name
        if not script_path.exists():
            continue
        try:
            subprocess.Popen(
                [PYTHON, str(script_path)],
                cwd=str(SCRIPTS_DIR),
                creationflags=subprocess.CREATE_NO_WINDOW,
            )
            log.info(f"Auto-restarted {script_name} for {agent_id}")
        except Exception as e:
            log.error(f"Auto-restart failed: {e}")


@app.route("/api/ejabberd")
def api_ejabberd():
    health = _xmpp_health()
    online_jids = _ejabberd_online_jids()
    return jsonify({
        "alive": len(online_jids) > 0,
        "xmpp_bot_connected": health.get("xmpp_connected", False),
        "online_jids": sorted(list(online_jids)) if online_jids else [],
        "bot_jid": health.get("bot_jid", ""),
    })


@app.route("/api/agents/<agent_id>/logs")
def api_agent_logs(agent_id):
    lines = request.args.get("lines", 50, type=int)
    return jsonify({"lines": _tail_logs(lines)})


@app.route("/api/agents/<agent_id>/start", methods=["POST"])
def api_agent_start(agent_id):
    agents_config = load_agents_config()
    agent = next((a for a in agents_config if a["id"] == agent_id), None)
    if not agent:
        return jsonify({"ok": False, "error": "Agent not found"}), 404

    # Linux local → systemctl start
    if _is_local_linux(agent):
        svc = _agent_bot_service(agent)
        if not svc:
            return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
        try:
            r = subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
            if r.returncode == 0:
                _offline_counter[agent_id] = 0
                log.info(f"Started {svc} for {agent_id}")
                return jsonify({"ok": True, "service": svc})
            else:
                err = r.stderr.decode().strip()
                return jsonify({"ok": False, "error": err}), 500
        except Exception as e:
            return jsonify({"ok": False, "error": str(e)}), 500

    # Windows local → subprocess
    if agent.get("platform") != "windows":
        return jsonify({"ok": False, "error": "Remote restart not supported yet"}), 400
    started = []
    for svc in agent.get("services", []):
        script_name = SCRIPT_NAMES.get(svc.get("type", ""))
        if not script_name:
            continue
        script_path = SCRIPTS_DIR / script_name
        if not script_path.exists():
            continue
        try:
            subprocess.Popen(
                [PYTHON, str(script_path)],
                cwd=str(SCRIPTS_DIR),
                creationflags=subprocess.CREATE_NO_WINDOW,
            )
            started.append(script_name)
            log.info(f"Started {script_name} for {agent_id}")
        except Exception as e:
            log.error(f"Failed to start {script_name}: {e}")
    _offline_counter[agent_id] = 0
    return jsonify({"ok": True, "started": started})


@app.route("/api/agents/<agent_id>/stop", methods=["POST"])
def api_agent_stop(agent_id):
    agents_config = load_agents_config()
    agent = next((a for a in agents_config if a["id"] == agent_id), None)
    if not agent:
        return jsonify({"ok": False, "error": "Agent not found"}), 404

    # Linux local → systemctl stop
    if _is_local_linux(agent):
        svc = _agent_bot_service(agent)
        if not svc:
            return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
        try:
            r = subprocess.run(["sudo", "systemctl", "stop", svc], capture_output=True, timeout=15)
            if r.returncode == 0:
                log.info(f"Stopped {svc} for {agent_id}")
                return jsonify({"ok": True, "service": svc})
            else:
                err = r.stderr.decode().strip()
                return jsonify({"ok": False, "error": err}), 500
        except Exception as e:
            return jsonify({"ok": False, "error": str(e)}), 500

    # Windows local → taskkill
    if agent.get("platform") != "windows":
        return jsonify({"ok": False, "error": "Remote stop not supported yet"}), 400
    processes = _get_local_processes()
    stopped = []
    for svc in agent.get("services", []):
        script_name = SCRIPT_NAMES.get(svc.get("type", ""))
        if not script_name:
            continue
        for proc in processes:
            if script_name in proc.get("cmdline", ""):
                pid = proc["pid"]
                try:
                    subprocess.run(["taskkill", "/f", "/pid", str(pid)], capture_output=True)
                    stopped.append({"script": script_name, "pid": pid})
                except Exception as e:
                    log.error(f"Failed to stop {script_name}: {e}")
    return jsonify({"ok": True, "stopped": stopped})


@app.route("/api/agents/<agent_id>/restart", methods=["POST"])
def api_agent_restart(agent_id):
    api_agent_stop(agent_id)
    time.sleep(2)
    return api_agent_start(agent_id)


PLATFORM_SERVICES = [
    {"id": "wechat_bridge", "name": "莫荷微信 (Linux :3001)", "type": "ChannelBridge",
     "desc": "246 Docker wechatbot-webhook → 微信收发 + webhook 触发 → Hermes Gateway",
     "host": "192.168.1.246", "port": 3001},
    {"id": "article_processor", "name": "文章抓取服务 (5810)", "type": "wechat-fetch",
     "desc": "fetches wechat article content + OCR (DrissionPage)",
     "host": "192.168.1.16", "health_url": "http://192.168.1.16:5810/health"},
]


def _docker_session_failed():
    """Check docker logs for recent loginCheck errors (WeChat session expired silently)."""
    try:
        dr = subprocess.run(
            ["docker", "logs", "wxBotWebhook", "--since", "15m"],
            capture_output=True, text=True, timeout=5
        )
        logs = dr.stdout + dr.stderr
        err_lines = [l for l in logs.split("\n") if "loginCheck" in l and "Error" in l]
        return len(err_lines) >= 3
    except Exception:
        return False
    return False


def _docker_login_check():
    """Check docker logs for recent WeChat login. Returns datetime or None."""
    try:
        dr = subprocess.run(
            ["docker", "logs", "wxBotWebhook", "--since", "120m"],
            capture_output=True, text=True, timeout=5
        )
        for line in (dr.stdout + dr.stderr).split("\n"):
            ansi_re = re.compile(r'\x1b\[[0-9;]*m')
            clean = ansi_re.sub('', line)
            m = re.match(r"^\[?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", clean)
            if m and "logged in" in line:
                try:
                    return datetime.strptime(m.group(1)[:19], "%Y-%m-%dT%H:%M:%S")
                except ValueError:
                    pass
    except Exception:
        pass
    return None


def _augment_wechat_status(entry):
    """Add real login status and QR code to wechat_bridge platform entry."""
    import socket as _sock
    now = datetime.now()
    entry["login_ok"] = False
    entry["message"] = ""
    entry["qr_url"] = None
    entry["qr_timestamp"] = None
    entry["session_age_hours"] = 0

    # 1. Fetch QR code (always available)
    try:
        req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
        html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
        m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
        if m:
            entry["qr_url"] = m.group(1)
            entry["qr_timestamp"] = now.isoformat()
    except Exception:
        pass

    # 2. Check webhook log for real login status
    webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
    try:
        r = subprocess.run(["tail", "-200", webhook_log],
                          capture_output=True, text=True, timeout=5)
        lines = r.stdout.strip().split("\n") if r.stdout else []
        last_ok, last_err, last_login, last_logout = None, None, None, None
        for line in reversed(lines):
            ts_match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
            ts = ts_match.group(1) if ts_match else None
            if "WeChat send OK" in line and ts and not last_ok:
                last_ok = ts
            elif "WeChat send error" in line and ts and not last_err:
                last_err = ts
            if last_ok and last_err:
                break
        # Login/logout events: search full log (not just tail -200)
        try:
            for ev, key in (("system_event_logout", "last_logout"), ("system_event_login", "last_login")):
                er = subprocess.run(["grep", ev, webhook_log],
                                   capture_output=True, text=True, timeout=5)
                ev_lines = [l for l in er.stdout.strip().split("\n") if l.strip()]
                if ev_lines:
                    m = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", ev_lines[-1])
                    if m:
                        if key == "last_login":
                            last_login = m.group(1)
                        else:
                            last_logout = m.group(1)
        except Exception:
            pass

        ok_dt = err_dt = logout_dt = None
        if last_ok:
            ok_dt = datetime.strptime(last_ok, "%Y-%m-%d %H:%M:%S")
            entry["session_age_hours"] = round((now - ok_dt).total_seconds() / 3600, 1)
        if last_err:
            err_dt = datetime.strptime(last_err, "%Y-%m-%d %H:%M:%S")
        if last_logout:
            logout_dt = datetime.strptime(last_logout, "%Y-%m-%d %H:%M:%S")

        # ═══ 检测"正在等待扫码"状态 ═══
        # 如果 docker 日志最近显示二维码, 说明可能未登录等待扫码。
        # 注意: docker 日志是追加的 — 登录成功后旧二维码文本仍在 5min 窗口内,
        # 因此必须先确认"没有登录成功"再判定为等待扫码（登录成功是最高优先级）。
        try:
            _dr = subprocess.run(
                ["docker", "logs", "wxBotWebhook", "--since", "5m"],
                capture_output=True, text=True, timeout=5
            )
            _dlogs = _dr.stdout + _dr.stderr
            _logged_in = "logged in" in _dlogs
            _has_msg = "Message:" in _dlogs
            _showing_qr = ("扫描以下二维码" in _dlogs) or ("Access the URL to login" in _dlogs)
            _login_err = _dlogs.count("loginCheck") >= 2
        except Exception:
            _logged_in = False
            _has_msg = False
            _showing_qr = False
            _login_err = False
        if _logged_in or _has_msg:
            # 登录成功 / 正在收发消息 = 已登录（覆盖二维码残留文本的误判）
            entry["login_ok"] = True
            entry["status"] = "running"
            entry["message"] = "已登录"
            _done_flag = True
        elif _showing_qr or _login_err:
            entry["login_ok"] = False
            entry["status"] = "logged_out"
            entry["message"] = "等待扫码登录" if _showing_qr else "会话失效(loginCheck错误)"
            entry["qr_url"] = entry.get("qr_url") or ""
            _done_flag = True
        else:
            _done_flag = False

        # Check docker logs for recent login (catches fresh logins before webhook sees them)
        # Check webhook log for login event
        login_dt = None
        if last_login:
            try:
                login_dt = datetime.strptime(last_login, "%Y-%m-%d %H:%M:%S")
            except ValueError:
                pass
        dlogin_dt = _docker_login_check()
        # Session-failed: loginCheck errors = WeChat session expired silently
        if _docker_session_failed():
            entry["login_ok"] = False
            entry["message"] = "会话失效(loginCheck错误)"
            entry["status"] = "logged_out"
        # Logout is the most authoritative signal: if logout happened after the last login, session is dead
        if _done_flag:
            pass
        elif logout_dt and login_dt and logout_dt > login_dt:
            entry["login_ok"] = False
            h = round((now - logout_dt).total_seconds() / 3600, 1)
            entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
            entry["status"] = "logged_out"
        elif logout_dt and dlogin_dt and logout_dt > dlogin_dt:
            entry["login_ok"] = False
            h = round((now - logout_dt).total_seconds() / 3600, 1)
            entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
            entry["status"] = "logged_out"
        elif err_dt and login_dt and login_dt > err_dt:
            entry["login_ok"] = True
            entry["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
            h = entry["session_age_hours"]
            entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
            entry["status"] = "running"
        elif err_dt and dlogin_dt and dlogin_dt > err_dt:
            entry["login_ok"] = True
            entry["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
            h = entry["session_age_hours"]
            entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
            entry["status"] = "running"
        elif err_dt and ok_dt and err_dt > ok_dt:
            entry["login_ok"] = False
            h = round((now - err_dt).total_seconds() / 3600, 1)
            entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
            entry["status"] = "logged_out"
        elif err_dt and not ok_dt:
            entry["login_ok"] = False
            h = round((now - err_dt).total_seconds() / 3600, 1)
            entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
            entry["status"] = "logged_out"
        elif ok_dt:
            entry["login_ok"] = True
            h = entry["session_age_hours"]
            entry["message"] = f"已登录 {h:.0f}h"
        else:
            entry["message"] = "状态未知"
    except Exception:
        entry["message"] = "无法检测"

@app.route("/api/platform")
def api_platform():
    """Return platform services status by querying health endpoints."""
    import urllib.request as _ur
    result = []
    for ps in PLATFORM_SERVICES:
        status = "stopped"
        health_data = None
        health_url = ps.get("health_url", "")
        if health_url:
            try:
                req = _ur.Request(health_url)
                with _ur.urlopen(req, timeout=3) as resp:
                    body = resp.read()
                    status = "running"
                    try:
                        health_data = json.loads(body)
                    except Exception:
                        pass
            except Exception:
                status = "stopped"
        elif "port" in ps:
            import socket
            try:
                s = socket.socket()
                s.settimeout(2)
                s.connect((ps.get("host", "127.0.0.1"), ps["port"]))
                s.close()
                status = "running"
            except Exception:
                status = "stopped"
        entry = {
            "id": ps["id"],
            "name": ps["name"],
            "type": ps["type"],
            "desc": ps["desc"],
            "status": status,
        }
        if health_data:
            entry["health_data"] = health_data
        result.append(entry)

    # Augment wechat_bridge with real login status + QR code
    for entry in result:
        if entry["id"] == "wechat_bridge":
            _augment_wechat_status(entry)
            break

    return jsonify(result)


@app.route("/api/service/5810/logs")
def api_article_processor_logs():
    """Proxy logs from article-processor service on Windows."""
    try:
        req = urllib.request.Request("http://192.168.1.16:5810/logs?lines=200")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
        lines = data.get("lines", [])
        response = jsonify({"lines": lines})
        response.headers["Access-Control-Allow-Origin"] = "*"
        return response
    except Exception as e:
        response = jsonify({"lines": ["<error: {}>".format(str(e))], "error": True})
        response.headers["Access-Control-Allow-Origin"] = "*"
        return response


@app.route("/api/kanban")
def api_kanban():
    """Return kanban tasks from shared Hermes kanban.db."""
    kanban_db = Path(os.path.expanduser("~/.hermes/kanban.db"))
    if not kanban_db.exists():
        return jsonify({"tasks": [], "db_exists": False})
    try:
        db = sqlite3.connect(str(kanban_db))
        rows = db.execute(
            "SELECT id, title, body, status, assignee, created_by, created_at FROM tasks ORDER BY created_at DESC"
        ).fetchall()
        db.close()
        tasks = []
        for r in rows:
            tasks.append({
                "id": r[0], "title": r[1], "body": r[2], "status": r[3],
                "assignee": r[4], "created_by": r[5], "created_at": r[6]
            })
        return jsonify({"tasks": tasks, "db_exists": True, "count": len(tasks)})
    except Exception as e:
        return jsonify({"error": str(e), "db_exists": True})


# ════════════════════════════════════════════════════════════
#  A — 源码管理可视化
# ════════════════════════════════════════════════════════════
@app.route("/api/git")
def api_git():
    """最近 git 提交历史 + 分支状态（从 .git 直接读取，不依赖 git 命令）"""
    try:
        # 先用 _PROJECT_DIR（已处理 AGENTSMEETING_ROOT 覆盖），不行再探测常见路径
        candidates = [str(_PROJECT_DIR)]
        if _PROJECT_OVERRIDE:
            candidates.append(str(_PROJECT_DIR))
        # 246 生产环境常见路径
        if sys.platform != "win32":
            candidates.extend([
                "/home/hmo/projects/AgentsMeeting",
                os.path.expanduser("~/projects/AgentsMeeting"),
            ])
        git_dir = None
        for c in candidates:
            if os.path.exists(os.path.join(c, ".git")):
                git_dir = c
                break
        if not git_dir:
            return jsonify({"ok": True, "log": [], "dirty": False, "note": "未找到.git目录,请设置AGENTSMEETING_ROOT"})
        dot_git = os.path.join(git_dir, ".git")
        log_lines = []
        # 优先用 git log 命令（真实提交信息），降级到解析 reflog
        tried_git = False
        # Linux nohup 环境 PATH 可能不含 /usr/bin，必须用绝对路径
        GIT_CANDIDATES = [
            os.environ.get("GIT_CMD", ""),
            shutil.which("git") or "",
            "/usr/bin/git", "/usr/local/bin/git",
        ]
        git_bin = ""
        for c in GIT_CANDIDATES:
            if c and os.path.exists(c):
                git_bin = c
                break
        if git_bin:
            try:
                r = subprocess.run(
                    [git_bin, "log", "--format=%h|%ad|%s", "--date=format:%m-%d %H:%M", "--no-decorate"],
                    cwd=git_dir, capture_output=True, text=True, timeout=10)
                if r.returncode == 0 and r.stdout.strip():
                    tried_git = True
                    for line in r.stdout.strip().split("\n"):
                        line = line.strip()
                        if line:
                            parts = line.split("|", 2)
                            if len(parts) == 3:
                                log_lines.append(f"{parts[0]} {parts[1]} {parts[2]}")
                            else:
                                log_lines.append(line)
            except:
                pass
        if not tried_git:
            reflog = os.path.join(dot_git, "logs", "HEAD")
            if os.path.exists(reflog):
                with open(reflog, "r", encoding="utf-8", errors="replace") as f:
                    lines = f.readlines()
                for line in reversed(lines):
                    line = line.strip()
                    if not line:
                        continue
                    parts = line.split("\t")
                    msg = parts[-1] if len(parts) > 1 else line
                    hash_part = line.split()[1][:7] if len(line.split()) > 1 else "?"
                    # 从 reflog 提取时间戳: 第3+4字段 = unix时间 + 时区
                    ts = ""
                    try:
                        cols = line.split()
                        if len(cols) >= 5:
                            ts = datetime.fromtimestamp(int(cols[3])).strftime("%m-%d %H:%M") + " "
                    except:
                        pass
                    log_lines.append(f"{ts}{hash_part} {msg}")
        # 检查是否有未提交的改动
        dirty = False
        head_file = os.path.join(dot_git, "HEAD")
        index_file = os.path.join(dot_git, "index")
        if os.path.exists(head_file) and os.path.exists(index_file):
            try:
                dirty = os.path.getmtime(index_file) > os.path.getmtime(head_file)
            except:
                pass
        # 分支名
        branch = "master"
        try:
            with open(head_file, "r") as f:
                ref = f.read().strip()
            if ref.startswith("ref: refs/heads/"):
                branch = ref[16:]
        except:
            pass
        return jsonify({"ok": True, "log": log_lines, "dirty": dirty, "branch": branch, "source": git_dir, "method": ("git_log" if tried_git else "reflog")})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "log": [], "dirty": False})


# ════════════════════════════════════════════════════════════
#  B — 服务架构可视化
# ════════════════════════════════════════════════════════════
@app.route("/api/services")
def api_services():
    """服务功能树 + watchdog 覆盖矩阵"""
    try:
        services = []
        # xmpp_bot (跨平台：通过 XMPP_BRIDGE_URL 检测 Windows .16)
        xmpp_url = f"{XMPP_BRIDGE_URL}/health"
        svc1 = {"name": "xmpp_bot", "port": 5807, "health": _health_status(xmpp_url),
                "watchdog": True, "pid_lock": True, "type": "core", "depends_on": ["ejabberd"]}
        services.append(svc1)
        # article_processor (跨平台：通过 PLATFORM_SERVICES 中的 health_url)
        ap_url = "http://192.168.1.16:5810/health"
        svc2 = {"name": "article_processor", "port": 5810, "health": _health_status(ap_url),
                "watchdog": True, "pid_lock": False, "type": "bridge", "depends_on": []}
        services.append(svc2)
        # dashboard (本地)
        svc3 = {"name": "dashboard", "port": 5803, "health": _health_status("http://127.0.0.1:5803/api/health"),
                "watchdog": False, "pid_lock": True, "type": "mgmt", "depends_on": ["xmpp_bot"]}
        services.append(svc3)
        # ejabberd
        online = _ejabberd_online_jids()
        services.append({"name": "ejabberd", "port": 5222, "health": {"ok": len(online) > 0},
                         "watchdog": True, "pid_lock": False, "type": "infra", "depends_on": []})
        # remote gateways (R01 monitoring)
        g1 = {"name": "gateway_mohe", "port": 8646, "health": _health_status("http://192.168.1.246:8646/v1/health"),
              "watchdog": False, "pid_lock": False, "type": "gateway", "depends_on": ["ejabberd"]}
        services.append(g1)
        g2 = {"name": "gateway_zhiwei", "port": 8643, "health": _health_status("http://192.168.1.246:8643/v1/health"),
              "watchdog": False, "pid_lock": False, "type": "gateway", "depends_on": ["ejabberd"]}
        services.append(g2)
        # 统计
        watched = sum(1 for s in services if s.get("watchdog"))
        return jsonify({"ok": True, "services": services, "watched": watched, "total": len(services)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


def _health_status(url, timeout=3):
    try:
        r = urllib.request.urlopen(url, timeout=timeout)
        return {"ok": r.status == 200, "status": r.status, "detail": "ok"}
    except urllib.error.HTTPError as e:
        return {"ok": False, "status": e.code, "detail": f"HTTP {e.code}"}
    except Exception as e:
        return {"ok": False, "status": 0, "detail": str(e)[:50]}


# ════════════════════════════════════════════════════════════
#  C — 监控结果可视化
# ════════════════════════════════════════════════════════════
@app.route("/api/monitor")
def api_monitor():
    """Tier1/Tier2 最近检查结果 + 定时任务状态"""
    result = {"ok": True, "tier1": None, "tier2": None, "tasks": [], "platform": sys.platform}
    # Tier1 最新报告
    t1_file = TEMP_DIR / "last_health_check.json"
    if t1_file.exists():
        try:
            with open(str(t1_file)) as f:
                result["tier1"] = json.load(f)
        except:
            pass
    else:
        result["tier1"] = {"status": "no_data", "note": f"{t1_file} 不存在", "summary": {"ok": 0, "total": 0}}
    # Tier2 最新报告
    t2_file = TEMP_DIR / "last_daily_health.json"
    if t2_file.exists():
        try:
            with open(str(t2_file)) as f:
                result["tier2"] = json.load(f)
        except:
            pass
    else:
        result["tier2"] = {"status": "no_data", "note": f"{t2_file} 不存在", "summary": {"ok": 0, "total": 0}}
    # 定时任务状态
    expected_tasks = [
        ("agents-health-check", "agents_health_check.py"),
        ("agents-daily-health", "agents_daily_health.py"),
        ("agents-todo-executor", "self_todo_executor.py"),
    ]
    if sys.platform == "win32":
        for name, filename in expected_tasks:
            try:
                r = subprocess.run(["schtasks", "/Query", "/TN", name, "/FO", "CSV", "/NH"],
                                   capture_output=True, text=True, timeout=5,
                                   creationflags=subprocess.CREATE_NO_WINDOW)
                status = "ok" if ("Ready" in r.stdout or "\u5c31\u7eea" in r.stdout) else ("missing" if name not in r.stdout else "other")
            except:
                status = "error"
            result["tasks"].append({"name": name, "status": status})
    else:
        # Linux: 直接检查本地 crontab（不在 Windows 上跑了）
        for name, filename in expected_tasks:
            try:
                r = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
                if filename in r.stdout:
                    result["tasks"].append({"name": name, "status": "cron_ok"})
                else:
                    result["tasks"].append({"name": name, "status": "not_deployed"})
            except Exception as e:
                result["tasks"].append({"name": name, "status": "not_deployed", "note": str(e)[:60]})
    return jsonify(result)


# ════════════════════════════════════════════════════════════
#  E — 元成长回路可视化
# ════════════════════════════════════════════════════════════
@app.route("/api/metagrowth")
def api_metagrowth():
    """元成长回路（Phase 3 规划中）"""
    try:
        # 统计已完成的修复次数
        todo_file = TEMP_DIR / "health_todos.jsonl"
        total_fixes = 0
        completed_fixes = 0
        if todo_file.exists():
            with open(str(todo_file), encoding="utf-8-sig") as f:
                for line in f:
                    line = line.strip()
                    if line:
                        total_fixes += 1
                        try:
                            entry = json.loads(line)
                        except json.JSONDecodeError:
                            continue
                        if entry.get("status") == "completed":
                            completed_fixes += 1
        # 统计 git 提交次数（从 reflog 行数计算，跟 api_git 同样路径探测逻辑）
        commit_count = 0
        git_candidates = [str(_PROJECT_DIR)]
        if sys.platform != "win32":
            git_candidates.extend(["/home/hmo/projects/AgentsMeeting",
                                   os.path.expanduser("~/projects/AgentsMeeting")])
        for c in git_candidates:
            rfl = os.path.join(c, ".git", "logs", "HEAD")
            if os.path.exists(rfl):
                try:
                    with open(rfl, "r", encoding="utf-8", errors="replace") as f:
                        commit_count = sum(1 for line in f if line.strip())
                except:
                    pass
                break
        return jsonify({
            "ok": True,
            "status": "planned",  # Phase 3
            "total_fixes": total_fixes,
            "completed_fixes": completed_fixes,
            "commit_count": commit_count,
            "meta_growth_enabled": False,
            "description": "E 元成长回路是 Phase 3 范围。启用后将自动分析修复模式并扩展检查规则。",
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


# ════════════════════════════════════════════════════════════
#  D — 自修复流水线可视化
# ════════════════════════════════════════════════════════════
@app.route("/api/todos")
def api_todos():
    """TODO 执行记录 + escalation 日志"""
    todos = []
    todo_file = TEMP_DIR / "health_todos.jsonl"
    if todo_file.exists():
        try:
            with open(str(todo_file)) as f:
                for line in f:
                    line = line.strip()
                    if line:
                        todos.append(json.loads(line))
        except:
            pass
    # executor 日志最新行
    executor_log = LOGS_DIR / "todo_executor.log"
    executor_tail = ""
    if executor_log.exists():
        try:
            lines = open(str(executor_log)).read().strip().split("\n")
            executor_tail = "\n".join(lines[-5:])
        except:
            pass
    return jsonify({"todos": todos, "executor_log_tail": executor_tail, "count": len(todos)})


@app.route("/api/autoheal")
def api_autoheal():
    """最近一次自动修复记录（auto_heal.py 写入的摘要）。"""
    sf = TEMP_DIR / "last_auto_heal.json"
    if sf.exists():
        try:
            with open(str(sf)) as f:
                return jsonify(json.load(f))
        except:
            pass
    return jsonify({"time": None, "dry_run": False, "total_expected": 0, "anomalies_found": 0, "actions_taken": []})


# ════════════════════════════════════════════════════════════
#  F — 期望状态 vs 实际状态
# ════════════════════════════════════════════════════════════
@app.route("/api/expected")
def api_expected():
    """动态期望矩阵：从 agents.yaml + PLATFORM_SERVICES 生成，跨平台区分检测"""
    expected = []
    # 1. 从 agents.yaml 生成各 Agent 服务的期望
    agents = load_agents_config()
    for agent in agents:
        display = agent.get("display_name", agent["name"])
        host = agent.get("host", "127.0.0.1")
        bot_type = agent.get("bot_type", "xmpp")
        for svc in agent.get("services", []):
            svc_type = svc["type"]
            # hermes 协议 agent 不跑独立 xmpp_bot，跳过
            if bot_type == "hermes" and svc_type == "xmpp_bot":
                continue
            port = svc.get("port")
            # key 供前端 M 映射表查找唯一标签（实例级，不按类型折叠）
            key = f"{agent['id']}:{svc_type}"
            spec = svc_type  # spec 模块名，供 ?§ 按钮链接 specs/{module}.json
            entry = {"name": f"{display}({host}):{svc_type}", "key": key, "spec": spec, "host": host, "expected": "running", "critical": True}
            if port:
                entry["port"] = port
                entry["check"] = "tcp"
            elif svc_type == "xmpp_bot":
                entry["check"] = "xmpp"  # xmpp bot without port means ejabberd-connected
                entry["jid"] = agent.get("jid", "")
            else:
                entry["check"] = "agent_online"
            expected.append(entry)

    # 2. 从 PLATFORM_SERVICES 生成平台服务的期望
    for ps in PLATFORM_SERVICES:
        entry = {"name": f"{ps['name']} ({ps['id']})", "key": ps["id"], "spec": ps["id"], "host": ps.get("host", "127.0.0.1"), "expected": "running", "critical": True}
        if ps.get("health_url"):
            entry["health_url"] = ps["health_url"]
            entry["check"] = "health_url"
        elif ps.get("port"):
            entry["port"] = ps["port"]
            entry["check"] = "tcp"
        else:
            continue
        expected.append(entry)

    # 3. 固定定时任务期望
    expected.append({"name": "agents-health-check", "check": "scheduled", "expected": "scheduled", "critical": False})
    expected.append({"name": "agents-daily-health", "check": "scheduled", "expected": "scheduled", "critical": False})

    # ── 实际状态检测 ──
    actual = {}
    for e in expected:
        name = e["name"]
        host = e.get("host", "127.0.0.1")
        check = e.get("check", "unknown")

        if check == "scheduled":
            task_name = name
            if sys.platform == "win32":
                try:
                    r = subprocess.run(["schtasks", "/Query", "/TN", task_name, "/FO", "CSV", "/NH"],
                                       capture_output=True, text=True, timeout=5, creationflags=subprocess.CREATE_NO_WINDOW)
                    ready = "Ready" in r.stdout or "\u5c31\u7eea" in r.stdout
                    actual[name] = "scheduled" if ready else "missing"
                except:
                    actual[name] = "error"
            else:
                try:
                    r = subprocess.run(["systemctl", "list-timers", "--all", "--no-pager"],
                                       capture_output=True, text=True, timeout=5)
                    if task_name in r.stdout:
                        actual[name] = "timer_ok"
                    else:
                        r2 = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
                        actual[name] = "cron_ok" if task_name in r2.stdout else "not_deployed"
                except:
                    actual[name] = "not_deployed"

        elif check == "health_url":
            actual[name] = "running" if _health_status(e["health_url"])["ok"] else "stopped"

        elif check == "tcp":
            # 判断目标是否是本机
            is_local = _is_local_host(host)
            if is_local:
                actual[name] = "running" if port_open(e["port"], host) else "stopped"
            else:
                # 远程服务：状态由 /api/platform 精确检测，这里标记为远程引用
                actual[name] = "remote (见平台Tab)"

        elif check == "xmpp":
            # 无端口号的 xmpp_bot：通过 ejabberdctl 查 JID 是否在线
            jid = e.get("jid", "")
            if jid:
                online = _ejabberd_online_jids()
                actual[name] = "online" if jid in online else "offline"
            else:
                actual[name] = "unknown"
        elif check == "agent_online":
            actual[name] = "unknown"
        else:
            actual[name] = "unknown"

    return jsonify({"expected": expected, "actual": actual})


def _is_local_host(host):
    """判断目标主机是否是本机（Dashboard 运行所在的机器）。"""
    if host in ("127.0.0.1", "localhost", "::1"):
        return True
    # 246 是 Dashboard 的生产部署机器（同时运行 ejabberd）
    if host == EJABBERD_HOST:
        return True
    try:
        # 解析本机 hostname → IP，与 host 对比
        local_name = socket.gethostname()
        if host == local_name:
            return True
        local_ip = socket.gethostbyname(local_name)
        if host == local_ip:
            return True
    except:
        pass
    return False


def port_open(port, host="127.0.0.1"):
    """Check if a port is listening on a specific host."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(2)
        result = s.connect_ex((host, port))
        s.close()
        return result == 0
    except:
        return False


# EasyTier/RDP API key for xmpp_bot on Windows
_BRIDGE_KEY = "xxm_bridge_8f3a2c"

def _bridge_post(path, payload, timeout=15):
    """POST to xmpp_bot HTTP bridge on Windows."""
    req = urllib.request.Request(
        f"{XMPP_BRIDGE_URL}{path}",
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json", "X-Api-Key": _BRIDGE_KEY},
        method="POST")
    resp = urllib.request.urlopen(req, timeout=timeout)
    return json.loads(resp.read())


# ════════════════════════════════════════════════════════════
#  EasyTier VPN + RDP 远程桌面控制
# ════════════════════════════════════════════════════════════
@app.route("/api/easytier")
def api_easytier_status():
    """EasyTier VPN 状态 — proxy to xmpp_bot on Windows."""
    try:
        windows_running = False
        status_246 = "unknown"
        try:
            data = _bridge_post("/easytier", {"action": "status"}, timeout=5)
            windows_running = data.get("running", False)
            status_246 = "running" if data.get("running") else "stopped"
        except:
            pass
        return jsonify({
            "ok": True,
            "status": {"windows": "running" if windows_running else "stopped", "246": status_246},
            "virtual_ips": {"windows": "10.144.144.3", "246": "10.144.144.1"},
            "windows_running": windows_running,
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


@app.route("/api/easytier/toggle", methods=["POST"])
def api_easytier_toggle():
    """EasyTier VPN 开关 — proxy to xmpp_bot on Windows."""
    try:
        body = json.loads(request.data)
        action = body.get("action", "")
        if action not in ("start", "stop"):
            return jsonify({"ok": False, "error": "action must be start|stop"})
        data = _bridge_post("/easytier", {"action": action}, timeout=15)
        return jsonify({"ok": data.get("ok", False), "message": data.get("message", "")})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


@app.route("/api/rdp")
def api_rdp_status():
    """RDP 远程桌面状态 — proxy to xmpp_bot on Windows."""
    try:
        rdp_enabled = False
        tunnel_running = False
        try:
            data = _bridge_post("/rdp", {"action": "status"}, timeout=5)
            rdp_enabled = data.get("rdp_enabled", False)
            tunnel_running = data.get("tunnel_running", False)
        except:
            pass
        return jsonify({
            "ok": True,
            "rdp_enabled": rdp_enabled,
            "tunnel_running": tunnel_running,
            "rdp_port": 3389,
            "tunnel_port": 8080,
            "public_endpoint": "47.115.32.206:8080",
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


@app.route("/api/rdp/toggle", methods=["POST"])
def api_rdp_toggle():
    """RDP 远程桌面开关 — proxy to xmpp_bot on Windows."""
    try:
        body = json.loads(request.data)
        action = body.get("action", "")
        if action not in ("start", "stop"):
            return jsonify({"ok": False, "error": "action must be start|stop"})
        data = _bridge_post("/rdp", {"action": action}, timeout=15)
        return jsonify({"ok": data.get("ok", False), "message": data.get("message", "")})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


# ════════════════════════════════════════════════════════════
#  OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
#  See gateway/scripts/specs/usage_monitor.json
# ════════════════════════════════════════════════════════════
_USAGE_STATS_FILE = TEMP_DIR / "usage_stats.json"
_USAGE_COLLECTOR_SCRIPT = _SCRIPT_DIR / "usage_collector.py"
_USAGE_MONITOR_DIR = _SCRIPT_DIR / "usage_monitor"
_usage_collector_running = False

@app.route("/api/usage")
def api_usage():
    """读取 usage_stats.json，合并 Kimi 数据（如果存在）。"""
    accounts = []
    providers = {}

    # 1. 主 OCG 数据
    if _USAGE_STATS_FILE.exists():
        try:
            with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
                data = json.load(f)
            for a in data.get("accounts", []):
                a["provider"] = a.get("provider", "opencode_go")
                accounts.append(a)
                providers["opencode_go"] = providers.get("opencode_go", 0) + 1
        except Exception as e:
            log.warning(f"usage: read failed: {e}")

    # 2. Kimi 数据
    kimi_file = TEMP_DIR / "usage_stats_kimi.json"
    if kimi_file.exists():
        try:
            with open(str(kimi_file), "r", encoding="utf-8") as f:
                kimi_data = json.load(f)
            for a in kimi_data.get("accounts", []):
                a["provider"] = a.get("provider", "kimi")
                accounts.append(a)
                providers["kimi"] = providers.get("kimi", 0) + 1
        except Exception:
            pass

    # 3. SenseNova 数据（2026-07-23 新增：Token Plan 三模型余量）
    sn_file = TEMP_DIR / "usage_stats_sensenova.json"
    if sn_file.exists():
        try:
            with open(str(sn_file), "r", encoding="utf-8") as f:
                sn_data = json.load(f)
            for a in sn_data.get("accounts", []):
                a["provider"] = a.get("provider", "sensenova")
                accounts.append(a)
                providers["sensenova"] = providers.get("sensenova", 0) + 1
        except Exception:
            pass

    if not accounts:
        return jsonify({"ok": False, "error": "no data yet",
                        "last_refresh_iso": None, "accounts": [], "providers": {}})
    return jsonify({
        "ok": True,
        "last_refresh_iso": max((a.get("last_update_iso","") for a in accounts), default=None),
        "account_count": len(accounts),
        "collected_count": sum(1 for a in accounts if a.get("error") is None),
        "providers": providers,
        "accounts": accounts,
    })


@app.route("/api/usage/refresh", methods=["POST"])
def api_usage_refresh():
    """触发 OCG + Kimi 采集（异步 subprocess，立即返回）"""
    global _usage_collector_running
    if _usage_collector_running:
        return jsonify({"ok": True, "message": "collection already in-flight"})
    _usage_collector_running = True

    def _runner():
        global _usage_collector_running
        try:
            # 1. OCG 本地采集
            log.info("usage: starting OCG collector")
            proc = subprocess.run(
                [sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
                capture_output=True, text=True, timeout=120)
            log.info(f"usage: OCG done (rc={proc.returncode})")
            if proc.returncode != 0 and proc.stderr:
                log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}")

            # 2. Kimi 远程采集（SSH 到 Windows）+ scp 回 246
            try:
                _collect_kimi_usage()
            except Exception as e:
                log.warning(f"usage: Kimi collection failed: {e}")

            # 3. SenseNova 远程采集（SSH 到 Windows，CDP 刮 console 余量）
            try:
                _collect_sensenova_usage()
            except Exception as e:
                log.warning(f"usage: SenseNova collection failed: {e}")
        except Exception as e:
            log.error(f"usage: collector crash: {e}")
        finally:
            _usage_collector_running = False

    t = threading.Thread(target=_runner, name="usage_collector", daemon=True)
    t.start()
    return jsonify({"ok": True, "message": "collection triggered (~15s), poll /api/usage shortly"})


def _collect_kimi_usage():
    """SSH 到 Windows 跑 usage_collector_kimi.py，scp 结果回 246。

    被 /api/usage/refresh 和 usage auto-timer 共用，保证 Kimi 数据新鲜。
    注意: Windows 控制台默认 GBK 编码，SSH 输出需容错解码（bytes + errors=replace），
    否则中文日志会让 text=True 的 UTF-8 解码抛异常，导致 scp 步骤不执行。
    """
    log.info("usage: starting Kimi collector via SSH")
    kimi_cmd = [
        "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
        "-o", "BatchMode=yes", "hmo@192.168.1.16",
        r'cmd /c "set PYTHONIOENCODING=utf-8 && C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_kimi.py"'
    ]
    proc2 = subprocess.run(kimi_cmd, capture_output=True, timeout=60)
    log.info(f"usage: Kimi done (rc={proc2.returncode})")
    if proc2.returncode != 0 and proc2.stderr:
        # Windows 输出可能是 GBK，容错解码
        try:
            err_text = proc2.stderr.decode("utf-8", errors="replace")
        except Exception:
            err_text = repr(proc2.stderr[-300:])
        log.warning(f"usage_kimi stderr: {err_text[-300:]}")

    # scp Kimi 结果回 246
    scp_cmd = [
        "scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
        "hmo@192.168.1.16:D:/F/NewI/opencode/daily-workspace/projects/AgentsMeeting/gateway/temp/usage_stats_kimi.json",
        str(TEMP_DIR / "usage_stats_kimi.json")
    ]
    proc3 = subprocess.run(scp_cmd, capture_output=True, timeout=15)
    log.info(f"usage: Kimi scp done (rc={proc3.returncode})")


def _collect_sensenova_usage():
    """SSH 到 Windows 跑 usage_collector_sensenova.py，scp 结果回 246。"""
    log.info("usage: starting SenseNova collector via SSH")
    sn_cmd = [
        "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
        "-o", "BatchMode=yes", "hmo@192.168.1.16",
        r'cmd /c "set PYTHONIOENCODING=utf-8 && C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_sensenova.py"'
    ]
    proc4 = subprocess.run(sn_cmd, capture_output=True, timeout=90)
    log.info(f"usage: SenseNova done (rc={proc4.returncode})")
    if proc4.returncode != 0 and proc4.stderr:
        try:
            sn_err = proc4.stderr.decode("utf-8", errors="replace")
        except Exception:
            sn_err = repr(proc4.stderr[-300:])
        log.warning(f"usage_sensenova stderr: {sn_err[-300:]}")
    scp4 = subprocess.run([
        "scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
        "hmo@192.168.1.16:D:/F/NewI/opencode/daily-workspace/projects/AgentsMeeting/gateway/temp/usage_stats_sensenova.json",
        str(TEMP_DIR / "usage_stats_sensenova.json")
    ], capture_output=True, timeout=15)
    log.info(f"usage: SenseNova scp done (rc={scp4.returncode})")


# ── Key Registry ──────────────────────────────────────────────
_ACCOUNTS_FILE = _USAGE_MONITOR_DIR / "accounts.json"

@app.route("/api/keys")
def api_keys():
    """返回所有可用 API key（masked 首尾）及各自用量。
    合并 accounts.json（key 原文）和 usage_stats.json（用量数据）。
    """
    try:
        # 读 key 原文
        if not _ACCOUNTS_FILE.exists():
            return jsonify({"ok": False, "error": "accounts.json not found", "keys": []})
        with open(str(_ACCOUNTS_FILE), "r", encoding="utf-8") as f:
            accounts_data = json.load(f)
        accounts = accounts_data.get("accounts", [])

        # 读用量数据（OCG + Kimi）
        usage_by_kid = {}
        if _USAGE_STATS_FILE.exists():
            with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
                usage_data = json.load(f)
            for a in usage_data.get("accounts", []):
                usage_by_kid[a.get("key_id", "")] = a
        # Kimi 独立文件
        kimi_file = TEMP_DIR / "usage_stats_kimi.json"
        if kimi_file.exists():
            with open(str(kimi_file), "r", encoding="utf-8") as f:
                kimi_data = json.load(f)
            for a in kimi_data.get("accounts", []):
                usage_by_kid[a.get("key_id", "")] = a
        # SenseNova 独立文件（2026-07-23 新增）
        sn_file = TEMP_DIR / "usage_stats_sensenova.json"
        if sn_file.exists():
            with open(str(sn_file), "r", encoding="utf-8") as f:
                sn_data = json.load(f)
            for a in sn_data.get("accounts", []):
                usage_by_kid[a.get("key_id", "")] = a

        # 合并
        keys_out = []
        _registry_kids = set()
        for a in accounts:
            kid = a.get("key_id", "")
            _registry_kids.add(kid)
            raw_key = a.get("key", "")
            # Mask: 前8后4，不足12字符则不mask
            if len(raw_key) > 12:
                masked = raw_key[:8] + "..." + raw_key[-4:]
            else:
                masked = raw_key  # 短 key 不 mask

            entry = {
                "key_id": kid,
                "label": a.get("label", kid),
                "key_masked": masked,
                "provider": "kimi" if kid == "key7" else "opencode_go",
            }
            # 附加用量
            usage = usage_by_kid.get(kid)
            if usage:
                entry["provider"] = usage.get("provider", entry["provider"])
                entry["workspace_id"] = usage.get("workspace_id", "")
                for period in ["rolling", "weekly", "monthly"]:
                    p = usage.get(period)
                    if p:
                        entry[period] = {
                            "usage_percent": p.get("usage_percent"),
                            "reset_in_sec": p.get("reset_in_sec"),
                            "status": p.get("status", "unknown"),
                        }
                entry["session_expired"] = usage.get("session_expired", False)
                entry["last_update"] = usage.get("last_update_iso", "")
            keys_out.append(entry)

        # 非注册表的用量条目（如 SenseNova key8/9/10：同一账户的模型视角，
        # 不属于 OCG 注册表——2026-07-23 老爸：不要混进 OpenCode Go 卡片）
        for kid, usage in usage_by_kid.items():
            if kid in _registry_kids:
                continue
            entry = {
                "key_id": kid,
                "label": usage.get("label", kid),
                "key_masked": "(共享SenseNova账户)",
                "provider": usage.get("provider", "other"),
                "last_update": usage.get("last_update_iso", ""),
            }
            for period in ["rolling", "weekly", "monthly"]:
                p = usage.get(period)
                if p:
                    entry[period] = {
                        "usage_percent": p.get("usage_percent"),
                        "reset_in_sec": p.get("reset_in_sec"),
                        "status": p.get("status", "unknown"),
                    }
            keys_out.append(entry)

        return jsonify({
            "ok": True,
            "count": len(keys_out),
            "keys": keys_out,
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "keys": []})


# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
_USAGE_AUTO_INTERVAL = 300

def _start_usage_auto_timer():
    """后台线程：OCG 每 5 分钟自动采集；Kimi/SenseNova 每 30 分钟采集（SSH 到 Windows）。"""
    _KIMI_INTERVAL_MULT = 6  # 每 6 个 OCG 周期（30min）采一次 Kimi/SenseNova
    def _loop():
        time.sleep(15)  # initial delay: let dashboard stabilize
        tick = 0
        while True:
            try:
                global _usage_collector_running
                if not _usage_collector_running:
                    _usage_collector_running = True
                    proc = subprocess.run(
                        [sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
                        capture_output=True, text=True, timeout=120)
                    log.info(f"usage auto-timer: rc={proc.returncode}")
                    if proc.returncode != 0 and proc.stderr:
                        log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}")

                    # Kimi/SenseNova：每 30 分钟刷新一次（SSH 到 Windows + CDP，较重）
                    tick += 1
                    if tick % _KIMI_INTERVAL_MULT == 0:
                        try:
                            _collect_kimi_usage()
                        except Exception as e:
                            log.warning(f"usage auto-timer: Kimi failed: {e}")
                        try:
                            _collect_sensenova_usage()
                        except Exception as e:
                            log.warning(f"usage auto-timer: SenseNova failed: {e}")
                    _usage_collector_running = False
            except Exception as e:
                log.error(f"usage auto-timer error: {e}")
                _usage_collector_running = False
            time.sleep(_USAGE_AUTO_INTERVAL)

    t = threading.Thread(target=_loop, name="usage_auto_timer", daemon=True)
    t.start()
    log.info(f"usage auto-timer started (interval={_USAGE_AUTO_INTERVAL}s, kimi every {_KIMI_INTERVAL_MULT * _USAGE_AUTO_INTERVAL}s)")


# ════════════════════════════════════════════════════════════
#  Module Spec — 功能模块 co-located 文档
# ════════════════════════════════════════════════════════════
_SPECS_DIR = _SCRIPT_DIR / "specs"

@app.route("/api/module-spec/<module>")
def api_module_spec(module):
    """返回指定模块的 co-located spec JSON（人看 + AI 看）"""
    spec_file = _SPECS_DIR / f"{module}.json"
    if not spec_file.exists():
        return jsonify({"ok": False, "error": f"spec not found: {module}"})
    try:
        with open(spec_file, "r", encoding="utf-8") as f:
            return jsonify(json.load(f))
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


@app.route("/api/module-specs")
def api_module_specs():
    """列出所有可用的 module spec"""
    specs = []
    if _SPECS_DIR.exists():
        for p in sorted(_SPECS_DIR.glob("*.json")):
            try:
                with open(p, "r", encoding="utf-8") as f:
                    data = json.load(f)
                    specs.append({
                        "module": data.get("module", p.stem),
                        "purpose": data.get("purpose", ""),
                        "version": data.get("version", ""),
                    })
            except:
                pass
    return jsonify({"ok": True, "specs": specs})


# ════════════════════════════════════════════════════════════
#  Spec 文档展示
# ════════════════════════════════════════════════════════════
@app.route("/api/spec")
def api_spec():
    """返回开发规范文档内容"""
    # Try multiple possible locations
    candidates = [
        # Git-tracked copy in project docs/ (priority)
        os.path.join(_PROJECT_DIR, "docs", "dev-spec.md"),
        os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), "projects", "AgentsMeeting", "docs", "dev-spec.md"),
        # Legacy .memory/ locations (gitignored, no history)
        os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), ".memory", "dev-spec.md"),
        os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".memory", "dev-spec.md"),
        os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..", ".memory", "dev-spec.md"),
        os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), ".memory", "dev-spec.md"),
        os.path.expanduser("~/.memory/dev-spec.md"),
    ]
    spec_path = None
    for c in candidates:
        p = os.path.normpath(c)
        if os.path.exists(p):
            spec_path = p
            break
    if not spec_path:
        return jsonify({"ok": False, "error": "dev-spec.md not found", "content": ""})
    try:
        with open(spec_path, "r", encoding="utf-8") as f:
            content = f.read()
        return jsonify({"ok": True, "content": content, "path": spec_path})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "content": ""})


@app.route("/api/spec/history")
def api_spec_history():
    """返回 dev-spec.md 的 git 历史版本"""
    return _git_history_for(".memory/dev-spec.md")


@app.route("/api/prd/history")
def api_prd_history():
    """返回 PRD.md 的 git 历史版本"""
    return _git_history_for("docs/PRD.md")


def _git_history_for(filepath):
    """Helper: run git log for a file and return formatted history"""
    project_root = _PROJECT_DIR
    # For dev-spec.md, also check docs/ variant which is git-tracked
    if filepath == ".memory/dev-spec.md":
        filepath = "docs/dev-spec.md"
    try:
        cmd = ["git", "log", "--format=%h %ai %s", "--", filepath]
        result = subprocess.run(
            cmd,
            cwd=project_root,
            capture_output=True,
            text=True,
            timeout=15,
        )
        if result.returncode == 0 and result.stdout.strip():
            lines = result.stdout.strip().split("\n")
            return jsonify({"ok": True, "log": lines, "count": len(lines)})
        else:
            return jsonify({"ok": True, "log": [], "count": 0})
    except Exception as e:
        return jsonify({"ok": True, "log": [], "count": 0, "error": str(e)})


@app.route("/api/prd")
def api_prd():
    """返回产品需求文档 (PRD) 内容"""
    candidates = [
        os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "docs", "PRD.md"),
        os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "PRD.md"),
        # venv deployment: ~/projects/AgentsMeeting/docs/PRD.md
        os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), "projects", "AgentsMeeting", "docs", "PRD.md"),
        os.path.expanduser("~/projects/AgentsMeeting/docs/PRD.md"),
    ]
    prd_path = None
    for c in candidates:
        p = os.path.normpath(c)
        if os.path.exists(p):
            prd_path = p
            break
    if not prd_path:
        return jsonify({"ok": False, "error": "PRD.md not found", "content": ""})
    try:
        with open(prd_path, "r", encoding="utf-8") as f:
            content = f.read()
        return jsonify({"ok": True, "content": content, "path": prd_path})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "content": ""})


# ════════════════════════════════════════════════════════════
#  K — 自动测试接口
# ════════════════════════════════════════════════════════════
@app.route("/api/tests")
def api_tests():
    """运行自动测试并返回结果"""
    try:
        from tests_api import run_tests
        return jsonify(run_tests())
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


# ── WeChat Bridge Status ──────────────────────────────────────
@app.route("/api/wechat/status")
def api_wechat_status():
    """Returns real WeChat bridge login status + QR code."""
    import socket as _sock
    result = {"online": False, "message": "", "port_ok": False,
              "qr_url": None, "qr_timestamp": None,
              "session_age_hours": 0, "last_ok_at": None, "last_err_at": None}
    now = datetime.now()

    # 1. TCP check port 3001
    try:
        s = _sock.socket(); s.settimeout(2)
        s.connect(("127.0.0.1", 3001)); s.close()
        result["port_ok"] = True
    except Exception:
        result["message"] = "Docker container port 3001 unreachable"
        return jsonify(result)

    # 2. Fetch QR code from login page
    try:
        req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
        html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
        m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
        if m:
            result["qr_url"] = m.group(1)
            result["qr_timestamp"] = now.isoformat()
    except Exception as e:
        result["message"] = f"QR page fetch failed: {e}"

    # 3a. 优先检测: docker 日志是否正在显示登录二维码 (未登录等待扫码)
    try:
        _dr = subprocess.run(
            ["docker", "logs", "wxBotWebhook", "--since", "5m"],
            capture_output=True, text=True, timeout=5
        )
        _dlogs = _dr.stdout + _dr.stderr
        _showing_qr = ("扫描以下二维码" in _dlogs) or ("Access the URL to login" in _dlogs)
        _login_err = _dlogs.count("loginCheck") >= 2
        _logged_in = "logged in" in _dlogs
        _has_msg = "Message:" in _dlogs
    except Exception:
        _showing_qr = False
        _login_err = False
        _logged_in = False
        _has_msg = False
    # 登录成功是最高优先级: 有 logged in 或正在收发消息 = 已登录
    if _logged_in or _has_msg:
        result["online"] = True
        result["message"] = "已登录"
        result["qr_url"] = None
        result["qr_timestamp"] = None
        return jsonify(result)
    if _showing_qr:
        result["online"] = False
        result["message"] = "等待扫码登录"
        return jsonify(result)
    if _login_err:
        result["online"] = False
        result["message"] = "会话失效(loginCheck错误)"
        return jsonify(result)

    # 3. Check login status from webhook log (last WeChat send)
    webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
    try:
        r = subprocess.run(["tail", "-200", webhook_log],
                          capture_output=True, text=True, timeout=5)
        lines = r.stdout.strip().split("\n") if r.stdout else []
        for line in reversed(lines):
            ts_match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
            ts = ts_match.group(1) if ts_match else None
            if "WeChat send OK" in line and ts and not result["last_ok_at"]:
                result["last_ok_at"] = ts
            elif "WeChat send error" in line and ts and not result["last_err_at"]:
                result["last_err_at"] = ts
            if result["last_ok_at"] and result["last_err_at"]:
                break
        # Login/logout events: search full log (not just tail -200)
        try:
            for ev, key in (("system_event_logout", "last_logout_at"), ("system_event_login", "last_login_at")):
                er = subprocess.run(["grep", ev, webhook_log],
                                   capture_output=True, text=True, timeout=5)
                ev_lines = [l for l in er.stdout.strip().split("\n") if l.strip()]
                if ev_lines:
                    m = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", ev_lines[-1])
                    if m:
                        result[key] = m.group(1)
        except Exception:
            pass
    except Exception:
        pass

    # 4. Determine online status
    if result["last_ok_at"]:
        try:
            last_ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
            result["session_age_hours"] = round((now - last_ok_dt).total_seconds() / 3600, 1)
        except ValueError:
            pass

    ok_dt = err_dt = logout_dt = None
    try:
        if result["last_ok_at"]:
            ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
        if result["last_err_at"]:
            err_dt = datetime.strptime(result["last_err_at"], "%Y-%m-%d %H:%M:%S")
        if result.get("last_logout_at"):
            logout_dt = datetime.strptime(result["last_logout_at"], "%Y-%m-%d %H:%M:%S")
    except ValueError:
        pass

    # Check docker logs for recent login
    dlogin_dt = _docker_login_check()
    # Session-failed: loginCheck errors = WeChat session expired silently
    if _docker_session_failed():
        result["online"] = False
        result["message"] = "会话失效(loginCheck错误)"
    # Check webhook login event
    login_dt = None
    if result.get("last_login_at"):
        try:
            login_dt = datetime.strptime(result["last_login_at"], "%Y-%m-%d %H:%M:%S")
        except ValueError:
            pass
    if logout_dt and login_dt and logout_dt > login_dt:
        result["online"] = False
        h = round((now - logout_dt).total_seconds() / 3600, 1)
        result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
    elif logout_dt and dlogin_dt and logout_dt > dlogin_dt:
        result["online"] = False
        h = round((now - logout_dt).total_seconds() / 3600, 1)
        result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
    elif err_dt and login_dt and login_dt > err_dt:
        result["online"] = True
        result["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
        h = result["session_age_hours"]
        result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
    elif err_dt and dlogin_dt and dlogin_dt > err_dt:
        result["online"] = True
        result["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
        h = result["session_age_hours"]
        result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
    elif err_dt and ok_dt and err_dt > ok_dt:
        result["online"] = False
        h = round((now - err_dt).total_seconds() / 3600, 1)
        result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
    elif err_dt and not ok_dt:
        result["online"] = False
        h = round((now - err_dt).total_seconds() / 3600, 1)
        result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
    elif ok_dt and not err_dt:
        result["online"] = True
        h = result["session_age_hours"]
        result["message"] = f"已登录 {h:.0f}h"
    elif ok_dt and err_dt and ok_dt > err_dt:
        result["online"] = True
        h = result["session_age_hours"]
        result["message"] = f"已登录 {h:.0f}h"
    else:
        result["message"] = "状态未知"

    return jsonify(result)

@app.route("/api/platform")
@app.route("/api/health")
def api_health():
    xmpp = _xmpp_health()
    return jsonify({
        "ok": True,
        "time": datetime.now().isoformat(),
        "xmpp_bot_alive": xmpp.get("xmpp_connected", False),
        "ejabberd_alive": xmpp.get("ejabberd_alive", False),
    })


# ── OCG Router Proxy Status ──────────────────────────────────
# 代理跑在 246 :19878，本地直读 status API
_ROUTER_STATUS_URL = "http://127.0.0.1:19878/api/status"

@app.route("/api/proxy/status")
def api_proxy_status():
    """返回 OCG 路由代理的运行状态（从本地 :19878 拉取）。"""
    try:
        req = urllib.request.Request(_ROUTER_STATUS_URL, method="GET")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return jsonify(data)
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "proxy_running": False})


@app.route("/api/proxy/toggle-key", methods=["POST"])
def api_proxy_toggle_key():
    """启用/停用某个 OCG key（转发到 router :19878，立即生效）。"""
    try:
        payload = request.get_json(force=True, silent=True) or {}
        kid = payload.get("key_id", "")
        enabled = bool(payload.get("enabled", True))
        if not kid:
            return jsonify({"ok": False, "error": "key_id required"})
        req = urllib.request.Request(
            "http://127.0.0.1:19878/api/keys/toggle",
            data=json.dumps({"key_id": kid, "enabled": enabled}).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return jsonify(data)
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})


# ============================================================
#  Main
# ============================================================

def main():
    lock = guard("dashboard")
    if not lock.ok:
        log.error(lock.message)
        print(f"[dashboard] {lock.message}")
        sys.exit(1)
    port = int(os.environ.get("DASHBOARD_PORT", 5803))
    log.info(f"Dashboard starting on :{port}")
    print(f"[dashboard] Starting on http://127.0.0.1:{port}")
    _start_usage_auto_timer()
    app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)


if __name__ == "__main__":
    main()
