#!/usr/bin/env python3
from __future__ import annotations
"""
mpop - MeshPOP Network Operations CLI
Multi-server VPN mesh monitoring, management, and security tool

Standalone single-file CLI with built-in i18n support.
No external dependencies required.

Usage: mpop [command] [options]
Environment:
  MPOP_LANG=ko|en   Override language (default: auto-detect)
  MPOP_VPN=wire|tailscale   Override VPN type
"""

import socket
import subprocess
import sys
import os
import json
import hashlib
import base64
import getpass
import time
import glob
import shutil
import venv
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime

VERSION = "4.13.52"

# ==================== ANSI Colors ====================
class C:
    """ANSI color codes for terminal output"""
    # Reset
    R = "\033[0m"
    # Colors
    RED = "\033[91m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    BLUE = "\033[94m"
    MAGENTA = "\033[95m"
    CYAN = "\033[96m"
    WHITE = "\033[97m"
    GRAY = "\033[90m"
    # Styles
    BOLD = "\033[1m"
    DIM = "\033[2m"
    # Backgrounds
    BG_RED = "\033[41m"
    BG_GREEN = "\033[42m"
    BG_YELLOW = "\033[43m"

def bar_graph(percent, width=10):
    """Create a mini bar graph: [████░░░░░░] 45%"""
    if isinstance(percent, str):
        percent = int(percent.replace("%", "") or 0)
    filled = int(percent / 100 * width)
    empty = width - filled
    if percent > 90:
        color = C.RED
    elif percent > 70:
        color = C.YELLOW
    else:
        color = C.GREEN
    return f"{color}{'█' * filled}{'░' * empty}{C.R}"

# ==================== i18n (Internationalization) ====================

# Language detection: config > MPOP_LANG > LANG > default (en)
# Language: English only.
# (Language pack support can be added later via ~/.mpop/locales/<lang>.json)

_TRANSLATIONS = {
    "error": "Error",
    "success": "Success",
    "failed": "Failed",
    "unknown_cmd": "Unknown command",
    "usage": "Usage",
    "examples": "Examples",
    "options": "Options",
    "commands": "Commands",
    "status": "Status",
    "name": "Name",
    "target": "Target",
    "total": "Total",
    "none": "None",
    "yes": "Yes",
    "no": "No",
    "dashboard": "Dashboard",
    "server": "Server",
    "servers": "Servers",
    "load": "Load",
    "memory": "Memory",
    "disk": "Disk",
    "uptime": "Uptime",
    "online": "online",
    "offline": "offline",
    "enforce_scan": "Dragon Scan",
    "enforce_execute": "Execute fixes",
    "enforce_dryrun": "Dry-run mode",
    "secret_stored": "Secret stored",
    "secret_not_found": "Secret not found",
    "machine_registered": "Machine registered",
    "vpn_peers": "VPN Peers",
    "dns_installed": "MagicDNS installed",
    "dns_updated": "DNS updated",
    "tunnel_added": "Tunnel ready",
    "tunnel_deployed": "Tunnel deployed",
    "collecting_data": "Collecting server data...",
    "generating": "Generating...",
    "analyzing": "Analyzing...",
    "executing": "Executing...",
    "completed": "Completed",
    "cancelled": "Cancelled",
    "rule_analysis": "Rule-based analysis...",
    "risk_score": "Risk Score",
    "server_status": "Server Status",
    "security": "Security",
    "audit": "Audit",
    "blocked": "Blocked",
    "allowed": "Allowed",
    "news": "NEWS",
    "connection_failed": "Connection failed",
    "timeout": "Timeout",
    "not_found": "Not found",
    "permission_denied": "Permission denied",
}

def t(key, **kwargs):
    """Return English string for key."""
    text = _TRANSLATIONS.get(key, key)
    if kwargs:
        try:
            return text.format(**kwargs)
        except Exception:
            return text
    return text


# ==================== Configuration System ====================
CONFIG_DIR   = os.path.expanduser("~/.mpop")
CONFIG_FILE  = os.path.join(CONFIG_DIR, "config.json")
RADIOMCP_VENV = os.path.join(CONFIG_DIR, "radiomcp-env")
_config_cache = None
def _load_config():
    """Load configuration from ~/.mpop/config.json"""
    global _config_cache
    if _config_cache is not None:
        return _config_cache

    if not os.path.exists(CONFIG_FILE):
        return None

    try:
        with open(CONFIG_FILE, 'r') as f:
            _config_cache = json.load(f)
        return _config_cache
    except Exception as e:
        print(f"  {C.RED}Error loading config:{C.R} {e}")
        return None

# Well-known key aliases: short name → canonical dot-notation path
_CONFIG_ALIASES = {
    "ssh_method":        "connection.ssh_method",
    "vpn":               "connection.vpn",
    "use_server_agent":  "connection.use_server_agent",
    "ollama_model":      "ollama_model",
    "llm_model":         "llm_model",
}

def _get_config(key, default=None):
    """Get a config value with dot notation (e.g., 'servers.relay1.ip').
    Also resolves well-known aliases (e.g., 'ssh_method' → 'connection.ssh_method')
    and falls back to flat top-level key lookup.
    """
    config = _load_config()
    if config is None:
        return default

    # Resolve alias first
    canonical = _CONFIG_ALIASES.get(key, key)
    keys = canonical.split(".")
    val = config
    for k in keys:
        if isinstance(val, dict) and k in val:
            val = val[k]
        else:
            # Fallback: check flat top-level key (e.g., user set 'ssh_method' without dot prefix)
            if len(keys) > 1 and keys[-1] in config:
                return config[keys[-1]]
            return default
    return val

def _get_ollama_host():
    """Get ollama host with proper IP:port format.

    Resolves server names (node1, relay1) to VPN IPs and adds default port.
    """
    host = _get_config("ollama_host", "localhost:11434")

    # Already has port
    if ":" in host:
        return host

    # Check if it's a server name
    config = _load_config() or {}
    servers = config.get("servers", {})
    if host in servers:
        ip = servers[host]
        if isinstance(ip, dict):
            ip = ip.get("ip", ip.get("vpn_ip", host))
        return f"{ip}:11434"

    # Fallback: add default port
    return f"{host}:11434"


def _config_required():
    """Check if config exists, show setup instructions if not"""
    if os.path.exists(CONFIG_FILE):
        return True

    print(f"""
  {C.YELLOW}╔═══════════════════════════════════════════════════════════════╗
  ║  Configuration required                                       ║
  ╚═══════════════════════════════════════════════════════════════╝{C.R}

  {C.BOLD}1. Create config directory:{C.R}
     mkdir -p ~/.mpop

  {C.BOLD}2. Initialize config:{C.R}
     mpop init

  {C.BOLD}Or manually create ~/.mpop/config.json:{C.R}
     {{
       "servers": {{
         "web1": {{"ip": "10.0.0.1", "user": "deploy", "role": "web"}}
       }},
       "relays": ["your.relay.ip"],
       "vssh_secret": "your-secret-here"
     }}

  {C.DIM}See: mpop help config{C.R}
""")
    return False

def _ensure_config_exists():
    """Create sample config if missing (for first-run from GitHub - show dashboard immediately).
    Returns True if config was created, False if it already existed."""
    if os.path.exists(CONFIG_FILE):
        return False
    config_dir = os.path.expanduser("~/.mpop")
    os.makedirs(config_dir, exist_ok=True)
    sample = {
        "_comment": "mpop configuration - edit to match your infrastructure",
        "language": "en",
        "connection": {"vpn": "tailscale", "ssh_method": "ssh"},
        "servers": {
            "web1": {"ip": "10.0.1.10", "user": "deploy", "role": "Web server"},
            "db1": {"ip": "10.0.1.20", "user": "deploy", "role": "Database server"},
            "app1": {"ip": "10.0.1.30", "user": "deploy", "role": "Application server"},
        },
    }
    with open(CONFIG_FILE, "w") as f:
        json.dump(sample, f, indent=2)
    return True

# ==================== VPN Configuration ====================

# VPN type detection and peer discovery
VPN_TYPE = None  # "wire" or "tailscale" - auto-detected
SERVERS = {}  # Populated dynamically
_servers_cache = {"data": None, "time": 0}
_CACHE_TTL = 300  # 5 minutes

# Bootstrap anchor - set via env MPOP_RELAY or config "relays": ["your-relay-ip"]
# Empty by default; each user sets their own relay server
BOOTSTRAP_RELAY = os.environ.get("MPOP_RELAY", "")
BOOTSTRAP_API_PORT = int(os.environ.get("MPOP_API_PORT", "8790"))

# Load from config with bootstrap fallback
def _get_relays():
    relays = _get_config("relays", [])
    if not relays and BOOTSTRAP_RELAY:
        relays = [BOOTSTRAP_RELAY]
    return relays

def _get_api_port():
    return _get_config("api_port", 8786)

# Connection settings
def _get_vpn_type():
    """Get VPN type: 'wire', 'tailscale', or 'none'"""
    return _get_config("connection.vpn", "wire")

def _get_ssh_method():
    """Get SSH method: 'vssh', 'ssh', or 'tailscale-ssh'"""
    return _get_config("connection.ssh_method", "vssh")

def _use_server_agent():
    """Whether to use server-agent for monitoring"""
    return _get_config("connection.use_server_agent", False)

def _get_tailscale_prefix():
    """Get Tailscale IP prefix (e.g., '100.')"""
    return _get_config("tailscale_prefix", "100.")

def _get_server_ip(server_name, prefer_vpn=True):
    """Get the appropriate IP for a server based on VPN type"""
    servers = _get_config("servers", {})
    srv = servers.get(server_name, {})

    vpn_type = _get_vpn_type()

    if vpn_type == "tailscale" and srv.get("tailscale_ip"):
        return srv.get("tailscale_ip")
    elif vpn_type == "wire" and srv.get("ip"):
        return srv.get("ip")
    elif srv.get("public_ip") and not prefer_vpn:
        return srv.get("public_ip")

    # Fallback
    return srv.get("ip") or srv.get("tailscale_ip") or srv.get("public_ip", "")

WIRE_API_PORT = 8790  # Default, can be overridden by config

# Fallback servers from config
def _get_fallback_servers():
    servers = _get_config("servers", {})
    return {name: srv.get("ip", "") for name, srv in servers.items()}

FALLBACK_SERVERS = {}  # Populated from config

# Hostname mapping from config
def _get_hostname_map():
    return _get_config("hostname_map", {})

HOSTNAME_MAP = {}  # Populated from config

# ==================== Server Context (from config) ====================

def _get_server_context():
    servers = _get_config("servers", {})
    ctx = {}
    for name, srv in servers.items():
        ctx[name] = {
            "type": srv.get("type", "generic"),
            "expected_services": srv.get("services", []),
            "public": srv.get("public", False),
            "high_load_ok": srv.get("high_load_ok", False),
            "gpu": srv.get("gpu", False),
        }
    return ctx

SERVER_CONTEXT = {}  # Populated from config

# ==================== SSH/Network Connection Info (from config) ====================

def _get_server_ssh():
    servers = _get_config("servers", {})
    ssh = {}
    for name, srv in servers.items():
        ssh[name] = {
            "user": srv.get("user", "root"),
            "ssh_port": srv.get("ssh_port", 22),
            "public_ip": srv.get("public_ip"),
            "tailscale_ip": srv.get("tailscale_ip"),
            "local": srv.get("local", False),
        }
    return ssh

SERVER_SSH = {}  # Populated from config

# VPN IP mapping from config
def _get_server_vpn_ip():
    servers = _get_config("servers", {})
    return {name: srv.get("ip", "") for name, srv in servers.items()}

SERVER_VPN_IP = {}  # Populated from config

# Ports that are safe within VPN (not exposed to public)
VPN_SAFE_PORTS = {6379, 5432, 3306, 27017, 11211}  # Redis, PostgreSQL, MySQL, MongoDB, Memcached

# VSSH secret from environment variable ONLY (never hardcoded)
# VSSH secret: env var takes priority, then config file
def _get_vssh_secret():
    # 1. Environment variable (highest priority)
    env_secret = os.environ.get('VSSH_SECRET', '')
    if env_secret:
        return env_secret

    # 2. Local config
    config_secret = _get_config("vssh_secret", "")
    if config_secret:
        return config_secret

    return ""

VSSH_SECRET = ""  # Set after config load
VSSH_PORT = int(os.environ.get('VSSH_PORT', '48291'))

# VPN subnet prefix for connection checks (e.g., "10.99." or "192.168.")
def _get_vpn_prefix():
    return _get_config("vpn_prefix", "10.")

# Server Agent URLs (set dynamically after peer discovery — all peers tried in order)

SERVER_AGENT_URL = None   # legacy compat (first URL in list)
SERVER_AGENT_URLS = []    # all candidate agent URLs; query_server_agent tries each until one responds

# ==================== Embedded Agent Script ====================
# agent-reporter.py embedded so pip install works without extra files
def _load_agent_script() -> str:
    """Load agent_reporter.py — installed alongside mpop.py in site-packages (pip install meshpop).
    Falls back to dev location next to mpop.py.
    """
    import os as _os
    _here = _os.path.dirname(_os.path.abspath(__file__))
    for _name in ("agent_reporter.py", "agent-reporter.py"):
        _path = _os.path.join(_here, _name)
        if _os.path.exists(_path):
            with open(_path, "r", encoding="utf-8") as _f:
                return _f.read()
    # Fallback: ~/.mpop/agent_reporter.py (manually placed)
    _fallback = _os.path.expanduser("~/.mpop/agent_reporter.py")
    if _os.path.exists(_fallback):
        with open(_fallback, "r", encoding="utf-8") as _f:
            return _f.read()
    raise FileNotFoundError(
        "agent_reporter.py not found. Reinstall: pip install meshpop --upgrade"
    )

def _load_dashboard_script() -> str:
    """Load dashboard_server.py - installed alongside mpop.py in site-packages."""
    import os as _os
    _here = _os.path.dirname(_os.path.abspath(__file__))
    for _name in ("dashboard_server.py", "dashboard.py"):
        _path = _os.path.join(_here, _name)
        if _os.path.exists(_path):
            with open(_path, "r", encoding="utf-8") as _f:
                return _f.read()
    for _dev in ("/opt/server-agent/dashboard.py",
                 _os.path.expanduser("~/opt/server-agent/dashboard.py")):
        if _os.path.exists(_dev):
            with open(_dev, "r", encoding="utf-8") as _f:
                return _f.read()
    raise FileNotFoundError(
        "dashboard_server.py not found in site-packages. "
        "Reinstall: pip install meshpop --upgrade"
    )

# Legacy aliases for backward compatibility
_AGENT_REPORTER_SCRIPT = None   # use _load_agent_script()
_DASHBOARD_SERVER_SCRIPT = None # use _load_dashboard_script()

# dashboard_server.py extracted to separate file (see _load_dashboard_script)


# Server roles from config
def _get_server_roles():
    servers = _get_config("servers", {})
    return {name: srv.get("role", "") for name, srv in servers.items()}

SERVER_ROLES = {}  # Populated from config

# Initialize config-based variables
def _init_config():
    """Initialize all config-based global variables"""
    global FALLBACK_SERVERS, HOSTNAME_MAP, SERVER_CONTEXT, SERVER_SSH, SERVER_VPN_IP, SERVER_ROLES, VSSH_SECRET

    config = _load_config()
    if config is None:
        return

    FALLBACK_SERVERS = _get_fallback_servers()
    HOSTNAME_MAP = _get_hostname_map()
    SERVER_CONTEXT = _get_server_context()
    SERVER_SSH = _get_server_ssh()
    SERVER_VPN_IP = _get_server_vpn_ip()
    SERVER_ROLES = _get_server_roles()
    VSSH_SECRET = _get_vssh_secret()

# Run init at module load
_init_config()

# ==================== Core Utilities ====================

def get_hostname():
    return socket.gethostname()

def detect_vpn_types() -> list:
    """Detect ALL available VPN types on this machine, in priority order.
    Returns e.g. ['wire', 'tailscale'], ['tailscale', 'wire'], ['wire'], etc.
    Used for automatic fallback: try first, fall back to second."""
    # Explicit env override → single forced type, no fallback
    env_vpn = os.environ.get('MPOP_VPN', '').lower()
    if env_vpn in ('tailscale', 'ts'):
        return ['tailscale']
    elif env_vpn in ('wire', 'aw'):
        return ['wire']

    available = []
    try:
        # wire VPN: 10.99.x.x address on any interface
        r = subprocess.run(
            "ip addr show 2>/dev/null | grep -E '10\\.99\\.' || ifconfig 2>/dev/null | grep -E '10\\.99\\.'",
            shell=True, capture_output=True, text=True, timeout=3
        )
        if r.stdout.strip():
            available.append('wire')
    except Exception:
        pass
    try:
        # Tailscale: 100.64-127.x.x (CGNAT range) on tailscale0/utun*
        r = subprocess.run(
            "ip addr show tailscale0 2>/dev/null | grep 'inet ' || ifconfig 2>/dev/null | grep -A1 -E 'tailscale|utun' | grep 'inet 100\\.'",
            shell=True, capture_output=True, text=True, timeout=3
        )
        if r.stdout.strip():
            available.append('tailscale')
    except Exception:
        pass

    # 둘 다 없으면 wire 기본값 (설정 완료 후 사용한다고 가정)
    return available if available else ['wire']

def detect_vpn_type():
    """Return primary VPN type. Use detect_vpn_types() for full ordered list."""
    global VPN_TYPE
    if VPN_TYPE:
        return VPN_TYPE
    types = detect_vpn_types()
    VPN_TYPE = types[0]
    return VPN_TYPE

def get_vpn_ip():
    """Get local VPN IP (supports both wire and Tailscale)"""
    vpn_type = detect_vpn_type()

    try:
        if vpn_type == "tailscale":
            # Tailscale: 100.x.x.x
            result = subprocess.run(
                "tailscale ip -4 2>/dev/null || ip addr show tailscale0 2>/dev/null | grep inet | awk '{print $2}' | cut -d/ -f1",
                shell=True, capture_output=True, text=True, timeout=5
            )
            if result.stdout.strip():
                return result.stdout.strip().split('\n')[0]

        # VPN IP detection (configurable prefix)
        vpn_prefix = _get_vpn_prefix()
        result = subprocess.run(
            f"ip addr show 2>/dev/null | grep -F 'inet {vpn_prefix}' | awk '{{print $2}}' | cut -d/ -f1 | head -1",
            shell=True, capture_output=True, text=True, timeout=5
        )
        if result.stdout.strip():
            return result.stdout.strip()

        # macOS: look for "inet 10.x" pattern
        result = subprocess.run(
            f"ifconfig 2>/dev/null | grep -F 'inet {vpn_prefix}' | awk '{{print $2}}' | head -1",
            shell=True, capture_output=True, text=True, timeout=5
        )
        return result.stdout.strip() or "N/A"
    except Exception:
        return "N/A"

def _get_local_node_info():
    """Return (name, ip) for the local machine - MacBook/Linux running mpop.
    Always includes self so dashboard shows 'local' or hostname-derived name."""
    hn = socket.gethostname().lower()
    # Config hostname_map, or Mac->m1, else "local"
    name = (HOSTNAME_MAP.get(hn) or
            ("m1" if "mac" in hn else "local"))
    ip = get_vpn_ip()
    if ip and ip != "N/A":
        return name, ip
    # No VPN: use primary LAN IP (Mac/Linux)
    try:
        for cmd in [
            "ip route get 1 2>/dev/null | awk '{print $7; exit}'",
            "hostname -I 2>/dev/null | awk '{print $1}'",
            "ipconfig getifaddr en0 2>/dev/null",  # macOS WiFi
            "ipconfig getifaddr en1 2>/dev/null",  # macOS Ethernet
        ]:
            r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=2)
            addr = (r.stdout or "").strip().split()[0] if r.returncode == 0 else ""
            if addr and addr.startswith(("10.", "192.168.", "172.")):
                return name, addr
    except Exception:
        pass
    return name, "127.0.0.1"


def _fetch_wire_peers():
    """Fetch peers from wire API"""
    import urllib.request

    relays = _get_relays()
    if not relays:
        return None

    for relay_ip in relays:
        try:
            url = f"http://{relay_ip}:{WIRE_API_PORT}/peers"
            req = urllib.request.Request(url, headers={'User-Agent': 'mpop'})
            with urllib.request.urlopen(req, timeout=5) as resp:
                data = json.loads(resp.read())
                peers = data.get("peers", [])

                # Build IP→name lookup from servers.json so wire peers resolve to canonical names
                _sj_ip_map = {}
                _sj_path = os.path.join(os.path.expanduser("~"), ".mpop", "servers.json")
                if os.path.isfile(_sj_path):
                    try:
                        _sj = json.loads(open(_sj_path).read())
                        for _sn, _sv in _sj.items():
                            for _f in ("vpn_ip", "wire_ip", "ip", "public_ip"):
                                if _sv.get(_f):
                                    _sj_ip_map[_sv[_f]] = _sn
                    except Exception:
                        pass

                servers = {}
                for peer in peers:
                    vpn_ip = peer.get("vpn_ip", "")
                    public_ip = peer.get("public_ip", "")
                    hostname = peer.get("hostname", "").lower()

                    # Determine name from:
                    # 1. Hostname map (user-configured hostname → name)
                    # 2. servers.json vpn_ip/public_ip match (canonical name)
                    # 3. FALLBACK_SERVERS IP match
                    # 4. Relay IP pattern (v1, v2, ...)
                    # 5. Last octet fallback (node-NNN) — only if nothing else matches
                    name = HOSTNAME_MAP.get(hostname)

                    if not name:
                        # Check servers.json by vpn_ip or public_ip
                        name = _sj_ip_map.get(vpn_ip) or _sj_ip_map.get(public_ip)

                    if not name:
                        # Check FALLBACK_SERVERS by IP
                        for known_name, known_ip in FALLBACK_SERVERS.items():
                            if known_ip == vpn_ip:
                                name = known_name
                                break

                    if not name:
                        # Relay IP pattern
                        if public_ip in relays:
                            idx = relays.index(public_ip) + 1
                            name = f"v{idx}"
                        elif vpn_ip:
                            # Last resort: use last octet
                            suffix = vpn_ip.split('.')[-1]
                            name = f"node-{suffix}"

                    if name and vpn_ip:
                        servers[name] = vpn_ip

                return servers
        except Exception as e:
            continue

    return {}

def _fetch_tailscale_peers():
    """Fetch peers from Tailscale"""
    try:
        result = subprocess.run(
            "tailscale status --json",
            shell=True, capture_output=True, text=True, timeout=10
        )
        if result.returncode != 0:
            return {}

        data = json.loads(result.stdout)
        servers = {}

        # Add self
        self_info = data.get("Self", {})
        self_ips = self_info.get("TailscaleIPs", [])
        self_name = self_info.get("HostName", "").lower()
        if self_ips and self_name:
            name = HOSTNAME_MAP.get(self_name, self_name[:8])
            servers[name] = self_ips[0]

        # Add peers
        peers = data.get("Peer", {})
        if peers:
            for pub_key, peer in peers.items():
                hostname = peer.get("HostName", "").lower()
                ips = peer.get("TailscaleIPs", [])
                if ips and hostname:
                    name = HOSTNAME_MAP.get(hostname, hostname[:8])
                    servers[name] = ips[0]

        return servers
    except Exception:
        return {}

def discover_peers(force=False):
    """Discover VPN peers dynamically"""
    global SERVERS, SERVER_AGENT_URL, _servers_cache

    now = time.time()
    if not force and _servers_cache["data"] and (now - _servers_cache["time"]) < _CACHE_TTL:
        SERVERS = _servers_cache["data"]
        return SERVERS

    # 감지된 VPN 타입 순서대로 시도, 실패하면 다음으로 폴백
    _fetchers = {
        'wire': _fetch_wire_peers,
        'tailscale': _fetch_tailscale_peers,
    }
    servers = {}
    for vpn in detect_vpn_types():
        servers = _fetchers[vpn]() if vpn in _fetchers else {}
        if servers:
            break  # 성공하면 멈춤

    if servers:
        SERVERS = servers
    else:
        SERVERS = {}

    # Merge config servers first (base)
    if FALLBACK_SERVERS:
        for name, ip in FALLBACK_SERVERS.items():
            if name not in SERVERS:
                SERVERS[name] = ip

    # Always include local machine (MacBook/Linux) so users see themselves
    _local_name, _local_ip = _get_local_node_info()
    if _local_name:
        SERVERS[_local_name] = (_local_ip if _local_ip and _local_ip != "N/A" else "127.0.0.1")

    _servers_cache = {"data": SERVERS, "time": now}

    # Build SERVER_AGENT_URLS — all known peers can answer (each holds full mesh stats via push)
    # v1 goes first as preferred relay, then the rest; any one that responds is fine
    global SERVER_AGENT_URL, SERVER_AGENT_URLS
    candidate_names = (["v1"] if "v1" in SERVERS else []) + [n for n in SERVERS if n != "v1"]
    SERVER_AGENT_URLS = [f"http://{SERVERS[n]}:8721" for n in candidate_names]

    # Also add any dashboard_url entries from config (user-defined overrides)
    _cfg_urls = _get_config("dashboard_url", "")
    if isinstance(_cfg_urls, list):
        for u in _cfg_urls:
            u = u.rstrip("/")
            if u and u not in SERVER_AGENT_URLS:
                SERVER_AGENT_URLS.append(u)
    elif _cfg_urls:
        for u in str(_cfg_urls).split(","):
            u = u.strip().rstrip("/")
            if u and u not in SERVER_AGENT_URLS:
                SERVER_AGENT_URLS.append(u)

    SERVER_AGENT_URL = SERVER_AGENT_URLS[0] if SERVER_AGENT_URLS else None

    return SERVERS

def get_servers():
    """Get servers dict (with lazy initialization)"""
    if not SERVERS:
        discover_peers()
    return SERVERS

def get_best_ip(server_name, timeout=1):
    """Get best IP for server - LAN if reachable, otherwise VPN.

    Checks if server has a LAN IP on same subnet and if it's reachable.
    Returns (ip, method) where method is 'lan' or 'vpn'.
    """
    servers = get_servers()
    vpn_ip = servers.get(server_name, "")
    if not vpn_ip:
        return None, None

    # Get server's LAN IP from config
    config = _load_config() or {}
    srv_config = config.get("servers", {}).get(server_name, {})
    lan_ip = srv_config.get("lan_ip", "")

    if lan_ip:
        # Check if LAN IP is reachable (quick ping)
        try:
            result = subprocess.run(
                f"ping -c 1 -W {timeout} {lan_ip}",
                shell=True, capture_output=True, timeout=timeout+1
            )
            if result.returncode == 0:
                return lan_ip, "lan"
        except Exception:
            pass

    return vpn_ip, "vpn"

def get_my_name():
    vpn_ip = get_vpn_ip()
    servers = get_servers()
    for name, ip in servers.items():
        if ip == vpn_ip:
            return name
    return "local"

def ping_server(ip, timeout=1):
    """Check if server is reachable - ICMP first, then vssh command fallback"""
    # Try ICMP ping first
    try:
        result = subprocess.run(
            f"ping -c 1 -W {timeout} {ip}",
            shell=True, capture_output=True, timeout=timeout+1
        )
        if result.returncode == 0:
            return True
    except Exception:
        pass

    # ICMP failed - try vssh echo command as fallback (with retry)
    # This handles servers with ICMP blocked (like relay3) or high latency
    for attempt in range(2):  # 2 attempts
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(8)  # v3 needs more time
            sock.connect((ip, VSSH_PORT))
            sock.sendall(f"SSH:{VSSH_SECRET}:echo PING\n".encode())
            resp = sock.recv(64)
            sock.close()
            if resp.startswith(b'OK'):
                return True
        except Exception:
            pass
    return False

def check_port(ip, port, timeout=2):
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((ip, port))
        sock.close()
        return True
    except Exception:
        return False

def vssh_exec(ip, cmd, timeout=5):
    """Execute command on remote server via vssh"""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((ip, VSSH_PORT))
        sock.sendall(f"SSH:{VSSH_SECRET}:{cmd}\n".encode())

        resp = sock.recv(4096)
        if not resp.startswith(b'OK'):
            return None

        # Strip "OK\n" prefix; keep any output that arrived in the same packet
        ok_end = resp.find(b'\n')
        data = resp[ok_end + 1:] if ok_end >= 0 else resp[2:]

        sock.settimeout(2)
        while True:
            try:
                chunk = sock.recv(8192)
                if not chunk:
                    break
                data += chunk
            except socket.timeout:
                break

        sock.close()
        return data.decode('utf-8', errors='replace').strip()
    except Exception:
        return None


def ssh_exec(server_name, cmd, timeout=30):
    """Execute command on remote server using configured SSH method

    SSH methods:
    - vssh: Custom vssh protocol (fastest, requires vssh daemon)
    - ssh: Standard SSH (uses VPN/public IP based on vpn setting)
    - tailscale-ssh: Tailscale SSH (uses tailscale ssh command)
    - local: Run directly on local machine (no SSH)

    Server-specific override: set "ssh_method" in server config
    """
    servers = _get_config("servers", {})
    srv = servers.get(server_name, {})

    # Local machine — run directly without SSH
    if srv.get("local") or server_name == get_my_name():
        try:
            result = subprocess.run(
                cmd, shell=True, capture_output=True, text=True, timeout=timeout
            )
            return result.stdout.strip()
        except subprocess.TimeoutExpired:
            return None
        except Exception:
            return None

    # Server-specific SSH method override, fallback to global
    method = srv.get("ssh_method") or _get_ssh_method()

    user = srv.get("user", "root")
    ssh_port = srv.get("ssh_port", 22)

    # Server-specific VPN type override, fallback to global
    vpn_type = srv.get("vpn") or _get_vpn_type()
    if vpn_type == "tailscale":
        ip = srv.get("tailscale_ip") or srv.get("ip")
    elif vpn_type == "wire":
        ip = srv.get("ip")
    else:
        ip = srv.get("public_ip") or srv.get("ip")

    if not ip:
        return None

    try:
        if method == "vssh":
            # 1순위: vssh (wire VPN)
            result = vssh_exec(ip, cmd, timeout=timeout)
            if result is not None:
                return result
            # 2순위: tailscale-ssh (tailscale IP 있을 때)
            ts_target = srv.get("tailscale_hostname") or srv.get("tailscale_ip")
            if ts_target:
                try:
                    r = subprocess.run(
                        ["tailscale", "ssh", f"{user}@{ts_target}", "--", cmd],
                        capture_output=True, text=True, timeout=timeout
                    )
                    if r.returncode == 0:
                        return r.stdout.strip()
                except Exception:
                    pass
            # 3순위: 일반 SSH (lan_ip → public_ip 순서)
            for fallback_ip in filter(None, [srv.get("lan_ip"), srv.get("public_ip"), srv.get("ip")]):
                if fallback_ip == ip:
                    continue  # wire VPN IP는 이미 vssh로 시도함
                ssh_cmd = ["ssh",
                           "-o", f"ConnectTimeout={min(timeout, 10)}",
                           "-o", "StrictHostKeyChecking=no",
                           "-o", "BatchMode=yes",
                           "-p", str(ssh_port),
                           f"{user}@{fallback_ip}", cmd]
                try:
                    r = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
                    if r.returncode == 0:
                        return r.stdout.strip()
                except Exception:
                    continue
            return None

        elif method == "tailscale-ssh":
            # Use tailscale ssh command (MagicDNS hostname or tailscale IP)
            # Priority: tailscale_hostname > tailscale_ip > server_name
            target = srv.get("tailscale_hostname") or srv.get("tailscale_ip") or server_name
            ts_cmd = ["tailscale", "ssh", f"{user}@{target}", "--", cmd]
            result = subprocess.run(
                ts_cmd,
                capture_output=True, text=True, timeout=timeout
            )
            return result.stdout.strip() if result.returncode == 0 else None

        else:  # method == "ssh"
            # Standard SSH
            ssh_cmd = ["ssh",
                       "-o", "ConnectTimeout=10",
                       "-o", "StrictHostKeyChecking=no",
                       "-o", "BatchMode=yes",
                       "-p", str(ssh_port)]

            # Synology userspace Tailscale: needs ProxyCommand
            if vpn_type == "tailscale" and os.path.exists("/volume1/@appstore/Tailscale/bin/tailscale"):
                ssh_cmd.extend(["-o", "ProxyCommand=/volume1/@appstore/Tailscale/bin/tailscale nc %h %p"])

            ssh_cmd.extend([f"{user}@{ip}", cmd])
            result = subprocess.run(
                ssh_cmd,
                capture_output=True, text=True, timeout=timeout
            )
            return result.stdout.strip() if result.returncode == 0 else None

    except subprocess.TimeoutExpired:
        return None
    except Exception:
        return None


def ssh_sudo(server_name, cmd, timeout=30):
    """Execute sudo command with password from mpop secret.

    Uses the secret '<server>_sudo' (e.g., d1_sudo) for password.
    Falls back to passwordless sudo if no secret found.
    """
    # Get sudo password from mpop secret store
    secret_key = f"{server_name}_sudo"
    password = None
    try:
        password = _load_secrets().get(secret_key, "") or None
    except Exception:
        pass

    if password:
        # Use echo + sudo -S to provide password
        sudo_cmd = f"echo '{password}' | sudo -S {cmd}"
        return ssh_exec(server_name, sudo_cmd, timeout=timeout)
    else:
        # Fallback to passwordless sudo
        return ssh_exec(server_name, f"sudo {cmd}", timeout=timeout)


def ssh_exec_all(cmd, timeout=30, parallel=True):
    """Execute command on all servers using configured SSH method

    Returns: dict of {server_name: output}
    """
    servers = _get_config("servers", {})
    my_name = get_my_name()
    results = {}

    def run_one(name):
        if name == my_name:
            # Local execution
            try:
                result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
                return name, result.stdout.strip()
            except Exception:
                return name, None
        return name, ssh_exec(name, cmd, timeout=timeout)

    if parallel:
        with ThreadPoolExecutor(max_workers=10) as executor:
            futures = [executor.submit(run_one, name) for name in servers.keys()]
            for future in as_completed(futures):
                name, output = future.result()
                results[name] = output
    else:
        for name in servers.keys():
            _, output = run_one(name)
            results[name] = output

    return results

# ==================== Status Indicators ====================

def load_icon(load_str):
    try:
        l = float(load_str)
        if l > 5: return "!!"
        elif l > 2: return "* "
        else: return "  "
    except Exception:
        return "? "

def disk_icon(disk_str):
    try:
        pct = int(disk_str.replace('%', ''))
        if pct > 90: return "!!"
        elif pct > 70: return "* "
        else: return "  "
    except Exception:
        return "? "

# ==================== Data Collection ====================

def get_server_stats(ip):
    """Get comprehensive server stats via vssh"""
    cmd = """
echo "LOAD:$(cat /proc/loadavg 2>/dev/null | cut -d' ' -f1-3 || sysctl -n vm.loadavg 2>/dev/null | tr -d '{}')"
echo "CORES:$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo '1')"
echo "CPU:$(top -bn1 2>/dev/null | grep -E 'Cpu|%Cpu' | head -1 | grep -oE '[0-9.]+[ ]*id' | grep -oE '[0-9.]+' | awk '{print int(100-$1)}' || echo '-')"
echo "UP:$(uptime -p 2>/dev/null | sed 's/up //' | sed 's/ hours*/h/' | sed 's/ hour/h/' | sed 's/ minutes*/m/' | sed 's/ days*/d/' | sed 's/ day/d/' | cut -c1-12 || uptime | sed 's/.*up //' | sed 's/,.*//' | sed 's/ days*/d/' | cut -c1-10)"
echo "DISK:$(df -h / 2>/dev/null | tail -1 | awk '{print $5}')"
echo "MEM:$(free 2>/dev/null | awk 'NR==2{printf \"%.0f%%\", $3/$2*100}' || echo '-')"
echo "SVCS:$(systemctl list-units --type=service --state=running 2>/dev/null | grep -oE 'vssh|wire|docker|nginx|redis|postgres|mysql|ssh' | sort -u | tr '\\n' ',' | sed 's/,$//' || launchctl list 2>/dev/null | grep -c 'com\\.' | xargs -I{} echo 'launchd:{}')"
echo "FAIL:$(journalctl -u sshd --since '1 hour ago' 2>/dev/null | grep -c 'Failed' || grep -c 'Failed' /var/log/auth.log 2>/dev/null || echo '0')"
echo "CONN:$(ss -tn 2>/dev/null | grep -c ESTAB || netstat -an 2>/dev/null | grep -c ESTABLISHED || echo '?')"
"""
    result = vssh_exec(ip, cmd.strip(), timeout=5)

    stats = {
        "load": "-", "load_full": "-", "cores": "1", "cpu": "-",
        "uptime": "-", "disk": "-", "mem": "-",
        "services": "-", "failed_logins": "0", "connections": "-"
    }
    if result:
        for line in result.split('\n'):
            if line.startswith("LOAD:"):
                val = line[5:].strip()
                if val:
                    stats["load_full"] = val[:15]  # 1min 5min 15min
                    stats["load"] = val.split()[0][:5]  # 1min only
            elif line.startswith("CORES:"):
                stats["cores"] = line[6:].strip()
            elif line.startswith("CPU:"):
                stats["cpu"] = line[4:].strip()
            elif line.startswith("UP:"):
                stats["uptime"] = line[3:].strip()[:12]
            elif line.startswith("DISK:"):
                stats["disk"] = line[5:].strip()
            elif line.startswith("MEM:"):
                stats["mem"] = line[4:].strip()
            elif line.startswith("SVCS:"):
                stats["services"] = line[5:].strip()[:20]
            elif line.startswith("FAIL:"):
                stats["failed_logins"] = line[5:].strip()
            elif line.startswith("CONN:"):
                stats["connections"] = line[5:].strip()

    return stats

def get_local_stats():
    """Get local server stats"""
    stats = {
        "load": "-", "load_full": "-", "cores": "1", "cpu": "-",
        "uptime": "-", "disk": "-", "mem": "-",
        "services": "-", "failed_logins": "0", "connections": "-"
    }

    try:
        result = subprocess.run(
            "cat /proc/loadavg 2>/dev/null | cut -d' ' -f1-3 || sysctl -n vm.loadavg 2>/dev/null | tr -d '{}'",
            shell=True, capture_output=True, text=True, timeout=2
        )
        if result.stdout.strip():
            stats["load_full"] = result.stdout.strip()[:15]
            stats["load"] = result.stdout.strip().split()[0][:5]
    except Exception: pass

    try:
        result = subprocess.run(
            "nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo '1'",
            shell=True, capture_output=True, text=True, timeout=2
        )
        if result.stdout.strip():
            stats["cores"] = result.stdout.strip()
    except Exception: pass

    try:
        result = subprocess.run(
            "ps -A -o %cpu 2>/dev/null | awk '{s+=$1} END {printf \"%.0f%%\", s}'",
            shell=True, capture_output=True, text=True, timeout=2
        )
        if result.stdout.strip():
            stats["cpu"] = result.stdout.strip()
    except Exception: pass

    try:
        result = subprocess.run(
            "uptime -p 2>/dev/null | sed 's/up //' | sed 's/ hours*/h/' | sed 's/ days*/d/' | cut -c1-12 || uptime | sed 's/.*up //' | sed 's/,.*//' | sed 's/ days*/d/' | cut -c1-10",
            shell=True, capture_output=True, text=True, timeout=2
        )
        if result.stdout.strip():
            stats["uptime"] = result.stdout.strip()[:12]
    except Exception: pass

    try:
        result = subprocess.run("df -h / | tail -1 | awk '{print $5}'",
            shell=True, capture_output=True, text=True, timeout=2)
        if result.stdout.strip():
            stats["disk"] = result.stdout.strip()
    except Exception: pass

    try:
        result = subprocess.run(
            "free 2>/dev/null | awk 'NR==2{printf \"%.0f%%\", $3/$2*100}' || echo '-'",
            shell=True, capture_output=True, text=True, timeout=2)
        if result.stdout.strip():
            stats["mem"] = result.stdout.strip()
    except Exception: pass

    try:
        result = subprocess.run(
            "systemctl list-units --type=service --state=running 2>/dev/null | grep -oE 'vssh|wire|docker|nginx|redis|postgres|mysql|ssh' | sort -u | tr '\\n' ',' | sed 's/,$//' || launchctl list 2>/dev/null | grep -c 'com\\.' | xargs -I{} echo 'launchd:{}'",
            shell=True, capture_output=True, text=True, timeout=3)
        if result.stdout.strip():
            stats["services"] = result.stdout.strip()[:20]
    except Exception: pass

    try:
        result = subprocess.run(
            "ss -tn 2>/dev/null | grep -c ESTAB || netstat -an 2>/dev/null | grep -c ESTABLISHED || echo '?'",
            shell=True, capture_output=True, text=True, timeout=2)
        if result.stdout.strip():
            stats["connections"] = result.stdout.strip()
    except Exception: pass

    return stats

def check_server_full(name, ip, vpn_ip):
    """Full server check including connectivity, stats, services"""
    result = {"name": name, "ip": ip}

    if ip == vpn_ip:
        result["me"] = True
        result["ping"] = True
        result["ssh"] = True
        result["vssh"] = True
        result["stats"] = get_local_stats()
        return result

    result["ping"] = ping_server(ip, timeout=1)
    if not result["ping"]:
        result["ssh"] = False
        result["vssh"] = False
        result["stats"] = None
        return result

    ssh_port = _get_config("default_ssh_port", 22)
    result["ssh"] = check_port(ip, ssh_port, timeout=2)
    result["vssh"] = check_port(ip, VSSH_PORT, timeout=2)
    result["stats"] = get_server_stats(ip) if result["vssh"] else None

    return result

def get_extended_info(ip):
    """Get extended server info: processes, docker, security, updates"""
    cmd = """
# Top 5 CPU processes
echo "PROCS_CPU:$(ps aux --sort=-%cpu 2>/dev/null | head -6 | tail -5 | awk '{printf "%s:%s%%, ", $11, $3}' | sed 's/, $//' || echo '-')"

# Top 5 Memory processes
echo "PROCS_MEM:$(ps aux --sort=-%mem 2>/dev/null | head -6 | tail -5 | awk '{printf "%s:%s%%, ", $11, $4}' | sed 's/, $//' || echo '-')"

# Running Docker containers
echo "DOCKER:$(docker ps --format '{{.Names}}:{{.Status}}' 2>/dev/null | head -5 | tr '\\n' ',' | sed 's/,$//' || echo 'no-docker')"

# Docker container count
echo "DOCKER_CNT:$(docker ps -q 2>/dev/null | wc -l | tr -d ' ' || echo '0')"

# SSH failed logins (24h) - filter by date
echo "SSH_FAIL_24H:$(awk -v d=\\$(date -d '24 hours ago' '+%Y-%m-%dT%H:%M' 2>/dev/null || date -v-24H '+%Y-%m-%dT%H:%M') '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep -c 'Failed' || journalctl -u sshd -u ssh --since '24 hours ago' 2>/dev/null | grep -c 'Failed' || echo '0')"

# Top attacker IPs (24h)
echo "ATTACKERS:$(awk -v d=\\$(date -d '24 hours ago' '+%Y-%m-%dT%H:%M' 2>/dev/null || date -v-24H '+%Y-%m-%dT%H:%M') '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep 'Failed' | grep -v '10\\.99\\.' | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' | sort | uniq -c | sort -rn | head -3 | awk '{printf \"%s(%s), \", \\$2, \\$1}' | sed 's/, $//' || echo '-')"

# fail2ban status
echo "F2B:$(fail2ban-client status sshd 2>/dev/null | grep 'Currently banned' | grep -oE '[0-9]+' || echo '-')"

# Open ports (external facing)
echo "PORTS:$(ss -tlnp 2>/dev/null | grep -v '127.0.0.1' | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | head -8 | tr '\\n' ',' | sed 's/,$//' || echo '-')"

# Firewall status
echo "FW:$(ufw status 2>/dev/null | head -1 | awk '{print $2}' || iptables -L 2>/dev/null | head -1 | grep -q 'Chain' && echo 'iptables' || echo '-')"

# Pending updates (apt/yum)
echo "UPDATES:$(apt list --upgradable 2>/dev/null | grep -c upgradable || yum check-update 2>/dev/null | grep -c 'updates$' || echo '-')"

# Running systemd services count
echo "SVC_CNT:$(systemctl list-units --type=service --state=running 2>/dev/null | grep -c 'running' || echo '-')"

# Custom scripts running
echo "SCRIPTS:$(ps aux 2>/dev/null | grep -E 'python|node|ruby|perl' | grep -v grep | awk '{print $11}' | xargs -I{} basename {} 2>/dev/null | sort -u | head -5 | tr '\\n' ',' | sed 's/,$//' || echo '-')"

# Last login
echo "LAST_LOGIN:$(last -1 2>/dev/null | head -1 | awk '{print $1, $3, $4, $5}' || echo '-')"

# Kernel version
echo "KERNEL:$(uname -r | cut -d'-' -f1)"
"""
    result = vssh_exec(ip, cmd.strip(), timeout=8)

    ext = {
        "procs_cpu": "-", "procs_mem": "-",
        "docker": "-", "docker_cnt": "0",
        "ssh_fail_24h": "0", "attackers": "-", "f2b_banned": "-",
        "ports": "-", "firewall": "-",
        "updates": "-", "svc_cnt": "-", "scripts": "-",
        "last_login": "-", "kernel": "-"
    }

    if result:
        for line in result.split('\n'):
            if line.startswith("PROCS_CPU:"):
                ext["procs_cpu"] = line[10:].strip()[:60]
            elif line.startswith("PROCS_MEM:"):
                ext["procs_mem"] = line[10:].strip()[:60]
            elif line.startswith("DOCKER:"):
                ext["docker"] = line[7:].strip()[:50]
            elif line.startswith("DOCKER_CNT:"):
                ext["docker_cnt"] = line[11:].strip()
            elif line.startswith("SSH_FAIL_24H:"):
                ext["ssh_fail_24h"] = line[13:].strip()
            elif line.startswith("ATTACKERS:"):
                ext["attackers"] = line[10:].strip()[:40]
            elif line.startswith("F2B:"):
                ext["f2b_banned"] = line[4:].strip()
            elif line.startswith("PORTS:"):
                ext["ports"] = line[6:].strip()[:30]
            elif line.startswith("FW:"):
                ext["firewall"] = line[3:].strip()
            elif line.startswith("UPDATES:"):
                ext["updates"] = line[8:].strip()
            elif line.startswith("SVC_CNT:"):
                ext["svc_cnt"] = line[8:].strip()
            elif line.startswith("SCRIPTS:"):
                ext["scripts"] = line[8:].strip()[:40]
            elif line.startswith("LAST_LOGIN:"):
                ext["last_login"] = line[11:].strip()[:30]
            elif line.startswith("KERNEL:"):
                ext["kernel"] = line[7:].strip()

    return ext

def check_connectivity_from(source_ip, targets):
    """Check which servers are reachable from source server (ping + python fallback)"""
    target_ips = [SERVERS[t] for t in targets if t in SERVERS]
    if not target_ips:
        return {}

    # Build a robust check script: ping first, python socket fallback
    ip_list = ' '.join(target_ips)
    cmd = f"""python3 -c "
import socket, subprocess, sys, threading
targets = '{ip_list}'.split()
results = {{}}
def chk(ip):
    try:
        r = subprocess.run(['ping','-c','1','-W','1',ip], capture_output=True, timeout=2)
        if r.returncode == 0:
            results[ip] = True
            return
    except Exception:
        pass
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(1)
        s.connect((ip, 48291))
        s.close()
        results[ip] = True
    except Exception:
        results[ip] = False
threads = [threading.Thread(target=chk, args=(ip,)) for ip in targets]
for t in threads: t.start()
for t in threads: t.join(timeout=3)
for ip in targets:
    print(f'OK:{{ip}}' if results.get(ip) else f'FAIL:{{ip}}')
" 2>/dev/null"""

    # Find server name for this IP to use correct ssh_method
    src_name = None
    for sn, si in SERVERS.items():
        if si == source_ip:
            src_name = sn
            break
    if src_name:
        result = ssh_exec(src_name, cmd, timeout=15)
    else:
        result = vssh_exec(source_ip, cmd, timeout=10)

    _ = len(result) if result else 0  # force result evaluation

    reachable = {}
    if result:
        for line in result.split('\n'):
            line = line.strip()
            if line.startswith("OK:"):
                reachable[line[3:]] = True
            elif line.startswith("FAIL:"):
                reachable[line[5:]] = False
    return reachable

# ==================== Commands ====================

def cmd_dashboard(args):
    """Main dashboard - overview of all servers

    Default: Use server-agent API (fast, <1 second)
    --live: Use vssh direct scan (10-15 seconds, real-time)
    check:  Verify each server's local dashboard has received reports (분산 확인)
    """
    if "check" in args:
        return cmd_dashboard_check(args)
    if "--live" in args or "-l" in args:
        return cmd_dashboard_live(args)

    get_servers()  # Ensure SERVERS populated (discovery + FALLBACK_SERVERS)
    # Try server-agent API first (fast mode)
    data = query_server_agent("/api/status")

    # Always merge localhost agent data — the local machine's own info
    # must appear even on a single standalone install (MacBook, no remote peers).
    # Remote agents may not yet have received a push from this machine.
    # NOTE: query_server_agent returns normalized {"servers": [...]} (not "nodes"),
    # so we normalize localhost data too before merging.
    try:
        import urllib.request as _ur_local
        with _ur_local.urlopen("http://localhost:8721/api/status", timeout=2) as _r_local:
            _local_raw = json.loads(_r_local.read())
            # Normalize local data (convert "nodes" → "servers" format)
            _local_norm = _normalize_agent_response(_local_raw) if "nodes" in _local_raw else _local_raw
            if "servers" in _local_norm:
                if "servers" in data:
                    # Merge: add local servers not already present in remote data
                    _remote_names = {s.get("name") for s in data.get("servers", [])}
                    for _ls in _local_norm.get("servers", []):
                        if _ls.get("name") not in _remote_names:
                            data["servers"].append(_ls)
                elif "error" in data:
                    data = _local_norm  # remote failed → use local only
    except Exception:
        pass  # no local agent running — continue with remote data

    if "error" in data:
        err = data["error"]
        if err != "not_configured":
            # Configured but unreachable — show warning
            print(f"  [!] Server-agent unavailable: {err}")
            print("  [*] Falling back to live scan...")
        # else: not configured yet, silently use live scan
        return cmd_dashboard_live(args)

    my_name = get_my_name()
    servers = data.get("servers", [])
    # Wire/mesh coordinator returns "nodes" format; convert to "servers"
    if not servers and "nodes" in data:
        now_ts = int(time.time())
        servers = []
        for n in data.get("nodes", []):
            ts = n.get("ts", 0)
            is_online = (now_ts - ts) < 300  # 5min
            name = n.get("node", n.get("name", "?"))
            # HOSTNAME_MAP for display (vultr->v1, dragon1->s1, etc.)
            _wire_map = {"vultr": "v1", "dragon1": "s1", "dragon": "m1"}
            name = HOSTNAME_MAP.get(name.lower()) or _wire_map.get(name.lower(), name)
            servers.append({
                "name": name,
                "ip": n.get("ip", "?"),
                "online": is_online,
                "source": "wire",
                "disk_pct": n.get("disk_pct", 0),
                "mem_pct": n.get("mem_pct", 0),
                "load": n.get("load", "-"),
                "uptime": n.get("uptime", "-"),
            })
    if data.get("last_update"):
        last_update = data.get("last_update")
    elif data.get("ts"):
        last_update = datetime.utcfromtimestamp(data["ts"]).isoformat()
    else:
        last_update = "?"

    # Convert server-agent format to mpop format
    # Exclude placeholder/virtual nodes (l1-l20) from count — same filter as display
    _skip_count = set(_get_config("dashboard_skip", [])) | {f"l{i}" for i in range(1, 21)}
    _counted = [s for s in servers if s.get("name", "?") not in _skip_count]
    online = sum(1 for s in _counted if s.get("online"))
    offline = [s.get("name", "?") for s in _counted if not s.get("online")]
    total = len(_counted)

    # Check for alerts
    alerts = []
    for s in servers:
        if s.get("online"):
            try:
                disk = s.get("disk_pct", 0)
                if isinstance(disk, str):
                    disk = int(disk.replace("%", ""))
                if disk > 90:
                    alerts.append(f"{s['name']}:disk{disk}%")
            except Exception: pass
            try:
                mem = s.get("mem_pct", 0)
                if mem > 90:
                    alerts.append(f"{s['name']}:mem{mem}%")
            except Exception: pass

    # Header with colors
    if not offline:
        status_icon = f"{C.GREEN}{C.BOLD}✓ OK{C.R}"
    elif online > total//2:
        status_icon = f"{C.YELLOW}{C.BOLD}⚠ WARN{C.R}"
    else:
        status_icon = f"{C.RED}{C.BOLD}✗ CRIT{C.R}"
    now = datetime.now().strftime("%H:%M:%S")

    # Parse last_update time (server uses UTC)
    try:
        # Remove timezone info and compare as naive datetime
        update_str = last_update.split('.')[0]  # Remove microseconds
        update_dt = datetime.fromisoformat(update_str)
        # Server returns UTC, convert local to UTC for comparison
        import subprocess
        utc_now = subprocess.run("date -u +%Y-%m-%dT%H:%M:%S", shell=True, capture_output=True, text=True).stdout.strip()
        now_dt = datetime.fromisoformat(utc_now)
        age_sec = (now_dt - update_dt).total_seconds()
        if age_sec < 0:
            age_str = "just now"
        elif age_sec < 120:
            age_str = f"{int(age_sec)}s ago"
        else:
            age_str = f"{int(age_sec/60)}m ago"
    except Exception as e:
        age_str = "?"

    print(f"""
  [{status_icon}] {C.BOLD}MeshPOP v{VERSION}{C.R} | {C.CYAN}{my_name}{C.R} | {C.GREEN}{online}{C.R}/{total} online | {now} | API ({age_str})
""")

    # Build server lookup by name
    server_map = {s.get("name", "?"): s for s in servers}

    # Merge API VPS list into SERVERS so connected VPSes show (local + all mesh nodes)
    for s in servers:
        name = s.get("name", "?")
        if name and name != "?":
            ip = s.get("ip") or SERVERS.get(name) or "?"
            SERVERS[name] = ip

    # Display nodes: local + VPSes from config + API (exclude placeholders)
    _skip = set(_get_config("dashboard_skip", []))
    if _get_config("dashboard_skip_placeholders", True):
        _skip |= {f"l{i}" for i in range(1, 21)}
    all_names = set(SERVERS.keys()) | {s.get("name") for s in servers if s.get("name")}
    display_names = sorted(n for n in all_names if n not in _skip)

    # Table 1: Connectivity (fixed column widths)
    _sep1 = 58
    print(f"  {C.BOLD}CONNECTIVITY{C.R}")
    print(f"  {C.DIM}" + "─" * _sep1 + f"{C.R}")
    print(f"  {C.BOLD}{'Node':<8} │ {'VPN IP':<15} │ {'Status':<10} │ Source{C.R}")
    print(f"  {C.DIM}" + "─" * _sep1 + f"{C.R}")
    for name in display_names:
        s = server_map.get(name) or server_map.get(name.upper()) or server_map.get(name.capitalize())
        if not s and name == "local":
            s = server_map.get("Mac")
        ip = SERVERS.get(name) or (s.get("ip", "?") if s else "?")
        nm = name[:8] if len(name) > 8 else name
        if not s:
            print(f"  {C.GRAY}{nm:<8}{C.R} │ {str(ip):<15} │ {C.DIM}{'?':<10}{C.R} │ -")
        elif not s.get("online"):
            print(f"  {C.RED}{nm:<8}{C.R} │ {str(ip):<15} │ {C.RED}{'● OFFLINE':<10}{C.R} │ {str(s.get('source', '?'))[:6]}")
        else:
            print(f"  {C.GREEN}{nm:<8}{C.R} │ {str(ip):<15} │ {C.GREEN}{'● ONLINE':<10}{C.R} │ {str(s.get('source', '?'))[:6]}")

    # Table 2: Performance (fixed widths: Node8 Load7 Mem18 Disk18 Uptime10)
    _sep2 = 72
    print()
    print(f"  {C.BOLD}PERFORMANCE{C.R}")
    print(f"  {C.DIM}" + "─" * _sep2 + f"{C.R}")
    print(f"  {C.BOLD}{'Node':<8} │ {'Load':<6} │ {'Memory':<16} │ {'Disk':<16} │ Uptime{C.R}")
    print(f"  {C.DIM}" + "─" * _sep2 + f"{C.R}")
    for name in display_names:
        s = server_map.get(name) or server_map.get(name.upper()) or server_map.get(name.capitalize())
        if not s and name == "local":
            s = server_map.get("Mac")
        nm = name[:8] if len(name) > 8 else name

        if not s or not s.get("online"):
            print(f"  {C.GRAY}{nm:<8}{C.R} │ {'-':<6} │ {C.DIM}{'░░░░░░░░ -':<16}{C.R} │ {C.DIM}{'░░░░░░░░ -':<16}{C.R} │ -")
            continue

        _load_raw = s.get("load", "?")
        _mem_raw = s.get("mem_pct", 0) or 0
        _has_data = (_load_raw not in ("?", "-", "", None) or _mem_raw > 0)
        if not _has_data:
            print(f"  {C.GRAY}{nm:<8}{C.R} │ {'?':<6} │ {C.DIM}{'░░░░░░░░ ?':<16}{C.R} │ {C.DIM}{'░░░░░░░░ ?':<16}{C.R} │ -")
            continue

        load_val = s.get("load", "-")
        if isinstance(load_val, str):
            parts = load_val.replace(",", " ").split()
            load_val = parts[0] if parts else "-"
        try:
            load_f = float(load_val)
            load_s = f"{load_f:6.2f}"
            if load_f > 5:
                load_str = f"{C.RED}{load_s}{C.R}"
            elif load_f > 2:
                load_str = f"{C.YELLOW}{load_s}{C.R}"
            else:
                load_str = f"{C.GREEN}{load_s}{C.R}"
        except Exception:
            load_str = f"{C.GRAY}{str(load_val)[:6]:<6}{C.R}"

        mem_pct = s.get("mem_pct", 0) or 0
        disk_pct = s.get("disk_pct", 0)
        if isinstance(disk_pct, str):
            disk_pct = int(disk_pct.replace("%", "") or 0)
        mem_bar = bar_graph(mem_pct, 8)
        disk_bar = bar_graph(disk_pct, 8)
        # Fixed visible width (bar=8 + space + pct=6 + pad) for alignment
        mem_col = f"{mem_bar} {mem_pct:>5.1f}% "
        disk_col = f"{disk_bar} {disk_pct:>5.1f}% "
        uptime = (str(s.get("uptime", "-"))[:10] or "-").ljust(10)

        print(f"  {C.CYAN}{nm:<8}{C.R} │ {load_str} │ {mem_col} │ {disk_col} │ {uptime}")

    print(f"  {C.DIM}" + "═" * _sep2 + f"{C.R}")

    # Alerts with colors
    if alerts:
        print(f"  {C.RED}{C.BOLD}⚠ ALERTS:{C.R} {C.YELLOW}{', '.join(alerts)}{C.R}")
    if offline:
        print(f"  {C.RED}{C.BOLD}✗ OFFLINE:{C.R} {C.RED}{', '.join(offline)}{C.R}")

    # Keyboard shortcuts hint
    print()
    print(f"  {C.DIM}Commands: {C.R}{C.CYAN}mpop full{C.R}{C.DIM} ({t('cmd_full')}) │ {C.R}{C.CYAN}mpop watch{C.R}{C.DIM} ({t('cmd_watch')}) │ {C.R}{C.CYAN}mpop audit{C.R}{C.DIM} ({t('cmd_audit')}){C.R}")
    print()

def _check_one_dashboard(name, ip):
    """Helper: check one server's local dashboard, return (name, count, nodes, status)"""
    _cmd = (
        "curl -s -m 3 localhost:8721/api/status 2>/dev/null >/tmp/_d.json; "
        "python3 -c \"import json,os; f='/tmp/_d.json'; d=json.load(open(f)) if os.path.exists(f) and os.path.getsize(f)>0 else dict(nodes=[]); n=d.get('nodes',[]); print(len(n),'nodes:',','.join(str(x.get('node','?')) for x in n))\" 2>/dev/null || echo '0 nodes: ?'"
    )
    result = ssh_exec(name, _cmd, timeout=10)
    count, nodes = "?", ""
    if result:
        if " nodes: " in result:
            a, _, b = result.partition(" nodes: ")
            count = a.strip().split()[0] if a.strip() else "?"
            nodes = (b.strip() or "").replace("\n", ", ").split("__END__")[0].strip().rstrip(",")[:55]
        elif "nodes:" in result:
            idx = result.find("nodes:")
            pre = result[:idx].strip()
            count = pre.split()[0] if pre else "?"
            nodes = result[idx+6:].strip().replace("\n", ", ").split("__END__")[0].strip().rstrip(",")[:55]
    try:
        n = int(str(count))
        status = f"{C.GREEN}✓{C.R}" if n > 0 else f"{C.YELLOW}○{C.R}"
    except Exception:
        status = f"{C.YELLOW}?{C.R}"
    return (name, count, nodes, status)

def cmd_dashboard_check(args):
    """Check each server's local dashboard — 서버별로 각자 받은 리포트 수 확인"""
    get_servers()
    print(f"\n  {C.CYAN}각 서버의 localhost:8721 dashboard 수신 현황{C.R}")
    print("  " + "─" * 60)
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(_check_one_dashboard, name, ip): name for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name = futures[future]
            try:
                results[name] = future.result()
            except Exception:
                results[name] = (name, "?", "", f"{C.YELLOW}?{C.R}")
    for name in SERVERS:
        row = results.get(name, (name, "?", "", f"{C.YELLOW}?{C.R}"))
        _, count, nodes, status = row
        print(f"  {name:8} {status} {str(count):>3} nodes  {nodes}")
    print("  " + "─" * 60)
    print(f"\n  {C.DIM}각 서버는 localhost:8721 에서 자신이 받은 리포트를 제공합니다.{C.R}")
    print(f"  {C.DIM}mpop dashboard  → 기본 대시보드 (한 서버 API에서 조회){C.R}\n")

def cmd_dashboard_live(args):
    """Live dashboard - direct vssh scan (original implementation)"""
    vpn_ip = get_vpn_ip()
    my_name = get_my_name()

    # Parallel check all servers
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    online = sum(1 for r in results.values() if r.get("ping"))
    offline = [n for n, r in results.items() if not r.get("ping")]
    total = len(SERVERS)

    # Check for security alerts
    alerts = []
    for name, r in results.items():
        if r.get("stats"):
            try:
                fails = int(r["stats"].get("failed_logins", "0"))
                if fails > 10:
                    alerts.append(f"{name}:{fails}fail")
            except Exception: pass
            try:
                disk = int(r["stats"].get("disk", "0%").replace('%', ''))
                if disk > 90:
                    alerts.append(f"{name}:disk{disk}%")
            except Exception: pass

    # Header
    status_icon = "OK" if not offline else "WARN" if online > total//2 else "CRIT"
    now = datetime.now().strftime("%H:%M:%S")
    print(f"""
  [{status_icon}] MeshPOP v{VERSION} | {my_name} | {online}/{total} online | {now} | LIVE
""")

    # Table 1: Connectivity
    print("  CONNECTIVITY")
    print("  " + "-" * 42)
    print("  Node | VPN IP          | Status")
    print("  " + "-" * 42)
    for name in SERVERS.keys():
        r = results.get(name, {})
        ip = SERVERS[name]
        if r.get("me"):
            print(f"  {name:4} | {ip:15} | << HERE")
        elif not r.get("ping"):
            print(f"  {name:4} | {ip:15} | OFFLINE")
        else:
            ssh_s = "+" if r.get("ssh") else "-"
            vssh_s = "+" if r.get("vssh") else "-"
            print(f"  {name:4} | {ip:15} | ssh:{ssh_s} vssh:{vssh_s}")

    # Table 2: Performance
    print()
    print("  PERFORMANCE")
    print("  " + "-" * 50)
    print("  Node | Load 1/5/15      | CPU  | Mem  | Disk")
    print("  " + "-" * 50)
    for name in SERVERS.keys():
        r = results.get(name, {})
        stats = r.get("stats") or {}

        if not r.get("ping") and not r.get("me"):
            print(f"  {name:4} |        -         |  -   |  -   |  -")
            continue

        load = stats.get("load", "-")
        load_full = stats.get("load_full", "-")[:14]
        cores = stats.get("cores", "1")
        cpu = stats.get("cpu", "-")[:4]
        disk = stats.get("disk", "-")
        mem = stats.get("mem", "-")[:4]

        # Load indicator
        try:
            l = float(load)
            if l > 5:
                load_s = f"!!{load_full}"
            elif l > 2:
                load_s = f"* {load_full}"
            else:
                load_s = f"  {load_full}"
        except Exception:
            load_s = f"  {load_full}"

        # Disk indicator
        disk_i = disk_icon(disk)
        disk_s = f"{disk_i}{disk}"

        print(f"  {name:4} | {load_s:16} | {cpu:4} | {mem:4} | {disk_s}")

    # Table 3: Uptime & Services
    print()
    print("  UPTIME & SERVICES")
    print("  " + "-" * 50)
    print("  Node | Uptime       | Services")
    print("  " + "-" * 50)
    for name in SERVERS.keys():
        r = results.get(name, {})
        stats = r.get("stats") or {}

        if not r.get("ping") and not r.get("me"):
            print(f"  {name:4} |      -       | -")
            continue

        uptime = stats.get("uptime", "-")[:12]
        svcs = stats.get("services", "-")[:25]
        print(f"  {name:4} | {uptime:12} | {svcs}")

    print("  ==========================================================================================")

    # Alerts
    if alerts:
        print(f"  [!] ALERTS: {', '.join(alerts)}")
    if offline:
        print(f"  [!] OFFLINE: {', '.join(offline)}")

    print()

def cmd_network(args):
    """Network/SSH connection info"""
    print("""
  ╔═══════════════════════════════════════════════════════════════════╗
  ║                    MeshPOP Network Info                           ║
  ╚═══════════════════════════════════════════════════════════════════╝
""")
    print("  Node | VPN IP          | SSH Connection                    | Note")
    print("  " + "-" * 70)

    for name in list(_get_config("servers", {}).keys()) or ["server1", "server2"]:
        vpn_ip = SERVER_VPN_IP.get(name, "?")
        ssh_info = SERVER_SSH.get(name, {})
        user = ssh_info.get("user", "?")
        port = ssh_info.get("ssh_port", 22)

        if ssh_info.get("public_ip"):
            # VPS - connect via public IP
            conn = f"ssh -p {port} {user}@{ssh_info['public_ip']}"
        elif ssh_info.get("tailscale_ip"):
            # NAT - connect via Tailscale
            conn = f"ssh -p {port} {user}@{ssh_info['tailscale_ip']}"
        elif ssh_info.get("local"):
            conn = "(local)"
        else:
            conn = "?"

        hint = ssh_info.get("pass_hint", "key")
        ctx = SERVER_CONTEXT.get(name, {})
        note = ctx.get("type", "")

        # Check VPN status
        status = "✅" if ping_server(vpn_ip, timeout=1) else "❌"

        print(f"  {name:4} | {vpn_ip:15} | {conn:33} | {hint:12} {status}")

    print()
    print("  Tailscale: tailscale status")
    print("  VPN status:  mpop vpn")
    print()

def cmd_vpn(args):
    """VPN status (wire + tailscale)"""
    print("""
  ╔═══════════════════════════════════════════════════════════════════╗
  ║                       VPN Status                                  ║
  ╚═══════════════════════════════════════════════════════════════════╝
""")

    # wire status
    print("  [ wire ]")
    online = 0
    offline = 0
    for name, vpn_ip in SERVER_VPN_IP.items():
        status = "✅" if ping_server(vpn_ip, timeout=1) else "❌"
        if status == "✅":
            online += 1
        else:
            offline += 1
        print(f"    {name}: {vpn_ip:15} {status}")

    print(f"\n    Total: {online}/10 online\n")

    # Server status (relay servers)
    print("  [ VPN Servers (HA) ]")
    relay_servers = [(n, s.get("public_ip")) for n, s in _get_server_ssh().items() if s.get("public_ip")]
    for name, ip in relay_servers:
        try:
            import urllib.request
            req = urllib.request.Request(f"http://{ip}:8790/health", method="GET")
            with urllib.request.urlopen(req, timeout=3) as resp:
                data = json.loads(resp.read())
                peers = data.get("peers", 0)
                print(f"    {name}: http://{ip}:8790 ✅ ({peers} peers)")
        except Exception:
            print(f"    {name}: http://{ip}:8790 ❌")

    # Tailscale status
    print("\n  [ Tailscale ]")
    try:
        result = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5)
        if result.returncode == 0:
            ts_data = json.loads(result.stdout)
            ts_self = ts_data.get("Self", {})
            print(f"    Self: {ts_self.get('HostName', '?')} ({ts_self.get('TailscaleIPs', ['?'])[0]})")

            peers = ts_data.get("Peer", {})
            ts_online = sum(1 for p in peers.values() if p.get("Online"))
            print(f"    Peers: {ts_online}/{len(peers)} online")
        else:
            print("    (not running)")
    except Exception as e:
        print(f"    Error: {e}")

    print()

def cmd_matrix(args):
    """Connectivity matrix - which server can reach which (parallel)"""
    vpn_ip = get_vpn_ip()
    my_name = get_my_name()
    server_list = list(SERVERS.keys())

    # Parallel: gather connectivity data from all servers at once
    def check_from(src_name, src_ip):
        if src_ip == vpn_ip:
            result = {}
            import threading as _th
            def _ping_check(dip):
                if dip == vpn_ip:
                    result[dip] = "*"
                elif ping_server(dip, timeout=1):
                    result[dip] = "+"
                else:
                    result[dip] = "-"
            _threads = [_th.Thread(target=_ping_check, args=(di,)) for di in SERVERS.values()]
            for _t in _threads: _t.start()
            for _t in _threads: _t.join(timeout=3)
            for di in SERVERS.values():
                if di not in result:
                    result[di] = "?"
            return src_name, result

        # For ssh-method servers, skip check_port and use ssh_exec with retry
        _srv_cfg = _get_config("servers", {}).get(src_name, {})
        _ssh_method = _srv_cfg.get("ssh_method", "")
        _can_check = False
        if _ssh_method == "ssh":
            _can_check = True
        elif check_port(src_ip, VSSH_PORT, timeout=2):
            _can_check = True
        if _can_check:
            reachable = check_connectivity_from(src_ip, server_list)
            if not reachable and _ssh_method == "ssh":
                import time; time.sleep(0.5)
                reachable = check_connectivity_from(src_ip, server_list)
            result = {}
            for dst_name, dst_ip in SERVERS.items():
                if dst_ip == src_ip:
                    result[dst_ip] = "*"
                elif reachable.get(dst_ip, False):
                    result[dst_ip] = "+"
                elif dst_ip in reachable:
                    result[dst_ip] = "-"
                else:
                    result[dst_ip] = "?"
            return src_name, result
        else:
            return src_name, {ip: "?" for ip in SERVERS.values()}

    from concurrent.futures import ThreadPoolExecutor, as_completed
    matrix_data = {}
    with ThreadPoolExecutor(max_workers=12) as ex:
        futures = {ex.submit(check_from, name, ip): name
                   for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name, result = future.result()
            matrix_data[name] = result

    # Print results
    print(f"""
  Connectivity Matrix (from {my_name})
  =========================================""")

    header = "  From\\To |"
    for name in SERVERS.keys():
        header += f" {name:3}|"
    print(header)
    print("  " + "-" * (10 + 5 * len(SERVERS)))

    for src_name in SERVERS.keys():
        data = matrix_data.get(src_name, {})
        row = f"  {src_name:7} |"
        for dst_name, dst_ip in SERVERS.items():
            symbol = data.get(dst_ip, "?")
            row += f"  {symbol} |"
        print(row)

    print("""
  Legend: * = self, + = reachable, - = unreachable, ? = unknown
""")

def cmd_services(args):
    """Show services running on all servers"""
    vpn_ip = get_vpn_ip()
    my_name = get_my_name()

    print(f"""
  Services Overview
  ====================================""")

    # Parallel fetch
    def get_services(name, ip):
        if ip == vpn_ip:
            try:
                result = subprocess.run(
                    "systemctl list-units --type=service --state=running 2>/dev/null | grep -E 'vssh|wire|docker|nginx|redis|postgres|mysql|ssh' | awk '{print $1}' | sed 's/.service//' | tr '\\n' ',' | sed 's/,$//' || launchctl list 2>/dev/null | grep -E 'vssh|wire' | awk '{print $3}' | tr '\\n' ','",
                    shell=True, capture_output=True, text=True, timeout=5)
                return result.stdout.strip()
            except Exception:
                return "-"
        else:
            cmd = "systemctl list-units --type=service --state=running 2>/dev/null | grep -E 'vssh|wire|docker|nginx|redis|postgres|mysql|ssh' | awk '{print $1}' | sed 's/.service//' | tr '\\n' ',' | sed 's/,$//' || echo '-'"
            result = vssh_exec(ip, cmd, timeout=5)
            return result if result else "-"

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(get_services, name, ip): name
                   for name, ip in SERVERS.items()}
        results = {}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    for name, ip in SERVERS.items():
        svcs = results.get(name, "-")
        status = "<<" if ip == vpn_ip else "  "
        print(f"  {name:4} | {svcs[:60]} {status}")

    print()

def cmd_security(args):
    """Security status - failed logins, open ports, connections"""
    vpn_ip = get_vpn_ip()
    my_name = get_my_name()

    print(f"""
  Security Overview
  ====================================""")

    def get_security(name, ip):
        cmd = """
echo "FAIL:$(journalctl -u sshd --since '1 hour ago' 2>/dev/null | grep -c 'Failed' || grep -c 'Failed password' /var/log/auth.log 2>/dev/null || echo '0')"
echo "CONN:$(ss -tn 2>/dev/null | grep -c ESTAB || netstat -an | grep -c ESTABLISHED)"
echo "PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -u | tr '\\n' ',' | sed 's/,$//' || netstat -tln | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -u | tr '\\n' ',')"
"""
        if ip == vpn_ip:
            try:
                result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
                return result.stdout.strip()
            except Exception:
                return ""
        else:
            return vssh_exec(ip, cmd.strip(), timeout=5) or ""

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(get_security, name, ip): name
                   for name, ip in SERVERS.items()}
        results = {}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    print("  Node | Failed (1h) | Connections | Open Ports")
    print("  " + "-" * 60)

    for name, ip in SERVERS.items():
        data = results.get(name, "")
        fail = "?"
        conn = "?"
        ports = "-"

        for line in data.split('\n'):
            if line.startswith("FAIL:"):
                fail = line[5:].strip()
            elif line.startswith("CONN:"):
                conn = line[5:].strip()
            elif line.startswith("PORTS:"):
                ports = line[6:].strip()[:40]

        # Alert icon
        try:
            f = int(fail)
            fail_s = f"!! {fail:3}" if f > 10 else f"   {fail:3}"
        except Exception:
            fail_s = f"   {fail:3}"

        status = "<<" if ip == vpn_ip else "  "
        print(f"  {name:4} | {fail_s:11} | {conn:11} | {ports} {status}")

    print()

# Well-known risky ports
RISKY_PORTS = {
    21: ("FTP", "HIGH", "Unencrypted file transfer, use SFTP instead"),
    23: ("Telnet", "CRITICAL", "Unencrypted remote access, use SSH"),
    25: ("SMTP", "MEDIUM", "Mail server, ensure authentication required"),
    110: ("POP3", "HIGH", "Unencrypted mail, use POP3S/IMAPS"),
    143: ("IMAP", "HIGH", "Unencrypted mail, use IMAPS"),
    445: ("SMB", "CRITICAL", "Windows file sharing, often targeted"),
    3306: ("MySQL", "HIGH", "Database, should not be public"),
    5432: ("PostgreSQL", "HIGH", "Database, should not be public"),
    6379: ("Redis", "CRITICAL", "No auth by default, highly targeted"),
    27017: ("MongoDB", "CRITICAL", "No auth by default, highly targeted"),
    11211: ("Memcached", "CRITICAL", "DDoS amplification risk"),
}

SAFE_PORTS = {22, 80, 443, 51820, 51830, 48291, 8080, 8443}

def analyze_ports(ports_str):
    """Analyze open ports and return risk assessment"""
    risks = []
    warnings = []

    try:
        ports = [int(p.strip()) for p in ports_str.split(',') if p.strip().isdigit()]
    except Exception:
        return [], []

    for port in ports:
        if port in RISKY_PORTS:
            name, level, advice = RISKY_PORTS[port]
            risks.append({"port": port, "service": name, "level": level, "advice": advice})
        elif port not in SAFE_PORTS and port < 1024:
            warnings.append({"port": port, "note": "System port, verify if needed"})
        elif port > 30000 and port not in SAFE_PORTS:
            pass  # High ports are usually fine

    return risks, warnings

def cmd_audit(args):
    """Deep security audit with attack logs and recommendations"""
    target = args[0] if args else None
    quick_mode = "--quick" in args or "-q" in args
    vpn_ip = get_vpn_ip()

    if target and target in SERVERS:
        servers_to_check = {target: SERVERS[target]}
    else:
        servers_to_check = SERVERS

    print(f"""
  {C.BOLD}╔══════════════════════════════════════════════════════════╗
  ║             🔒 SECURITY AUDIT REPORT                     ║
  ║   Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}                       ║
  ╚══════════════════════════════════════════════════════════╝{C.R}""")

    total_issues = 0
    for name, ip in servers_to_check.items():
        print(f"\n  {C.BOLD}{C.CYAN}[{name}]{C.R} {C.DIM}{ip}{C.R}")
        print(f"  {C.DIM}" + "─" * 60 + f"{C.R}")

        if not ping_server(ip):
            print(f"  {C.RED}Status: OFFLINE - Cannot audit{C.R}")
            continue

        # Comprehensive security check
        cmd = """
echo "=== ATTACK LOGS (24h) ==="
CUTOFF=$(date -d '24 hours ago' '+%Y-%m-%dT%H:%M' 2>/dev/null || date -v-24H '+%Y-%m-%dT%H:%M')
echo "SSH_FAIL:$(awk -v d=$CUTOFF '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep -c 'Failed password' || journalctl -u sshd -u ssh --since '24 hours ago' 2>/dev/null | grep -c 'Failed' || echo '0')"
echo "SSH_INVALID:$(awk -v d=$CUTOFF '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep -c 'Invalid user' || journalctl -u sshd -u ssh --since '24 hours ago' 2>/dev/null | grep -c 'Invalid user' || echo '0')"
echo "SSH_BREAK:$(awk -v d=$CUTOFF '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep -c 'Break-in' || echo '0')"
echo "BANNED:$(fail2ban-client status sshd 2>/dev/null | grep 'Currently banned' | awk '{print $NF}' || echo '0')"
echo "TOTAL_BANNED:$(fail2ban-client status sshd 2>/dev/null | grep 'Total banned' | awk '{print $NF}' || echo 'N/A')"

echo "=== TOP ATTACKERS 24h (external only) ==="
awk -v d=$CUTOFF '\\$0 >= d' /var/log/auth.log 2>/dev/null | grep 'Failed password' | grep -v '10\\.99\\.' | grep -oE 'from [0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' | sort | uniq -c | sort -rn | head -5 || echo "  No data"

echo "=== RECENT SUCCESSFUL LOGINS ==="
last -5 2>/dev/null | head -5 || echo "  No data"

echo "=== OPEN PORTS ==="
ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE ':[0-9]+$' | tr -d ':' | sort -un | tr '\\n' ',' | sed 's/,$//' || netstat -tln | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -un | tr '\\n' ','

echo "=== FIREWALL STATUS ==="
ufw status 2>/dev/null | head -10 || iptables -L -n 2>/dev/null | head -10 || echo "No firewall info"

echo "=== LISTENING SERVICES ==="
ss -tlnp 2>/dev/null | grep LISTEN | head -10 || netstat -tlnp 2>/dev/null | grep LISTEN | head -10

echo "=== ACTIVE CONNECTIONS (External) ==="
ss -tn 2>/dev/null | grep ESTAB | grep -v '10.99' | grep -v '127.0.0.1' | head -5 || echo "  None"

echo "=== SUDO LOGS ==="
journalctl -u sudo --since '24 hours ago' 2>/dev/null | tail -5 || grep sudo /var/log/auth.log 2>/dev/null | tail -5 || echo "  No data"

echo "=== SECURITY PACKAGES ==="
dpkg -l 2>/dev/null | grep -E 'fail2ban|ufw|iptables|crowdsec' | awk '{print $2,$3}' | head -5 || rpm -qa 2>/dev/null | grep -E 'fail2ban|firewalld' | head -5 || echo "  Unknown"
"""
        if ip == vpn_ip:
            try:
                result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
                output = result.stdout
            except Exception:
                output = ""
        else:
            output = vssh_exec(ip, cmd, timeout=30) or ""

        if not output:
            print("  [!] Could not retrieve security data")
            continue

        # Parse and display
        sections = {}
        current_section = None
        for line in output.split('\n'):
            if line.startswith("==="):
                current_section = line.replace("===", "").strip()
                sections[current_section] = []
            elif current_section and line.strip():
                sections[current_section].append(line.strip())

        # Attack Summary
        ssh_fail = "0"
        ssh_invalid = "0"
        banned = "0"
        for line in sections.get("ATTACK LOGS", []):
            if line.startswith("SSH_FAIL:"):
                ssh_fail = line.split(":")[1]
            elif line.startswith("SSH_INVALID:"):
                ssh_invalid = line.split(":")[1]
            elif line.startswith("BANNED:"):
                banned = line.split(":")[1]

        print(f"\n  {C.BOLD}>> Attack Summary (24h){C.R}")
        try:
            fail_n = int(ssh_fail)
            invalid_n = int(ssh_invalid)
            banned_n = int(banned) if banned != "N/A" else 0

            if fail_n > 100:
                print(f"     {C.RED}{C.BOLD}⚠ Failed SSH:{C.R} {C.RED}{ssh_fail} (HEAVY ATTACK!){C.R}")
                total_issues += 1
            elif fail_n > 10:
                print(f"     {C.YELLOW}⚠ Failed SSH:{C.R} {C.YELLOW}{ssh_fail} (Moderate){C.R}")
            else:
                print(f"     {C.GREEN}✓ Failed SSH:{C.R} {ssh_fail}")

            print(f"     Invalid users: {ssh_invalid}")
            print(f"     Banned IPs: {C.GREEN}{banned}{C.R}" if banned_n > 0 else f"     Banned IPs: {banned}")
        except Exception:
            print(f"     Failed: {ssh_fail}, Invalid: {ssh_invalid}, Banned: {banned}")

        # Top Attackers
        attackers = sections.get("TOP ATTACKERS", [])
        if attackers and attackers[0] != "No data":
            print(f"\n  >> Top Attackers")
            for a in attackers[:5]:
                print(f"     {a}")

        # Port Analysis
        ports_data = sections.get("OPEN PORTS", [])
        if ports_data:
            ports_str = ports_data[0] if ports_data else ""
            risks, warnings = analyze_ports(ports_str)

            print(f"\n  {C.BOLD}>> Port Analysis{C.R}")
            print(f"     Open: {C.CYAN}{ports_str[:60]}{C.R}")

            if risks:
                print(f"\n     {C.RED}{C.BOLD}⚠ RISKY PORTS:{C.R}")
                for r in risks:
                    level_color = C.RED if r['level'] == 'CRITICAL' else C.YELLOW
                    print(f"         {level_color}Port {r['port']} ({r['service']}): {r['level']}{C.R}")
                    print(f"         {C.DIM}→ {r['advice']}{C.R}")
                total_issues += len(risks)

            if warnings:
                print(f"\n     {C.YELLOW}⚠ Verify:{C.R}")
                for w in warnings:
                    print(f"         Port {w['port']}: {w['note']}")

            if not risks and not warnings:
                print(f"     {C.GREEN}✓ No risky ports detected{C.R}")

        # Recent Logins
        logins = sections.get("RECENT SUCCESSFUL LOGINS", [])
        if logins and logins[0] != "No data":
            print(f"\n  >> Recent Logins")
            for l in logins[:3]:
                print(f"     {l[:70]}")

        # Active External Connections
        ext_conn = sections.get("ACTIVE CONNECTIONS (External)", [])
        if ext_conn and ext_conn[0] != "None":
            print(f"\n  >> External Connections")
            for c in ext_conn[:5]:
                print(f"     {c}")

        # Recommendations
        print(f"\n  >> Recommendations")
        recommendations = []

        try:
            if int(ssh_fail) > 100:
                recommendations.append("Install/configure fail2ban to block brute force")
            if int(ssh_fail) > 50 and banned == "0":
                recommendations.append("fail2ban not active, consider enabling it")
        except Exception: pass

        if risks:
            recommendations.append("Close or restrict access to risky ports listed above")

        security_pkg = sections.get("SECURITY PACKAGES", [])
        if "fail2ban" not in str(security_pkg):
            recommendations.append("Consider installing fail2ban")
        if "ufw" not in str(security_pkg) and "iptables" not in str(security_pkg):
            recommendations.append("Consider enabling UFW or iptables firewall")

        if recommendations:
            for i, r in enumerate(recommendations, 1):
                print(f"     {C.YELLOW}{i}. {r}{C.R}")
        else:
            print(f"     {C.GREEN}✓ No critical issues found{C.R}")

    # Final Summary
    print(f"""
  {C.BOLD}╔══════════════════════════════════════════════════════════╗
  ║                    AUDIT SUMMARY                         ║
  ╠══════════════════════════════════════════════════════════╣{C.R}""")
    if total_issues > 0:
        print(f"  {C.BOLD}║  {C.RED}⚠ {total_issues} issues found - Action recommended{C.R}{C.BOLD}             ║{C.R}")
    else:
        print(f"  {C.BOLD}║  {C.GREEN}✓ All servers passed security check{C.R}{C.BOLD}                  ║{C.R}")
    print(f"""  {C.BOLD}╚══════════════════════════════════════════════════════════╝{C.R}

  {C.DIM}Tip: Run 'mpop enforce' for AI-powered security analysis{C.R}
""")

def cmd_info(args):
    """Detailed info for a specific server or all servers"""
    target = args[0] if args else None

    # If no target specified, show all servers
    if not target:
        for name in SERVERS.keys():
            show_server_info(name, SERVERS[name])
        return

    if target in SERVERS:
        show_server_info(target, SERVERS[target])
    else:
        print(f"  Unknown server: {target}")
        print(f"  Available: {', '.join(SERVERS.keys())}")

def show_server_info(name, ip):
    """Show detailed info for a server"""
    print(f"\n  [{name}] {ip}")
    print("  " + "=" * 60)

    if not ping_server(ip):
        print("  Status: OFFLINE")
        return

    cmd = """
echo "SYSTEM: $(hostname) | $(uname -m)"
echo "CPU: $(nproc 2>/dev/null || sysctl -n hw.ncpu) cores | Load: $(cat /proc/loadavg 2>/dev/null | cut -d' ' -f1-3 || sysctl -n vm.loadavg | tr -d '{}')"
echo "MEM: $(free -h 2>/dev/null | awk 'NR==2{print $3\"/\"$2}' || echo 'N/A')"
echo "DISK: $(df -h / 2>/dev/null | tail -1 | awk '{print $3\"/\"$2\" (\"$5\")\"}')"
echo "GPU: $(nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader 2>/dev/null | head -1 || echo 'None')"
echo "UPTIME: $(uptime -p 2>/dev/null | sed 's/up //' || uptime | sed 's/.*up //' | sed 's/,.*//')"
echo "SERVICES: $(systemctl list-units --type=service --state=running 2>/dev/null | grep -oE 'vssh|wire|docker|nginx|redis|postgres|ssh' | sort -u | tr '\\n' ' ' || echo '-')"
echo "DOCKER: $(docker ps --format '{{.Names}}' 2>/dev/null | tr '\\n' ' ' | head -c 50 || echo 'N/A')"
echo "PORTS: $(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -un | head -10 | tr '\\n' ' ' || echo '-')"
"""
    result = ssh_exec(name, cmd, timeout=10)
    if result:
        for line in result.split('\n'):
            if line.strip():
                print(f"  {line}")
    else:
        print("  [!] Could not connect")

def cmd_exec(args):
    """Execute command on one or all servers"""
    if len(args) < 1:
        print("  Usage: mpop exec <command>")
        print("         mpop exec -t <server> <command>")
        return

    target = None
    cmd_start = 0

    if args[0] == "-t" and len(args) >= 3:
        target = args[1]
        cmd_start = 2
    elif args[0] == "-a":
        target = "all"
        cmd_start = 1

    cmd = " ".join(args[cmd_start:])
    if not cmd:
        print("  No command specified")
        return

    # Auto-fix awk commands: escape $ in double-quoted awk to single-quoted
    # e.g., awk "NR==2{print $3}" -> awk 'NR==2{print $3}'
    import re
    import base64

    def fix_awk_quotes(m):
        inner = m.group(1)
        return "awk '" + inner + "'"
    cmd = re.sub(r'awk\s+"([^"]+)"', fix_awk_quotes, cmd)

    # Detect complex quoting that needs base64 encoding
    def needs_base64(c):
        single = c.count("'")
        # Multiple single-quoted sections with pipes = complex
        if single >= 4 and '|' in c:
            return True
        # Has awk with comparison operators
        if re.search(r"awk\s+'[^']*[><=][^']*'", c):
            return True
        # Multiple pipes with quotes
        if c.count('|') >= 2 and single >= 2:
            return True
        return False

    use_base64 = needs_base64(cmd)
    original_cmd = cmd
    if use_base64:
        # Encode command as base64, decode and execute on remote
        encoded = base64.b64encode(cmd.encode()).decode()
        cmd = f"echo {encoded} | base64 -d | bash"

    vpn_ip = get_vpn_ip()

    if target and target != "all":
        # Support comma-separated servers: -t node1,node2,node3
        targets = [t.strip() for t in target.split(",")]

        for t in targets:
            if t not in SERVERS:
                print(f"  Unknown server: {t}")
                continue
            print(f"\n  [{t}] {original_cmd if use_base64 else cmd}")
            print("  " + "-" * 50)
            result = ssh_exec(t, cmd, timeout=30)
            if result:
                for line in result.split('\n'):
                    print(f"  {line}")
                _log_history(f"{t}:{cmd[:30]}", "OK")
            else:
                print("  [!] Failed to execute")
                _log_history(f"{t}:{cmd[:30]}", "FAIL")
    else:
        # All servers
        _log_history(f"all:{original_cmd[:30] if use_base64 else cmd[:30]}", "START")
        for name, ip in SERVERS.items():
            print(f"\n  [{name}] {original_cmd if use_base64 else cmd}")
            print("  " + "-" * 50)
            result = ssh_exec(name, cmd, timeout=30)
            if result:
                for line in result.split('\n')[:20]:
                    print(f"  {line}")
            else:
                print("  [!] Failed to connect")
        _log_history(f"all:{cmd[:30]}", "DONE")


def cmd_servers(args):
    """List all servers with roles"""
    print("\n  Server List")
    print("  " + "=" * 70)
    print("  Node | VPN IP          | Role")
    print("  " + "-" * 70)
    for name, ip in SERVERS.items():
        role = SERVER_ROLES.get(name, "Unknown")
        print(f"  {name:4} | {ip:15} | {role}")
    print()

def analyze_server_role(name, ip):
    """Analyze what a server does based on processes/services (compact)"""
    cmd = """
echo "SVCS:$(systemctl list-units --type=service --state=running 2>/dev/null | grep -oE 'docker|nginx|redis|postgres|mysql|vssh|wire|ollama|ssh' | sort -u | tr '\\n' ',' || launchctl list 2>/dev/null | wc -l | xargs -I{} echo 'launchd:{}')"
echo "DOCKER:$(docker ps --format '{{.Image}}' 2>/dev/null | head -5 | tr '\\n' ',' || echo 'none')"
echo "PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -un | head -10 | tr '\\n' ',' || echo '-')"
echo "GPU:$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo 'none')"
"""
    result = vssh_exec(ip, cmd, timeout=5)
    return result or ""

def cmd_roles(args):
    """Analyze and report server roles using AI (GPT-mini default)"""
    use_gpt4 = "--gpt4" in args
    use_gpt  = "--gpt" in args or use_gpt4
    gpt_model = "gpt-4o" if use_gpt4 else "gpt-4o-mini"
    force_engine = "openai" if use_gpt else None
    force_model  = gpt_model if use_gpt else None
    target = None

    for arg in args:
        if arg in SERVERS:
            target = arg

    lang = detect_system_language()
    cfg_engine, cfg_model = _get_ai_config()
    engine = f"GPT ({gpt_model})" if use_gpt else f"{cfg_engine} ({cfg_model})"

    print(f"""
  ========================================
   SERVER ROLE ANALYSIS
   Engine: {engine}
  ========================================
""")

    if target:
        servers_to_check = {target: SERVERS[target]}
    else:
        servers_to_check = SERVERS

    # Collect data from all servers IN PARALLEL
    all_data = {}
    print("  Analyzing servers (parallel)...")

    def collect_role(name, ip):
        if not ping_server(ip, timeout=1):
            return name, {"status": "OFFLINE", "data": ""}
        data = analyze_server_role(name, ip)
        return name, {"status": "ONLINE", "data": data}

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(collect_role, name, ip): name for name, ip in servers_to_check.items()}
        for future in as_completed(futures):
            name, result = future.result()
            all_data[name] = result
            status = "OK" if result["status"] == "ONLINE" else "OFFLINE"
            print(f"    {name}: {status}")

    # Build prompt for LLM
    prompt = f"""Analyze the following server data and determine each server's PRIMARY ROLE and PURPOSE.

For each server, identify:
1. What is the main purpose of this server?
2. What key services/applications is it running?
3. What scripts or agents are deployed?
4. Any notable configurations?

SERVER DATA:
"""
    for name, info in all_data.items():
        known_role = SERVER_ROLES.get(name, "Unknown")
        prompt += f"\n=== {name} ({SERVERS[name]}) ===\n"
        prompt += f"Known role: {known_role}\n"
        prompt += f"Status: {info['status']}\n"
        if info['data']:
            prompt += f"Collected data:\n{info['data'][:3000]}\n"

    prompt += f"""

Based on this data, provide a {lang} report with:
1. A summary table showing each server's role
2. Detailed analysis of each server
3. Any concerns or recommendations

Be specific about what each server ACTUALLY does based on the data."""

    print("\n  Generating AI analysis...\n")

    report = ai_generate(prompt, lang=lang, force_engine=force_engine, force_model=force_model)

    report = report or "[No response from LLM]"

    print("  " + "=" * 60)
    print()
    for line in report.split('\n'):
        print(f"  {line}")
    print()
    print("  " + "=" * 60)
    print()

def get_system_prompt(lang):
    """Get system prompt for LLM"""
    return f"""You are an expert DevOps/SRE analyst for MeshPOP, a private VPN mesh network.

CONTEXT:
- Servers communicate over a private VPN mesh (WireGuard-based)
- Internal ports (vssh: 48291, wire: 8790) are not public-facing
- Redis/databases bound to 127.0.0.1 or VPN IPs are SAFE (not public)
- Some servers may run compute-intensive workloads (AI, video, builds) — high load may be expected

YOUR TASK:
Analyze the data and provide a {lang} report with:
1. Executive Summary (2-3 sentences, overall health)
2. Critical Issues (only REAL problems, not false alarms)
3. Security Concerns (focus on actual risks, not theoretical)
4. Performance Insights (trends, anomalies)
5. Actionable Recommendations (specific, prioritized)

Be practical, not paranoid. Distinguish between normal ops and real problems.
IMPORTANT: Write the ENTIRE report in {lang}."""

def _get_ai_config():
    """Read AI engine settings from config. Returns (engine, model)

    Config ~/.mpop/config.json:
      "ai": {
        "engine": "ollama",           // ollama (default) | openai | claude | gemini
        "ollama_model": "qwen2.5:14b",
        "openai_model": "gpt-4o-mini",
        "claude_model": "claude-sonnet-4-20250514",
        "gemini_model": "gemini-2.0-flash"
      }

    API keys via mpop secret:
      mpop secret set openai        # OpenAI API key
      mpop secret set anthropic     # Anthropic API key
      mpop secret set gemini        # Google AI Studio key

    Or use local Ollama (free, default):
      https://ollama.com  -> ollama pull qwen2.5:14b
    """
    cfg = _load_config()
    ai_cfg = cfg.get("ai", {})
    engine = ai_cfg.get("engine", "ollama")
    model = ai_cfg.get("model", "")
    if not model:
        defaults = {
            "openai":  ai_cfg.get("openai_model",  "gpt-4o-mini"),
            "claude":  ai_cfg.get("claude_model",  "claude-sonnet-4-20250514"),
            "gemini":  ai_cfg.get("gemini_model",  "gemini-2.0-flash"),
            "ollama":  ai_cfg.get("ollama_model",  "qwen2.5:14b"),
        }
        model = defaults.get(engine, ai_cfg.get("ollama_model", "qwen2.5:14b"))
    return engine, model

def _call_claude_generate(prompt, model, lang):
    """Call Anthropic Claude API"""
    import urllib.request, json as _j
    secrets = _load_secrets()
    api_key = secrets.get("anthropic", "") or os.environ.get("ANTHROPIC_API_KEY", "")
    if not api_key:
        return "[Claude API key not set. Run: mpop secret set anthropic]"
    system_prompt = get_system_prompt(lang)
    data = {
        "model": model,
        "max_tokens": 2000,
        "system": system_prompt,
        "messages": [{"role": "user", "content": prompt}]
    }
    try:
        req = urllib.request.Request(
            "https://api.anthropic.com/v1/messages",
            _j.dumps(data).encode(),
            {"Content-Type": "application/json",
             "x-api-key": api_key,
             "anthropic-version": "2023-06-01"}
        )
        with urllib.request.urlopen(req, timeout=60) as resp:
            return _j.loads(resp.read())["content"][0]["text"]
    except Exception as e:
        return f"[Claude Error: {e}]"

def _call_gemini_generate(prompt, model, lang):
    """Call Google Gemini API"""
    import urllib.request, json as _j
    secrets = _load_secrets()
    api_key = secrets.get("gemini", "") or os.environ.get("GEMINI_API_KEY", "")
    if not api_key:
        return "[Gemini API key not set. Run: mpop secret set gemini]"
    url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
    system_prompt = get_system_prompt(lang)
    data = {
        "system_instruction": {"parts": [{"text": system_prompt}]},
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"temperature": 0.3, "maxOutputTokens": 2000}
    }
    try:
        req = urllib.request.Request(
            url, _j.dumps(data).encode(),
            {"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=60) as resp:
            result = _j.loads(resp.read())
            return result["candidates"][0]["content"]["parts"][0]["text"]
    except Exception as e:
        return f"[Gemini Error: {e}]"

def ai_generate(prompt, lang="en", force_engine=None, force_model=None):
    """Generate text using configured AI engine.

    Default: local Ollama (free, no API key needed)
    Config:  ~/.mpop/config.json -> ai.engine = ollama | openai | claude | gemini
    Secrets: mpop secret set openai|anthropic|gemini
    CLI:     --gpt/--gpt4 (OpenAI), --claude (Anthropic), --gemini (Google)
    """
    engine, model = _get_ai_config()
    if force_engine:
        engine = force_engine
        if not force_model:
            _defaults = {"openai": "gpt-4o-mini", "claude": "claude-sonnet-4-20250514",
                         "gemini": "gemini-2.0-flash", "ollama": "qwen2.5:14b"}
            model = _defaults.get(engine, "qwen2.5:14b")
    if force_model:
        model = force_model
    if engine == "openai":
        return gpt_generate(prompt, model=model, lang=lang)
    elif engine == "claude":
        return _call_claude_generate(prompt, model=model, lang=lang)
    elif engine == "gemini":
        return _call_gemini_generate(prompt, model=model, lang=lang)
    return ollama_generate(prompt, model=model, lang=lang)

def ollama_generate(prompt, model="qwen2.5:14b", lang="en"):
    """Generate text using Ollama LLM on node1"""
    import urllib.request
    import json as json_lib

    ollama_host = _get_ollama_host()
    ollama_url = f"http://{ollama_host}/api/generate"
    system_prompt = get_system_prompt(lang)

    data = {
        "model": model,
        "prompt": f"{system_prompt}\n\n{prompt}",
        "stream": False,
        "options": {"temperature": 0.3}
    }

    try:
        req = urllib.request.Request(
            ollama_url,
            data=json_lib.dumps(data).encode(),
            headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json_lib.loads(resp.read().decode())
            return result.get("response", "")
    except Exception as e:
        return f"[LLM Error: {e}]"

def gpt_generate(prompt, model="gpt-4o-mini", lang="en"):
    """Generate text using OpenAI GPT API, fallback to Ollama if no API key"""
    import urllib.request
    import json as json_lib

    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        # Fallback to local Ollama
        return ollama_generate(prompt, model="qwen2.5:14b", lang=lang)

    system_prompt = get_system_prompt(lang)

    data = {
        "model": model,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": prompt}
        ],
        "temperature": 0.3,
        "max_tokens": 2000
    }

    try:
        req = urllib.request.Request(
            "https://api.openai.com/v1/chat/completions",
            data=json_lib.dumps(data).encode(),
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {api_key}"
            }
        )
        with urllib.request.urlopen(req, timeout=60) as resp:
            result = json_lib.loads(resp.read().decode())
            return result["choices"][0]["message"]["content"]
    except Exception as e:
        return f"[GPT Error: {e}]"

def detect_system_language():
    """Detect system language from environment"""
    lang_env = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
    if lang_env.startswith("ko"):
        return "Korean"
    elif lang_env.startswith("ja"):
        return "Japanese"
    elif lang_env.startswith("zh"):
        return "Chinese"
    else:
        return "English"

# ==================== Rule-Based Analysis (CODE makes judgments) ====================

def analyze_server_data(results, security_data):
    """
    Rule-based analysis. CODE makes all judgments here.
    Returns structured JSON with issues, scores, and context.
    LLM will only EXPLAIN this pre-analyzed data.
    """
    analysis = {
        "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        "summary": {},
        "issues": [],  # Each issue has: severity, server, type, detail, context
        "servers": {},
        "recommendations": [],
        "risk_score": 0,  # 0-100
    }

    total_risk = 0
    online_count = 0
    offline_list = []

    for name, r in results.items():
        ctx = SERVER_CONTEXT.get(name, {})
        stats = r.get("stats") or {}
        is_online = r.get("ping", False)

        server_info = {
            "status": "online" if is_online else "offline",
            "type": ctx.get("type", "unknown"),
            "ip": SERVERS.get(name, "?"),
            "metrics": {},
            "issues": [],
        }

        if is_online:
            online_count += 1

            # Load analysis
            try:
                load = float(stats.get("load", "0").replace(",", "."))
                cores = int(stats.get("cores", "1"))
                load_per_core = load / cores
                server_info["metrics"]["load"] = load
                server_info["metrics"]["load_per_core"] = round(load_per_core, 2)
                server_info["metrics"]["cores"] = cores

                # GPU/AI servers can have high load - that's normal
                if ctx.get("high_load_ok"):
                    if load_per_core > 10:
                        server_info["issues"].append({
                            "type": "load",
                            "severity": "warning",
                            "detail": f"Very high load ({load}) but this is GPU/AI server - may be expected"
                        })
                        total_risk += 5
                else:
                    if load_per_core > 5:
                        server_info["issues"].append({
                            "type": "load",
                            "severity": "critical",
                            "detail": f"Critical load: {load} (per-core: {load_per_core:.1f})"
                        })
                        total_risk += 20
                    elif load_per_core > 2:
                        server_info["issues"].append({
                            "type": "load",
                            "severity": "warning",
                            "detail": f"High load: {load}"
                        })
                        total_risk += 10
            except Exception:
                pass

            # Disk analysis
            try:
                disk_str = stats.get("disk", "0%").replace("%", "")
                disk = int(disk_str)
                server_info["metrics"]["disk_percent"] = disk

                if disk > 95:
                    server_info["issues"].append({
                        "type": "disk",
                        "severity": "critical",
                        "detail": f"Disk almost full: {disk}%"
                    })
                    total_risk += 25
                elif disk > 85:
                    server_info["issues"].append({
                        "type": "disk",
                        "severity": "warning",
                        "detail": f"Disk high: {disk}%"
                    })
                    total_risk += 10
            except Exception:
                pass

            # Memory analysis
            try:
                mem_str = stats.get("mem", "0%").replace("%", "")
                mem = int(mem_str)
                server_info["metrics"]["mem_percent"] = mem

                if mem > 95:
                    server_info["issues"].append({
                        "type": "memory",
                        "severity": "warning",
                        "detail": f"Memory high: {mem}%"
                    })
                    total_risk += 10
            except Exception:
                pass

            # SSH brute force analysis
            try:
                fail_24h = int(security_data.get(name, {}).get("fail24", "0"))
                server_info["metrics"]["ssh_failed_24h"] = fail_24h

                if fail_24h > 1000:
                    server_info["issues"].append({
                        "type": "security",
                        "severity": "warning",
                        "detail": f"Heavy SSH brute force: {fail_24h} attempts in 24h",
                        "context": "VPN provides primary security, but consider fail2ban"
                    })
                    total_risk += 5  # Low risk because VPN
            except Exception:
                pass

            # Port analysis
            ports_str = security_data.get(name, {}).get("ports", "")
            open_ports = []
            risky_ports = []

            for port_str in ports_str.split(','):
                try:
                    port = int(port_str.strip())
                    open_ports.append(port)

                    # Check if risky port
                    if port in VPN_SAFE_PORTS:
                        # Safe within VPN
                        pass
                    elif port in RISKY_PORTS:
                        port_info = RISKY_PORTS[port]
                        if ctx.get("public"):
                            # Public server with risky port = actual issue
                            risky_ports.append(port)
                            server_info["issues"].append({
                                "type": "port",
                                "severity": "warning",
                                "detail": f"Port {port} ({port_info[0]}) open on public server",
                                "context": "Review if this is intentional"
                            })
                            total_risk += 5
                        else:
                            # Internal server = VPN-only, lower risk
                            pass
                except Exception:
                    pass

            server_info["metrics"]["open_ports"] = open_ports
            server_info["metrics"]["risky_ports"] = risky_ports

        else:
            offline_list.append(name)
            # Offline server = issue (unless it's observer/laptop)
            if not ctx.get("observer"):
                server_info["issues"].append({
                    "type": "connectivity",
                    "severity": "critical",
                    "detail": "Server offline or unreachable"
                })
                total_risk += 15

        # Copy issues to main list with server name
        for issue in server_info["issues"]:
            issue_copy = issue.copy()
            issue_copy["server"] = name
            analysis["issues"].append(issue_copy)

        analysis["servers"][name] = server_info

    # Summary
    analysis["summary"] = {
        "total_servers": len(results),
        "online": online_count,
        "offline": offline_list,
        "critical_issues": len([i for i in analysis["issues"] if i["severity"] == "critical"]),
        "warning_issues": len([i for i in analysis["issues"] if i["severity"] == "warning"]),
    }

    # Normalize risk score (0-100)
    analysis["risk_score"] = min(100, total_risk)

    # Generate recommendations based on issues
    if any(i["type"] == "disk" and i["severity"] == "critical" for i in analysis["issues"]):
        analysis["recommendations"].append({
            "priority": "high",
            "action": "Clean up disk space immediately",
            "servers": [i["server"] for i in analysis["issues"] if i["type"] == "disk"]
        })

    if any(i["type"] == "connectivity" for i in analysis["issues"]):
        analysis["recommendations"].append({
            "priority": "high",
            "action": "Investigate offline servers",
            "servers": offline_list
        })

    return analysis


def cmd_report(args):
    """Generate AI-powered natural language report (GPT-mini default)"""
    lang = detect_system_language()  # Auto-detect
    target = None
    use_ollama = False  # Default to GPT now
    gpt_model = "gpt-4o-mini"

    # Parse args
    for i, arg in enumerate(args):
        if arg in ["-l", "--lang"]:
            if i + 1 < len(args):
                lang_map = {"ko": "Korean", "en": "English", "ja": "Japanese", "zh": "Chinese"}
                lang = lang_map.get(args[i+1], args[i+1])
        elif arg == "--ollama":
            use_ollama = True
        elif arg == "--gpt4":
            gpt_model = "gpt-4o"
        elif arg in SERVERS:
            target = arg

    engine = "Ollama on g1" if use_ollama else f"GPT ({gpt_model})"
    print(f"""
  ═══════════════════════════════════════════════════════════
   AI-POWERED NETWORK REPORT v2.1 (Rule-based Analysis)
   Language: {lang}  |  Engine: {engine}
  ═══════════════════════════════════════════════════════════
""")

    vpn_ip = get_vpn_ip()
    my_name = get_my_name()

    # Collect data from all servers
    if target:
        servers_to_check = {target: SERVERS[target]}
    else:
        servers_to_check = SERVERS

    print("  [1/4] Collecting server metrics...")
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in servers_to_check.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    # Collect security data
    print("  [2/4] Collecting security data...")
    security_data = {}
    for name, ip in servers_to_check.items():
        if not results.get(name, {}).get("vssh"):
            continue
        cmd = """
echo "FAIL24:$(journalctl -u sshd --since '24 hours ago' 2>/dev/null | grep -c 'Failed' || grep -c 'Failed' /var/log/auth.log 2>/dev/null || echo '0')"
echo "PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -un | tr '\\n' ',' || echo '-')"
"""
        result = vssh_exec(ip, cmd, timeout=5)
        if result:
            parsed = {}
            for line in result.split('\n'):
                if line.startswith("FAIL24:"):
                    parsed["fail24"] = line[7:].strip()
                elif line.startswith("PORTS:"):
                    parsed["ports"] = line[6:].strip()
            security_data[name] = parsed

    # Run rule-based analysis (CODE makes all judgments here)
    print("  [3/4] Running rule-based analysis...")
    analysis = analyze_server_data(results, security_data)

    # Print quick summary before LLM
    risk_color = "!!" if analysis["risk_score"] > 50 else "*" if analysis["risk_score"] > 20 else " "
    print(f"""
  ───────────────────────────────────────────────────────────
   Quick Summary (code-analyzed):
   • Risk Score: {risk_color}{analysis["risk_score"]}/100
   • Online: {analysis["summary"]["online"]}/{analysis["summary"]["total_servers"]}
   • Critical: {analysis["summary"]["critical_issues"]} | Warnings: {analysis["summary"]["warning_issues"]}
  ───────────────────────────────────────────────────────────
""")

    # Generate human-readable report via LLM
    # IMPORTANT: LLM only EXPLAINS, does NOT judge
    print("  [4/4] Generating human-readable report...")

    # Convert analysis to JSON for LLM
    import json as json_lib
    analysis_json = json_lib.dumps(analysis, indent=2, default=str)

    prompt = f"""Below is a PRE-ANALYZED infrastructure report. The analysis was done by code.
Your job is to EXPLAIN this analysis in detail. Do NOT make your own judgments - only explain what the code found.

=== PRE-ANALYZED DATA (JSON) ===
{analysis_json}

=== REPORT FORMAT (write in detail) ===

## 📊 Executive Summary
- Overall health assessment based on risk_score ({analysis['risk_score']}/100)
- Online status: {analysis['summary']['online']}/{analysis['summary']['total_servers']} servers
- Issue summary: {analysis['summary']['critical_issues']} critical, {analysis['summary']['warning_issues']} warnings

## 🔴 Critical Issues
For each issue where severity="critical", explain:
- Which server, what the problem is, and why it matters
- If no critical issues, state "No critical issues detected"

## ⚠️ Warnings
For each issue where severity="warning", explain:
- The server, the issue, and context (e.g., "GPU server high load may be normal")
- Include any "context" field from the issue

## 📈 Server Status Overview
Brief status of each server type based on their actual roles and metrics:
- Relay/VPN servers: status and connectivity
- High-load servers (AI/GPU/build): load context relative to their workload
- Application servers: services running and health

## 🛠️ Recommendations
List each recommendation with priority level and affected servers.

## 💡 Additional Insights
Any patterns noticed (e.g., all servers healthy, specific server types under load, etc.)

Write a comprehensive report. Be specific with server names and numbers."""

    report = ai_generate(prompt, lang=lang)
    _cfg_e, _cfg_m = _get_ai_config()
    engine_name = f"{_cfg_e} ({_cfg_m})"

    print("\n  ═══════════════════════════════════════════════════════════")
    print()
    for line in report.split('\n'):
        print(f"  {line}")
    print()
    print("  ═══════════════════════════════════════════════════════════")
    print(f"  Generated by {engine_name} | Analysis v2.1 (rule-based)")
    print()

def detect_anomalies(current, history):
    """Detect anomalies by comparing current state with history"""
    anomalies = []

    for name, data in current.items():
        if not data.get("ping"):
            # Server went down
            if history.get(name, {}).get("ping"):
                anomalies.append({
                    "type": "DOWN",
                    "server": name,
                    "severity": "CRITICAL",
                    "message": f"{name} went OFFLINE"
                })
            continue

        stats = data.get("stats", {})
        prev_stats = history.get(name, {}).get("stats", {})

        # Load spike detection
        try:
            curr_load = float(stats.get("load", "0").split(",")[0])
            prev_load = float(prev_stats.get("load", "0").split(",")[0]) if prev_stats else 0
            if curr_load > 5 and curr_load > prev_load * 2:
                anomalies.append({
                    "type": "LOAD_SPIKE",
                    "server": name,
                    "severity": "WARNING",
                    "message": f"{name} load spiked: {prev_load:.1f} → {curr_load:.1f}"
                })
        except Exception: pass

        # Disk filling up rapidly
        try:
            curr_disk = int(stats.get("disk", "0%").replace("%", ""))
            prev_disk = int(prev_stats.get("disk", "0%").replace("%", "")) if prev_stats else 0
            if curr_disk > 95:
                anomalies.append({
                    "type": "DISK_CRITICAL",
                    "server": name,
                    "severity": "CRITICAL",
                    "message": f"{name} disk at {curr_disk}%"
                })
            elif curr_disk > prev_disk + 5:
                anomalies.append({
                    "type": "DISK_GROWING",
                    "server": name,
                    "severity": "WARNING",
                    "message": f"{name} disk growing: {prev_disk}% → {curr_disk}%"
                })
        except Exception: pass

        # SSH attack surge
        try:
            curr_fail = int(stats.get("failed_logins", "0"))
            prev_fail = int(prev_stats.get("failed_logins", "0")) if prev_stats else 0
            if curr_fail > 100 and curr_fail > prev_fail * 2:
                anomalies.append({
                    "type": "SSH_ATTACK",
                    "server": name,
                    "severity": "WARNING",
                    "message": f"{name} SSH attacks surging: {curr_fail} attempts"
                })
        except Exception: pass

    return anomalies

def send_telegram_alert(message):
    """Send alert to Telegram (if configured)"""
    import urllib.request
    config_path = os.path.expanduser("~/.mpop/telegram.conf")
    if not os.path.exists(config_path):
        return False

    try:
        with open(config_path) as f:
            config = {}
            for line in f:
                if "=" in line:
                    k, v = line.strip().split("=", 1)
                    config[k.strip()] = v.strip()

        bot_token = config.get("BOT_TOKEN")
        chat_id = config.get("CHAT_ID")
        if not bot_token or not chat_id:
            return False

        url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
        data = json.dumps({"chat_id": chat_id, "text": f"🚨 MeshPOP Alert\n\n{message}", "parse_mode": "HTML"}).encode()
        req = urllib.request.Request(url, data, headers={"Content-Type": "application/json"})
        urllib.request.urlopen(req, timeout=5)
        return True
    except Exception:
        return False

def cmd_watch(args):
    """Real-time monitoring dashboard with anomaly detection"""
    import time

    interval = 5
    log_file = None
    log_mode = False
    alert_mode = "--alert" in args or "-a" in args
    history = {}  # Store previous state for anomaly detection
    alerted = set()  # Avoid duplicate alerts

    # Parse arguments
    remaining_args = []
    for arg in args:
        if arg == "--log":
            log_mode = True
            log_file = f"/tmp/mpop-watch-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
        elif arg.startswith("--log="):
            log_mode = True
            log_file = arg.split("=", 1)[1]
        elif arg.isdigit():
            interval = max(2, min(60, int(arg)))
        elif arg in ("--alert", "-a"):
            pass  # Already handled

    print(f"  {C.BOLD}Starting real-time monitor{C.R} (refresh every {interval}s)")
    if log_mode:
        print(f"  Logging to: {C.CYAN}{log_file}{C.R}")
    if alert_mode:
        print(f"  {C.GREEN}Telegram alerts enabled{C.R}")
    print(f"  Press {C.YELLOW}Ctrl+C{C.R} to stop\n")

    log_handle = open(log_file, 'a') if log_mode else None

    try:
        while True:
            # Clear screen (skip if logging to preserve output)
            if not log_mode:
                print("\033[H\033[J", end="")

            vpn_ip = get_vpn_ip()
            my_name = get_my_name()
            now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

            # Quick parallel check
            results = {}
            with ThreadPoolExecutor(max_workers=10) as ex:
                futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                           for name, ip in SERVERS.items()}
                for future in as_completed(futures):
                    name = futures[future]
                    results[name] = future.result()

            online = sum(1 for r in results.values() if r.get("ping"))
            total = len(SERVERS)

            # Anomaly detection
            anomalies = detect_anomalies(results, history) if history else []
            history = results.copy()

            # Send alerts for new anomalies
            if alert_mode and anomalies:
                for a in anomalies:
                    alert_key = f"{a['server']}:{a['type']}"
                    if alert_key not in alerted:
                        alerted.add(alert_key)
                        send_telegram_alert(f"<b>{a['severity']}</b>: {a['message']}")

            # Clear old alerts after 5 minutes
            # (simplified - just track current session)

            # Basic alerts
            alerts = []
            for name, r in results.items():
                if not r.get("ping") and not r.get("me"):
                    alerts.append(f"{name}:DOWN")
                if r.get("stats"):
                    try:
                        if float(r["stats"].get("load", "0").split(",")[0]) > 5:
                            alerts.append(f"{name}:HIGH_LOAD")
                        if int(r["stats"].get("disk", "0%").replace('%', '')) > 90:
                            alerts.append(f"{name}:DISK_FULL")
                        if int(r["stats"].get("failed_logins", "0")) > 50:
                            alerts.append(f"{name}:ATTACK")
                    except Exception: pass

            status = f"{C.GREEN}✓ OK{C.R}" if not alerts else f"{C.RED}⚠ ALERT{C.R}"

            # Show anomalies if any
            anomaly_line = ""
            if anomalies:
                anomaly_line = f"\n  {C.RED}⚠ Anomalies: {', '.join([a['message'] for a in anomalies[:3]])}{C.R}"

            print(f"""
  [{status}] {C.BOLD}MeshPOP LIVE{C.R}  |  {C.CYAN}{my_name}{C.R}  |  {C.GREEN}{online}{C.R}/{total} online  |  {now}{anomaly_line}
  {C.DIM}═══════════════════════════════════════════════════════════════════════════════{C.R}
  {C.BOLD}Node │ VPN IP          │ Status    │  Load │ Uptime │ Disk │  Mem │ Con{C.R}
  {C.DIM}───────────────────────────────────────────────────────────────────────────────{C.R}""")

            for name in SERVERS.keys():
                r = results.get(name, {})
                stats = r.get("stats") or {}
                ip = SERVERS[name]

                # Extract and format values (clean all whitespace/newlines)
                def clean_val(v):
                    return str(v).split()[0].strip() if v and str(v).strip() != "-" else "-"

                load_raw = stats.get("load", "-")
                load = load_raw.split(",")[0].strip() if load_raw and load_raw != "-" else "-"
                disk = clean_val(stats.get("disk", "-"))
                mem = clean_val(stats.get("mem", "-"))
                conn = clean_val(stats.get("connections", "-"))

                # Shorten uptime: "8 weeks, 5d, 3h" -> "8w 5d"
                uptime_raw = stats.get("uptime", "-")
                if uptime_raw and uptime_raw != "-":
                    uptime = ""
                    import re
                    if m := re.search(r'(\d+)\s*week', uptime_raw):
                        uptime += f"{m.group(1)}w "
                    if m := re.search(r'(\d+)\s*d', uptime_raw):
                        uptime += f"{m.group(1)}d "
                    if m := re.search(r'(\d+)\s*h', uptime_raw):
                        uptime += f"{m.group(1)}h"
                    uptime = uptime.strip() or uptime_raw[:8]
                else:
                    uptime = "-"

                # Color-coded metrics
                load_color = C.R
                try:
                    load_val = float(load) if load != "-" else 0
                    if load_val > 5: load_color = C.RED
                    elif load_val > 2: load_color = C.YELLOW
                except Exception: pass

                disk_color = C.R
                try:
                    disk_val = int(disk.replace('%', '')) if disk != "-" else 0
                    if disk_val > 90: disk_color = C.RED
                    elif disk_val > 70: disk_color = C.YELLOW
                except Exception: pass

                # Colorized name based on server type
                name_color = C.CYAN if name.startswith('v') else (C.GREEN if name.startswith('g') else C.BLUE)

                if r.get("me"):
                    print(f"  {name_color}{name:4}{C.R} │ {ip:15} │ {C.BOLD}◄◄ HERE{C.R}   │ {load_color}{load:>5}{C.R} │ {uptime:>7} │ {disk_color}{disk:>4}{C.R} │ {mem:>4} │ {conn:>3}")
                elif not r.get("ping"):
                    print(f"  {name_color}{name:4}{C.R} │ {ip:15} │ {C.RED}OFFLINE{C.R}   │     - │       - │    - │    - │   -")
                else:
                    ssh_s = f"{C.GREEN}+{C.R}" if r.get("ssh") else f"{C.RED}-{C.R}"
                    vssh_s = f"{C.GREEN}+{C.R}" if r.get("vssh") else f"{C.RED}-{C.R}"
                    status_s = f"ssh:{ssh_s} v:{vssh_s}"
                    print(f"  {name_color}{name:4}{C.R} │ {ip:15} │ {status_s} │ {load_color}{load:>5}{C.R} │ {uptime:>7} │ {disk_color}{disk:>4}{C.R} │ {mem:>4} │ {conn:>3}")

            print(f"  {C.DIM}═══════════════════════════════════════════════════════════════════════════════{C.R}")

            if alerts:
                print(f"  {C.RED}[!] ALERTS: {', '.join(alerts[:5])}{C.R}")

            # Log to file if enabled
            if log_handle:
                log_handle.write(f"\n--- {now} ---\n")
                log_handle.write(f"Status: {status} | Online: {online}/{total}\n")
                for name in SERVERS.keys():
                    r = results.get(name, {})
                    stats = r.get("stats") or {}
                    status_str = "HERE" if r.get("me") else ("OFFLINE" if not r.get("ping") else "OK")
                    log_handle.write(f"{name}: {status_str} load={stats.get('load', '-')} disk={stats.get('disk', '-')}\n")
                if alerts:
                    log_handle.write(f"ALERTS: {', '.join(alerts)}\n")
                log_handle.flush()

            print(f"\n  {C.DIM}Next refresh in {C.CYAN}{interval}s{C.R}{C.DIM}... (Press {C.YELLOW}Ctrl+C{C.R}{C.DIM} to stop){C.R}")

            time.sleep(interval)

    except KeyboardInterrupt:
        print("\n  Monitor stopped.")
        if log_handle:
            log_handle.close()
            print(f"  Log saved to: {log_file}")

AGENT_URL = None
try:
    AGENT_URL = _get_config("agent_url", None)
except:
    pass

def query_agent(endpoint, timeout=30):
    """Query the mpop-agent API on node1 (deprecated - use query_server_agent)"""
    import urllib.request
    try:
        url = f"{AGENT_URL}{endpoint}"
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode())
    except Exception as e:
        return {"error": str(e)}

def _get_dashboard_urls_cfg() -> list:
    """Get list of dashboard URLs from config (supports string or list)."""
    val = _get_config("dashboard_url", "")
    if not val:
        return []
    if isinstance(val, list):
        return [u.rstrip("/") for u in val if u]
    # Comma-separated string
    return [u.strip().rstrip("/") for u in str(val).split(",") if u.strip()]

def _normalize_agent_response(data: dict) -> dict:
    """Normalize mesh agent v2 response → legacy format so all commands work.

    New mesh agent returns:
      {"nodes": [{node, ip, cpu_pct, mem_pct, disk_pct, load, uptime, ts, ...}], "ts": N}
    Legacy format expected by commands:
      {"servers": [{name, ip, online, cpu_pct, mem_pct, disk_pct, load, uptime, source, ...}],
       "last_update": "ISO string"}
    """
    if "nodes" not in data:
        return data  # already legacy format or error — pass through

    now_ts = int(time.time())

    # Build lookups: ip → canonical name, AND set of all known server names
    # "vultr" hostname maps to "v1" config name via IP lookup
    # "m1" node name is kept as-is since it's a known config name (avoids LAN IP collision)
    _ip_to_name = {}
    _known_names = set()   # all server names explicitly defined in servers.json
    _name_to_vpn_ip = {}   # canonical name → wire VPN IP (10.99.x.x) for display
    _srv_json_path = os.path.join(os.path.expanduser("~"), ".mpop", "servers.json")
    if os.path.isfile(_srv_json_path):
        try:
            import json as _json_n
            _srv_j = _json_n.load(open(_srv_json_path))
            for _sn, _sv in _srv_j.items():
                _known_names.add(_sn)
                for _f in ("vpn_ip", "wire_ip", "ip", "public_ip"):
                    if _sv.get(_f):
                        _ip_to_name[_sv[_f]] = _sn
                # Prefer vpn_ip/wire_ip for the display VPN IP column
                _display_ip = _sv.get("vpn_ip") or _sv.get("wire_ip") or ""
                if _display_ip:
                    _name_to_vpn_ip[_sn] = _display_ip
        except Exception:
            pass

    # Deduplicate by IP: agent may report hostname "vultr" but config calls it "v1"
    # Keep the entry with the config-canonical name; merge if same IP.
    # IMPORTANT: if the raw node name IS a known config name (e.g. "m1"), trust it directly
    # and skip IP-based dedup — avoids false collision when Mac reports a LAN IP
    # shared with another server's VPN IP (e.g. m1 LAN 192.168.50.199 == d2 VPN).
    _seen_ips = {}   # ip → index in servers list
    servers = []
    for n in data.get("nodes", []):
        age = now_ts - n.get("ts", 0)
        _ip = n.get("ip", "")
        _raw_name = n.get("node", "?")
        # Node-name match takes priority: if raw name IS a known server, use it as-is
        _name_from_known = _raw_name in _known_names
        if _name_from_known:
            _name = _raw_name
        else:
            # Fall back to IP→name lookup, then HOSTNAME_MAP for aliases (e.g. synologyNas→s2)
            _name = _ip_to_name.get(_ip) or HOSTNAME_MAP.get(_raw_name) or HOSTNAME_MAP.get(_raw_name.lower()) or _raw_name
        # Suppress auto-generated "node-NNN" names if a canonical name exists
        if _raw_name.startswith("node-") and _name != _raw_name:
            pass  # replaced by canonical name
        # Use wire VPN IP from servers.json for display; fall back to agent-reported IP
        _display_ip = _name_to_vpn_ip.get(_name, _ip)
        entry = {
            "name":        _name,
            "ip":          _display_ip,
            "online":      age <= 120,
            "source":      "agent",
            "last_seen":   n.get("ts", 0),
            "cpu_pct":     n.get("cpu_pct", 0),
            "mem_pct":     n.get("mem_pct", 0),
            "mem_used_mb": n.get("mem_used_mb", 0),
            "mem_total_mb":n.get("mem_total_mb", 0),
            "disk_pct":    n.get("disk_pct", 0),
            "disk_used_gb":n.get("disk_used_gb", 0),
            "disk_total_gb":n.get("disk_total_gb", 0),
            "load":        n.get("load", "-"),
            "uptime":      n.get("uptime", "-"),
            "os":          n.get("os", ""),
            "log_errors":  n.get("log_errors", 0),
        }
        if _ip and not _name_from_known and _ip in _seen_ips:
            # Duplicate IP (name came from IP lookup) — merge with existing
            _existing_idx = _seen_ips[_ip]
            _existing = servers[_existing_idx]
            _existing_is_auto = _existing["name"].startswith("node-")
            _new_is_auto = _name.startswith("node-")
            if _existing_is_auto and not _new_is_auto:
                servers[_existing_idx] = entry  # replace auto-name with canonical
            elif not _existing_is_auto and not _new_is_auto:
                # Both canonical — keep newer ts
                if entry.get("last_seen", 0) > _existing.get("last_seen", 0):
                    servers[_existing_idx] = entry
            # else new is auto-name → keep existing canonical
        else:
            # For known-name entries: if same IP already in list (e.g. "node-111" was added
            # first via IP lookup), replace it with the canonical entry
            if _ip and _name_from_known and _ip in _seen_ips:
                _existing_idx = _seen_ips[_ip]
                _existing = servers[_existing_idx]
                if entry.get("last_seen", 0) >= _existing.get("last_seen", 0):
                    servers[_existing_idx] = entry          # canonical + newer wins
                else:
                    servers[_existing_idx]["name"] = entry["name"]  # force canonical name
                    servers[_existing_idx]["ip"]   = entry["ip"]    # force VPN IP
            else:
                if _ip and (_name_from_known or _ip not in _seen_ips):
                    _seen_ips[_ip] = len(servers)
                servers.append(entry)

    # Convert ts → ISO string for last_update
    ts = data.get("ts", now_ts)
    try:
        from datetime import datetime as _dt
        last_update = _dt.utcfromtimestamp(ts).isoformat()
    except Exception:
        last_update = str(ts)

    return {
        "servers":     servers,
        "last_update": last_update,
        "_mesh":       True,   # flag: came from mesh agent
    }


def query_server_agent(endpoint, timeout=10):
    """Query agent API — for /api/status queries ALL peers concurrently and merges nodes (no SPOF)."""
    import urllib.request
    import threading as _thr
    # Merge SERVER_AGENT_URLS (all peers) + any extra dashboard_url config entries
    urls = list(SERVER_AGENT_URLS) if SERVER_AGENT_URLS else (
        [SERVER_AGENT_URL] if SERVER_AGENT_URL else []
    )
    for u in _get_dashboard_urls_cfg():
        if u not in urls:
            urls.append(u)
    if not urls:
        return {"error": "not_configured"}

    # For /api/status: query ALL peers concurrently, normalize each response first,
    # then merge by canonical server name — latest last_seen wins.
    # Normalizing per-peer BEFORE merging resolves auto-names ("node-158" → "v3")
    # so the same physical server never appears twice under different names.
    if endpoint in ("/api/status", "/status") or endpoint.startswith("/api/status?"):
        _fetch_timeout = min(timeout, 4)
        results = [None] * len(urls)

        def _fetch(i, url_base):
            try:
                req = urllib.request.Request(f"{url_base}{endpoint}")
                with urllib.request.urlopen(req, timeout=_fetch_timeout) as resp:
                    results[i] = json.loads(resp.read().decode())
            except Exception:
                pass

        threads = [_thr.Thread(target=_fetch, args=(i, u), daemon=True) for i, u in enumerate(urls)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=_fetch_timeout + 0.5)

        # Normalize each peer's response, then merge by canonical server name
        merged_servers = {}   # canonical_name -> server_entry
        any_ok = False
        for raw in results:
            if raw is None:
                continue
            any_ok = True
            norm = _normalize_agent_response(raw) if "nodes" in raw else raw
            for srv in norm.get("servers", []):
                name = srv.get("name", "")
                if not name:
                    continue
                existing = merged_servers.get(name)
                if existing is None or srv.get("last_seen", 0) > existing.get("last_seen", 0):
                    merged_servers[name] = srv

        if not any_ok:
            return {"error": "unreachable"}

        from datetime import datetime as _dt2
        return {
            "servers":     list(merged_servers.values()),
            "last_update": _dt2.utcnow().isoformat(),
            "_mesh":       True,
        }

    # For other endpoints: try each URL in order until one responds
    last_err = "unreachable"
    for url_base in urls:
        try:
            url = f"{url_base}{endpoint}"
            req = urllib.request.Request(url)
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                raw = json.loads(resp.read().decode())
                return raw
        except Exception as e:
            last_err = str(e)
    return {"error": last_err}

def cmd_trend(args):
    """Show trend analysis from server-agent (relay1)"""
    hours = 24
    target = None

    for arg in args:
        if arg.isdigit():
            hours = int(arg)
        elif arg in SERVERS:
            target = arg

    print(f"""
  ═══════════════════════════════════════════════════════════
   TREND ANALYSIS (last {hours} hours)
   Source: server-agent on v1 (5min intervals)
  ═══════════════════════════════════════════════════════════
""")

    print("  Fetching trend data from server-agent...")
    data = query_server_agent(f"/api/trends?hours={hours}")

    if "error" in data:
        print(f"  Error: {data['error']}")
        print("  Make sure dashboard is running on relay1:8721 (mpop setup)")
        return

    if data.get("error"):
        print(f"  {data['error']}")
        print("  Wait for server-agent to collect more data (5min intervals)")
        return

    total_records = data.get("total_records", 0)
    print(f"  Data points: {total_records} ({total_records * 5} minutes of data)\n")

    servers = data.get("servers", {})
    if target:
        # Handle local/Mac mapping
        if target == "local" and target not in servers:
            target = "Mac"
        servers = {target: servers.get(target, {})}

    # Table header
    print("  " + "-" * 65)
    print(f"  {'Node':<6} | {'Mem Avg':>8} | {'Mem Max':>8} | {'Disk Avg':>9} | {'Disk Max':>9} | {'Uptime':>7}")
    print("  " + "-" * 65)

    for name in SERVERS.keys():
        # Handle local/Mac mapping
        trend = servers.get(name) or servers.get(name.upper()) or servers.get(name.capitalize())
        if not trend and name == "local":
            trend = servers.get("Mac")

        if not trend or not trend.get("records"):
            print(f"  {name:<6} |        - |        - |         - |         - |       -")
            continue

        mem_avg = trend.get("mem_avg", 0)
        mem_max = trend.get("mem_max", 0)
        disk_avg = trend.get("disk_avg", 0)
        disk_max = trend.get("disk_max", 0)
        uptime_pct = trend.get("uptime_pct", 0)
        records = trend.get("records", 0)

        # Indicators
        disk_warn = "!" if disk_max > 85 else " "
        mem_warn = "!" if mem_max > 90 else " "

        print(f"  {name:<6} | {mem_avg:>6.1f}%{mem_warn}| {mem_max:>6}%{mem_warn}| {disk_avg:>7.1f}%{disk_warn}| {disk_max:>7}%{disk_warn}| {uptime_pct:>6.1f}%")

    print("  " + "-" * 65)
    print()

    # Show alerts for high values
    alerts = []
    for name, trend in servers.items():
        if not trend:
            continue
        if trend.get("disk_max", 0) > 85:
            alerts.append(f"{name}: disk max {trend['disk_max']}%")
        if trend.get("mem_max", 0) > 90:
            alerts.append(f"{name}: mem max {trend['mem_max']}%")
        if trend.get("uptime_pct", 100) < 95:
            alerts.append(f"{name}: uptime {trend['uptime_pct']}%")

    if alerts:
        print("  [!] ALERTS:")
        for a in alerts:
            print(f"      - {a}")
        print()

def cmd_ai(args):
    """AI assistant - Plan generation and execution"""

    # Check if this is AI-OS mode (has a request string)
    # vs trend report mode (just numbers/flags)
    request = None
    model = "ollama"  # default: local LLM
    dry_run = False
    auto_approve = False

    remaining_args = []
    i = 0
    while i < len(args):
        arg = args[i]
        if arg == "--model" and i + 1 < len(args):
            model = args[i + 1]
            i += 2
            continue
        elif arg == "--dry":
            dry_run = True
        elif arg == "--approve":
            auto_approve = True
        elif arg == "--gpt4":
            model = "gpt4"
        elif arg == "--claude":
            model = "claude"
        elif arg.isdigit():
            remaining_args.append(arg)
        elif not arg.startswith("-"):
            # This is the request string
            request = " ".join(args[i:])
            break
        i += 1

    # If no request, fall back to trend report mode
    if not request:
        return _cmd_ai_trend(remaining_args)

    # AI-OS mode: generate and execute plan
    return _cmd_ai_plan(request, model, dry_run, auto_approve)


def _cmd_ai_plan(request, model="ollama", dry_run=False, auto_approve=False):
    """AI-OS: Generate and execute plan from natural language request"""

    print(f"""
  ═══════════════════════════════════════════════════════════
   🤖 AI-OS Plan Generator
   Model: {model}
   Mode: {"dry-run" if dry_run else "execute"}
  ═══════════════════════════════════════════════════════════
""")

    # Step 1: Build context
    print("  [1/4] Building context...", end=" ", flush=True)
    context = _ai_build_context()
    print("✅")

    # Step 2: Generate plan via LLM
    print(f"  [2/4] Generating plan ({model})...", end=" ", flush=True)
    plan = _ai_generate_plan(request, context, model)

    if not plan:
        print("❌")
        print("\n  ⚠️  Failed to generate plan")
        return

    print("✅")

    # Step 3: Validate plan
    print("  [3/4] Validating plan...", end=" ", flush=True)
    valid, errors, meta = _ai_validate_plan(plan)

    if not valid:
        print("❌")
        print("\n  ⚠️  Plan validation failed:")
        for err in errors:
            print(f"      - {err}")
        return

    print("✅")

    # Show plan
    print("\n  📋 PLAN:")
    print("  " + "-" * 50)
    for step in plan.get("steps", []):
        tier = step.get("tier", "?")
        tier_icon = {"T1": "🟢", "T2": "🟡", "T3": "🔴"}.get(tier, "⚪")
        action = step.get("action", "?")
        target = step.get("target", "?")
        reason = step.get("reason", "")[:40]
        print(f"  {tier_icon} [{tier}] {action} @ {target}")
        print(f"        {reason}")
    print("  " + "-" * 50)

    # Show meta info
    tiers = meta.get("tiers", {})
    tier_summary = ", ".join([f"{k}:{v}" for k, v in tiers.items() if v > 0])
    print(f"\n  📊 Tiers: {tier_summary}")

    if meta.get("needs_approval"):
        print(f"  🔐 Approval needed: {', '.join(meta['needs_approval'])}")

    if meta.get("needs_snapshot"):
        print(f"  💾 Snapshot needed: {', '.join(meta['needs_snapshot'])}")

    if meta.get("countdown", 0) > 0:
        print(f"  ⏱️  Countdown: {meta['countdown']}s")

    # Dry-run mode: stop here
    if dry_run:
        print("\n  ℹ️  Dry-run mode - plan not executed")
        return

    # Step 4: Execute plan
    print("\n  [4/4] Executing plan...")

    # Check tiers and get approval
    if meta.get("needs_approval") and not auto_approve:
        print(f"\n  ⚠️  This plan requires approval for: {', '.join(meta['needs_approval'])}")
        confirm = input("  Execute? [y/N]: ").strip().lower()
        if confirm != "y":
            print("  ❌ Cancelled")
            return

    # Countdown for T2/T3
    countdown = meta.get("countdown", 0)
    if countdown > 0:
        import time
        print(f"\n  ⏳ Executing in {countdown} seconds... (Ctrl+C to cancel)")
        try:
            for i in range(countdown, 0, -1):
                print(f"\r  ⏳ {i}...", end="", flush=True)
                time.sleep(1)
            print("\r  ⏳ Executing...    ")
        except KeyboardInterrupt:
            print("\n  ❌ Cancelled by user")
            return

    # Execute each step
    results = _ai_execute_plan(plan, auto_approve=auto_approve)

    print("\n  📊 RESULTS:")
    for step_id, result in results.items():
        status = "✅" if result.get("success") else "❌"
        msg = result.get("message", "")[:60]
        print(f"  {status} {step_id}: {msg}")

        # Show verification if available
        if result.get("verified"):
            print(f"      ↳ verify: {result['verified'][:50]}")

    # Log to audit
    print("\n  ✅ Plan executed and logged to audit")


# ==================== Action Registry ====================

# ============================================================================
# ACTION REGISTRY - mpop AI-OS Execution Kernel
# ============================================================================
# Allowed actions that all LLMs can execute
# LLMs cannot run arbitrary shell commands - must select from this list
#
# Attributes:
#   tier: T1(safe) / T2(caution) / T3(dangerous)
#   requires_approval: requires user approval
#   requires_snapshot: requires snapshot before execution
#   idempotent: same result on repeated execution
#   verify: verification command after execution
#   forbidden_paths: forbidden paths for this action
# ============================================================================

ACTION_REGISTRY = {
    # =========================================================================
    # 📊 Status Query (T1, safe) - read-only system info
    # =========================================================================
    "status.server": {
        "tier": "T1",
        "desc": "Server basic status (uptime, load)",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "uptime && echo '---' && cat /etc/os-release 2>/dev/null | head -3 || sw_vers 2>/dev/null",
        "verify": None,
    },
    "status.disk": {
        "tier": "T1",
        "desc": "Check disk usage",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "df -h",
        "verify": None,
    },
    "status.memory": {
        "tier": "T1",
        "desc": "Check memory usage",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "free -h 2>/dev/null || vm_stat 2>/dev/null | head -10",
        "verify": None,
    },
    "status.processes": {
        "tier": "T1",
        "desc": "Process list (top 10)",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "ps aux --sort=-%mem 2>/dev/null | head -11 || ps aux | head -11",
        "verify": None,
    },
    "status.docker": {
        "tier": "T1",
        "desc": "Docker container status",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "docker ps -a 2>/dev/null || echo 'Docker not installed'",
        "verify": None,
    },
    "status.network": {
        "tier": "T1",
        "desc": "Network connection summary",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "ss -s 2>/dev/null || netstat -an | wc -l",
        "verify": None,
    },
    "status.logs": {
        "tier": "T1",
        "desc": "Recent system logs",
        "params": {"lines": "int"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "journalctl -n {lines} --no-pager 2>/dev/null || tail -{lines} /var/log/syslog 2>/dev/null || tail -{lines} /var/log/messages 2>/dev/null || echo 'No logs'",
        "verify": None,
    },
    "status.service": {
        "tier": "T1",
        "desc": "Check service status",
        "params": {"service": "str"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemctl status {service} --no-pager 2>/dev/null | head -15 || service {service} status 2>/dev/null",
        "verify": None,
    },
    "status.security": {
        "tier": "T1",
        "desc": "Check security events",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "journalctl -u ssh --since '1 hour ago' --no-pager 2>/dev/null | tail -20 || tail -20 /var/log/auth.log 2>/dev/null || echo 'No auth logs'",
        "verify": None,
    },

    # =========================================================================
    # 🔧 Service Management (T1/T2) - service control
    # =========================================================================
    "service.restart": {
        "tier": "T1",
        "desc": "Restart service",
        "params": {"service": "str"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemctl restart {service} && echo 'Restarted {service}'",
        "verify": "systemctl is-active {service}",
    },
    "service.reload": {
        "tier": "T1",
        "desc": "Reload service config",
        "params": {"service": "str"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemctl reload {service} 2>/dev/null || systemctl restart {service}; echo 'Reloaded {service}'",
        "verify": "systemctl is-active {service}",
    },
    "service.stop": {
        "tier": "T2",
        "desc": "Stop service",
        "params": {"service": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemctl stop {service} && echo 'Stopped {service}'",
        "verify": "systemctl is-active {service} || echo 'inactive'",
    },
    "service.start": {
        "tier": "T1",
        "desc": "Start service",
        "params": {"service": "str"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemctl start {service} && echo 'Started {service}'",
        "verify": "systemctl is-active {service}",
    },

    # =========================================================================
    # 🧹 Maintenance (T1/T2) - system cleanup
    # =========================================================================
    "process.kill_zombies": {
        "tier": "T1",
        "desc": "Kill zombie processes",
        "params": {},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "ps aux | awk '$8 ~ /Z/ {print $2}' | xargs -r kill -9 2>/dev/null; echo 'Zombies cleared'",
        "verify": "ps aux | awk '$8 ~ /Z/' | wc -l",
    },
    "cleanup.tmp": {
        "tier": "T2",
        "desc": "Clean /tmp (7+ days)",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "find /tmp -type f -mtime +7 -delete 2>/dev/null; echo 'Cleaned /tmp'",
        "verify": "du -sh /tmp 2>/dev/null",
    },
    "cleanup.journal": {
        "tier": "T2",
        "desc": "Clean journal logs (keep 500MB)",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "journalctl --vacuum-size=500M 2>/dev/null || echo 'No journalctl'",
        "verify": "journalctl --disk-usage 2>/dev/null",
    },
    "cleanup.cache": {
        "tier": "T2",
        "desc": "Clear system cache",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "sync && echo 3 > /proc/sys/vm/drop_caches 2>/dev/null && echo 'Cache cleared' || echo 'Permission denied'",
        "verify": "free -h | head -2",
    },
    "cleanup.oldfiles": {
        "tier": "T2",
        "desc": "Delete old files",
        "params": {"path": "str", "days": "int"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "forbidden_paths": ["/", "/etc", "/var/lib/mpop", "/home", "/root", "/usr", "/bin", "/sbin"],
        "cmd": "find {path} -type f -mtime +{days} -delete 2>/dev/null; echo 'Cleaned {path}'",
        "verify": "du -sh {path} 2>/dev/null",
    },

    # =========================================================================
    # 🐳 Docker Management (T2)
    # =========================================================================
    "docker.prune": {
        "tier": "T2",
        "desc": "Docker system prune",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "docker system prune -f 2>/dev/null || echo 'Docker not installed'",
        "verify": "docker system df 2>/dev/null",
    },
    "docker.prune_images": {
        "tier": "T2",
        "desc": "Docker remove unused images",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "docker image prune -a -f 2>/dev/null || echo 'Docker not installed'",
        "verify": "docker images | wc -l",
    },
    "docker.restart": {
        "tier": "T2",
        "desc": "Docker restart container",
        "params": {"container": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "docker restart {container} 2>/dev/null && echo 'Restarted {container}'",
        "verify": "docker ps | grep {container}",
    },
    "docker.logs": {
        "tier": "T1",
        "desc": "Docker container logs",
        "params": {"container": "str", "lines": "int"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "docker logs --tail {lines} {container} 2>&1",
        "verify": None,
    },

    # =========================================================================
    # 🔥 Firewall/Security (T2)
    # =========================================================================
    "firewall.block": {
        "tier": "T2",
        "desc": "Block IP",
        "params": {"ip": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": False,
        "cmd": "iptables -A INPUT -s {ip} -j DROP && echo 'Blocked {ip}'",
        "verify": "iptables -L INPUT -n | grep {ip}",
    },
    "firewall.unblock": {
        "tier": "T2",
        "desc": "Unblock IP",
        "params": {"ip": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "iptables -D INPUT -s {ip} -j DROP 2>/dev/null; echo 'Unblocked {ip}'",
        "verify": "iptables -L INPUT -n | grep {ip} || echo 'Not found'",
    },
    "session.kill_idle": {
        "tier": "T2",
        "desc": "Kill idle SSH sessions",
        "params": {"idle_hours": "int"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "who -u | awk '$6 ~ /:/ && $6 > \"{idle_hours}:00\" {print $2}' | xargs -r pkill -t 2>/dev/null; echo 'Idle sessions killed'",
        "verify": "who",
    },

    # =========================================================================
    # 📤 Notification/Communication (T1)
    # =========================================================================
    "alert.log": {
        "tier": "T1",
        "desc": "Log to alert file",
        "params": {"message": "str"},
        "requires_approval": False,
        "requires_snapshot": False,
        "idempotent": False,
        "cmd": "echo \"[$(date '+%Y-%m-%d %H:%M:%S')] {message}\" >> /var/log/mpop-alerts.log && echo 'Alert logged'",
        "verify": "tail -1 /var/log/mpop-alerts.log",
    },

    # =========================================================================
    # 💣 Destructive (T3) - snapshot required
    # =========================================================================
    "log.rotate": {
        "tier": "T3",
        "desc": "Force log rotation",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": True,
        "cmd": "logrotate -f /etc/logrotate.conf 2>/dev/null && echo 'Logs rotated'",
        "verify": "ls -la /var/log/*.1 2>/dev/null | head -5",
    },
    "log.truncate": {
        "tier": "T3",
        "desc": "Truncate log file",
        "params": {"path": "str"},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": True,
        "forbidden_paths": ["/var/log/auth.log", "/var/log/secure", "/var/log/audit", "/var/log/mpop"],
        "cmd": "truncate -s 0 {path} && echo 'Truncated {path}'",
        "verify": "ls -la {path}",
    },
    "package.update": {
        "tier": "T3",
        "desc": "Update system packages",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": False,
        "cmd": "apt update && apt upgrade -y 2>/dev/null || yum update -y 2>/dev/null || echo 'Updated'",
        "verify": "apt list --upgradable 2>/dev/null | head -5",
    },
    "package.autoremove": {
        "tier": "T3",
        "desc": "Remove unused packages",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": True,
        "cmd": "apt autoremove -y 2>/dev/null || yum autoremove -y 2>/dev/null || echo 'Cleaned'",
        "verify": "dpkg -l | wc -l",
    },
    "process.kill": {
        "tier": "T3",
        "desc": "Kill process",
        "params": {"pid": "int"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "kill -9 {pid} 2>/dev/null && echo 'Killed PID {pid}' || echo 'Process not found'",
        "verify": "ps -p {pid} 2>/dev/null || echo 'Not running'",
    },
    "file.delete": {
        "tier": "T3",
        "desc": "Delete file",
        "params": {"path": "str"},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": True,
        "forbidden_paths": ["/", "/etc", "/var/lib/mpop", "/home", "/root", "/usr", "/bin", "/sbin", "/boot"],
        "cmd": "rm -f {path} && echo 'Deleted {path}'",
        "verify": "ls -la {path} 2>/dev/null || echo 'File not found'",
    },
    "reboot.schedule": {
        "tier": "T3",
        "desc": "Schedule reboot (1 min)",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": True,
        "idempotent": False,
        "cmd": "shutdown -r +1 'Scheduled reboot by mpop AI-OS' && echo 'Reboot scheduled in 1 minute'",
        "verify": None,
    },

    # =========================================================================
    # 📦 Deployment (T2/T3) - file/package transfer
    # =========================================================================
    "deploy.file": {
        "tier": "T2",
        "desc": "Download file from URL",
        "params": {"url": "str", "dest": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "forbidden_paths": ["/etc", "/usr/bin", "/usr/sbin", "/boot"],
        "cmd": "curl -fsSL '{url}' -o '{dest}' && chmod +x '{dest}' && echo 'Downloaded to {dest}'",
        "verify": "ls -la {dest}",
    },
    "deploy.agent": {
        "tier": "T2",
        "desc": "Deploy mpop agent",
        "params": {"source_url": "str"},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "curl -fsSL '{source_url}' -o /usr/local/bin/mpop && chmod +x /usr/local/bin/mpop && echo 'Agent deployed'",
        "verify": "mpop --version 2>/dev/null || echo 'Not installed'",
    },

    # =========================================================================
    # 🌐 Network (T2)
    # =========================================================================
    "network.dns_flush": {
        "tier": "T2",
        "desc": "Flush DNS cache",
        "params": {},
        "requires_approval": True,
        "requires_snapshot": False,
        "idempotent": True,
        "cmd": "systemd-resolve --flush-caches 2>/dev/null || dscacheutil -flushcache 2>/dev/null || echo 'Flushed'",
        "verify": None,
    },
}


def _ai_build_context():
    """Build context for AI plan generation"""
    context = {
        "servers": {},
        "constitution": "",
        "action_registry": {},
        "secrets": [],
        "history": [],
    }

    # Get server status (parallel for speed)
    servers = get_servers()
    with ThreadPoolExecutor(max_workers=10) as ex:
        def check_server(item):
            name, ip = item
            result = vssh_exec(ip, "uptime | head -1; df -h / | tail -1", timeout=3)
            if result:
                return name, {"status": "online", "info": result[:150]}
            return name, {"status": "offline"}

        for name, data in ex.map(check_server, servers.items()):
            context["servers"][name] = data

    # Get constitution from brain
    const = _brain_read("constitution.md")
    if const:
        context["constitution"] = const

    # Action registry (provide to LLM)
    context["action_registry"] = {
        name: {"tier": info["tier"], "desc": info["desc"]}
        for name, info in ACTION_REGISTRY.items()
    }

    # List secret names (not values!)
    # TODO: implement

    return context


def _ai_generate_plan(request, context, model="ollama"):
    """Generate plan via LLM"""

    prompt = f"""You are the mpop AI-OS operator.

## AI Constitution
{context.get('constitution', '(none)')}

## Current Server Status
{json.dumps(context.get('servers', {}), indent=2, ensure_ascii=False)}

## Available Actions
{json.dumps(context.get('action_registry', {}), indent=2, ensure_ascii=False)}

## User Request
{request}

## Instructions
Generate a Plan in JSON format to handle the request.

```json
{{
  "intent": "Summary of user request",
  "steps": [
    {{
      "id": "step-1",
      "action": "action_name",
      "target": "server_name",
      "params": {{}},
      "tier": "T1|T2|T3",
      "reason": "Why this action is needed"
    }}
  ]
}}
```

Output JSON only. No explanation needed."""

    if model == "ollama":
        return _ai_call_ollama(prompt)
    elif model in ("claude", "anthropic"):
        return _ai_call_claude(prompt)
    elif model in ("gpt4", "openai"):
        return _ai_call_openai(prompt)
    else:
        return _ai_call_ollama(prompt)


def _ai_call_ollama_text(prompt, host=None, model=None):
    """Call Ollama LLM and return raw text response"""
    import urllib.request

    # Get from config or defaults
    if not host:
        host = _get_ollama_host()
    if not model:
        model = _get_config("ollama_model", "qwen2.5:7b")

    servers = get_servers()
    ip = servers.get(host) or host.split(":")[0]

    try:
        url = f"http://{ip}:11434/api/generate"
        data = json.dumps({
            "model": model,
            "prompt": prompt,
            "stream": False
        }).encode()

        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode())
            return result.get("response", "")

    except Exception as e:
        print(f"\n  ⚠️  Ollama error: {e}")
        return None


def _ai_call_ollama(prompt, host=None, model=None):
    """Call Ollama LLM and parse JSON response"""
    import urllib.request
    import urllib.error

    # Get from config or defaults
    if not host:
        host = _get_ollama_host()
    if not model:
        model = _get_config("ollama_model", "qwen2.5:7b")

    servers = get_servers()
    ip = servers.get(host) or host.split(":")[0]

    try:
        url = f"http://{ip}:11434/api/generate"
        data = json.dumps({
            "model": model,
            "prompt": prompt,
            "stream": False
        }).encode()

        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode())
            response_text = result.get("response", "")

            # Parse JSON from response
            try:
                # Find JSON in response
                start = response_text.find("{")
                end = response_text.rfind("}") + 1
                if start >= 0 and end > start:
                    return json.loads(response_text[start:end])
            except Exception:
                pass

            return None

    except Exception as e:
        print(f"\n  ⚠️  Ollama error: {e}")
        return None


def _ai_call_claude(prompt, model="claude-sonnet-4-20250514"):
    """Call Claude API using $SECRET:anthropic"""
    import urllib.request
    import urllib.error

    # Get API key from secrets
    try:
        secrets = _load_secrets()
        api_key = secrets.get("anthropic", "")
        if not api_key:
            print("\n  ⚠️  Secret 'anthropic' not found. Run: mpop secret set anthropic")
            return None
    except Exception as e:
        print(f"\n  ⚠️  Failed to load secrets: {e}")
        return None

    try:
        url = "https://api.anthropic.com/v1/messages"
        data = json.dumps({
            "model": model,
            "max_tokens": 2048,
            "messages": [{"role": "user", "content": prompt}]
        }).encode()

        headers = {
            "Content-Type": "application/json",
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01"
        }

        req = urllib.request.Request(url, data=data, headers=headers)
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode())
            response_text = result.get("content", [{}])[0].get("text", "")

            # Parse JSON from response
            try:
                start = response_text.find("{")
                end = response_text.rfind("}") + 1
                if start >= 0 and end > start:
                    return json.loads(response_text[start:end])
            except Exception:
                pass

            return None

    except urllib.error.HTTPError as e:
        print(f"\n  ⚠️  Claude API error: {e.code} {e.reason}")
        return None
    except Exception as e:
        print(f"\n  ⚠️  Claude error: {e}")
        return None


def _ai_call_openai(prompt, model="gpt-4o-mini"):
    """Call OpenAI API using $SECRET:openai"""
    import urllib.request
    import urllib.error

    # Get API key from secrets
    try:
        secrets = _load_secrets()
        api_key = secrets.get("openai", "")
        if not api_key:
            print("\n  ⚠️  Secret 'openai' not found. Run: mpop secret set openai")
            return None
    except Exception as e:
        print(f"\n  ⚠️  Failed to load secrets: {e}")
        return None

    try:
        url = "https://api.openai.com/v1/chat/completions"
        data = json.dumps({
            "model": model,
            "messages": [{"role": "user", "content": prompt}],
            "response_format": {"type": "json_object"}
        }).encode()

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}"
        }

        req = urllib.request.Request(url, data=data, headers=headers)
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode())
            response_text = result.get("choices", [{}])[0].get("message", {}).get("content", "")

            # Parse JSON from response
            try:
                start = response_text.find("{")
                end = response_text.rfind("}") + 1
                if start >= 0 and end > start:
                    return json.loads(response_text[start:end])
            except Exception:
                pass

            return None

    except urllib.error.HTTPError as e:
        print(f"\n  ⚠️  OpenAI API error: {e.code} {e.reason}")
        return None
    except Exception as e:
        print(f"\n  ⚠️  OpenAI error: {e}")
        return None


def _ai_validate_plan(plan):
    """
    Validate plan against action registry and policies.

    Returns: (valid: bool, errors: list, meta: dict)
    meta contains:
      - needs_approval: list of step IDs requiring approval
      - needs_snapshot: list of step IDs requiring snapshot
      - tiers: dict of tier counts
    """
    errors = []
    meta = {
        "needs_approval": [],
        "needs_snapshot": [],
        "tiers": {"T1": 0, "T2": 0, "T3": 0},
        "countdown": 0,  # seconds
    }

    if not isinstance(plan, dict):
        return False, ["Plan is not a valid JSON object"], meta

    steps = plan.get("steps", [])
    if not steps:
        return False, ["Plan has no steps"], meta

    servers = get_servers()

    for step in steps:
        step_id = step.get("id", "?")
        action = step.get("action", "")
        target = step.get("target", "")
        tier = step.get("tier", "")
        params = step.get("params", {})

        # Check action exists in registry
        if action not in ACTION_REGISTRY:
            errors.append(f"{step_id}: unknown action '{action}'")
            continue

        action_info = ACTION_REGISTRY[action]

        # Check and fix tier
        expected_tier = action_info.get("tier", "T1")
        if tier != expected_tier:
            step["tier"] = expected_tier

        # Count tiers
        meta["tiers"][expected_tier] = meta["tiers"].get(expected_tier, 0) + 1

        # Check approval requirement
        if action_info.get("requires_approval", False):
            meta["needs_approval"].append(step_id)

        # Check snapshot requirement
        if action_info.get("requires_snapshot", False):
            meta["needs_snapshot"].append(step_id)

        # Check target exists
        if target not in servers and target != "all":
            errors.append(f"{step_id}: unknown target '{target}'")

        # Check required params
        required_params = action_info.get("params", {})
        for param_name, param_type in required_params.items():
            if param_name not in params:
                # Allow default values for some params
                if param_name == "lines":
                    params["lines"] = 20  # default
                elif param_name == "days":
                    params["days"] = 7  # default
                else:
                    errors.append(f"{step_id}: missing param '{param_name}'")

        # Check forbidden paths
        forbidden = action_info.get("forbidden_paths", [])
        if forbidden:
            paths_to_check = []
            if "path" in params:
                paths_to_check.append(params["path"])
            if "paths" in params:
                paths_to_check.extend(params["paths"] if isinstance(params["paths"], list) else [params["paths"]])
            if "dest" in params:
                paths_to_check.append(params["dest"])

            for path in paths_to_check:
                if path:
                    for fp in forbidden:
                        if path == fp or path.startswith(fp + "/"):
                            errors.append(f"{step_id}: forbidden path '{path}'")

    # Calculate countdown time based on tiers
    if meta["tiers"]["T3"] > 0:
        meta["countdown"] = 10
    elif meta["tiers"]["T2"] > 0:
        meta["countdown"] = 5
    else:
        meta["countdown"] = 0

    return len(errors) == 0, errors, meta


def _ai_execute_plan(plan, auto_approve=False, skip_verify=False):
    """
    Execute validated plan using ACTION_REGISTRY.

    Args:
        plan: Validated plan dict
        auto_approve: Skip approval prompts for T1 actions
        skip_verify: Skip post-execution verification

    Returns: dict of step_id -> {success, message, verified}
    """
    results = {}
    servers = get_servers()

    for step in plan.get("steps", []):
        step_id = step.get("id", "unknown")
        action = step.get("action", "")
        target = step.get("target", "")
        params = step.get("params", {})

        # Check if action exists in registry
        if action not in ACTION_REGISTRY:
            results[step_id] = {"success": False, "message": f"Unknown action: {action}"}
            continue

        action_info = ACTION_REGISTRY[action]
        tier = action_info.get("tier", "T1")
        cmd_template = action_info.get("cmd", "")
        verify_template = action_info.get("verify")
        requires_approval = action_info.get("requires_approval", False)

        # Check forbidden paths
        forbidden = action_info.get("forbidden_paths", [])
        if forbidden:
            paths_to_check = []
            if "path" in params:
                paths_to_check.append(params["path"])
            if "paths" in params:
                paths_to_check.extend(params["paths"] if isinstance(params["paths"], list) else [params["paths"]])
            if "dest" in params:
                paths_to_check.append(params["dest"])

            forbidden_found = False
            for p in paths_to_check:
                if p and any(p == f or p.startswith(f + "/") for f in forbidden):
                    results[step_id] = {"success": False, "message": f"Forbidden path: {p}"}
                    forbidden_found = True
                    break
            if forbidden_found:
                continue

        # Build command from template
        try:
            cmd_params = {}
            for k, v in params.items():
                if isinstance(v, list):
                    cmd_params[k] = " ".join(str(x) for x in v)
                else:
                    cmd_params[k] = str(v)

            cmd = cmd_template.format(**cmd_params) if cmd_params else cmd_template

        except KeyError as e:
            results[step_id] = {"success": False, "message": f"Missing param: {e}"}
            continue

        # Execute on target
        try:
            target_ip = servers.get(target)
            if not target_ip:
                results[step_id] = {"success": False, "message": f"Unknown target: {target}"}
                continue

            # Tier-based timeout
            timeout = {"T1": 30, "T2": 60, "T3": 120}.get(tier, 30)

            # Execute
            result = vssh_exec(target_ip, cmd, timeout=timeout)

            if result is not None:
                msg = result.strip()[:200] if result.strip() else "Done"
                step_result = {"success": True, "message": msg, "verified": None}

                # Run verification if available
                if verify_template and not skip_verify:
                    try:
                        verify_cmd = verify_template.format(**cmd_params) if cmd_params else verify_template
                        verify_result = vssh_exec(target_ip, verify_cmd, timeout=15)
                        if verify_result:
                            step_result["verified"] = verify_result.strip()[:100]
                    except Exception:
                        pass

                results[step_id] = step_result
            else:
                results[step_id] = {"success": False, "message": "No response"}

        except Exception as e:
            results[step_id] = {"success": False, "message": str(e)[:50]}

    return results


def _cmd_ai_trend(args):
    """Original AI trend report (GPT-powered)"""
    hours = 24
    gpt_model = "gpt-4o-mini"

    for arg in args:
        if arg.isdigit():
            hours = int(arg)
        elif arg == "--gpt4":
            gpt_model = "gpt-4o"

    lang = detect_system_language()

    print(f"""
  ═══════════════════════════════════════════════════════════
   AI TREND REPORT (GPT-powered)
   Period: {hours} hours
  ═══════════════════════════════════════════════════════════
""")

    # Get trend data from agent
    print("  📊 Fetching trend data from agent...")
    trends = query_agent(f"/trend?hours={hours}")

    if "error" in trends:
        print(f"  Error: {trends.get('error', 'Unknown')}")
        print("  Make sure mpop-agent is running on node1")
        return

    anomalies_data = query_agent(f"/anomalies?hours={hours}")
    anomalies = anomalies_data.get("anomalies", [])

    print(f"  🔍 Data points: {trends.get('total_records', 0)}")
    print(f"  ⚠️  Anomalies: {len(anomalies)}")
    print("  🤖 Generating AI report...\n")

    # Build prompt for GPT
    import json as json_lib
    trends_json = json_lib.dumps(trends, indent=2, default=str)
    anomalies_json = json_lib.dumps(anomalies, indent=2, default=str)

    prompt = f"""Analyze this server infrastructure TREND data and write a report.

=== TREND DATA (last {hours} hours) ===
{trends_json}

=== DETECTED ANOMALIES ===
{anomalies_json}

Write a comprehensive report:
1. 📊 Summary (2-3 sentences)
2. 📈 Key trends (disk growth rate, load changes, memory/GPU patterns)
3. ⚠️ Anomaly explanation (if any)
4. 🔮 Predictions (if current trends continue?)
5. 🛠️ Recommended actions

Be specific with server names, numbers, and rates (e.g., "2%/day disk growth").
Focus on TRENDS, not just current values."""

    report = ai_generate(prompt, lang=lang)

    print("  " + "─" * 55)
    for line in report.split('\n'):
        print(f"  {line}")
    print("  " + "─" * 55)
    print(f"  Generated by GPT ({gpt_model})")
    print()

def cmd_agent(args):
    """Check server-agent status on relay1"""
    print("  Checking server-agent status...")

    # Query server-agent API
    data = query_server_agent("/api/status")
    if "error" in data:
        print(f"  Server-agent not configured.")
        print("\n  To set up server-agent, run:")
        print("    mpop config set dashboard_url http://<your-server-ip>:8721")
        print("    mpop setup agent <server> -x")
        print("\n  Or use live scan (slower):")
        print("    mpop dashboard --live")
        return

    servers = data.get("servers", [])
    last_update = data.get("last_update", "?")
    agent_reports = data.get("agent_reports", {})

    online = sum(1 for s in servers if s.get("online"))
    total = len(servers)

    # Count by source
    agent_count = sum(1 for s in servers if s.get("source") == "agent")
    ssh_count = sum(1 for s in servers if s.get("source") == "ssh")

    print(f"""
  ═══════════════════════════════════════════════════════════
   server-agent on v1
  ═══════════════════════════════════════════════════════════

  Status: {online}/{total} servers online
  Last update: {last_update}
  Data sources: {agent_count} via agent, {ssh_count} via SSH

  Servers:""")

    for s in sorted(servers, key=lambda x: x.get("name", "")):
        name = s.get("name", "?")
        online_s = "ONLINE" if s.get("online") else "OFFLINE"
        source = s.get("source", "?")
        print(f"    {name:<6} | {online_s:<8} | {source}")

    # Show trends summary
    trends = query_server_agent("/api/trends?hours=24")
    if "error" not in trends:
        dashboard_url = SERVER_AGENT_URL or _get_config("dashboard_url", "")
        print(f"""
  History: {trends.get('total_records', 0)} records (5min intervals, 7 days retention)
  Dashboard: {dashboard_url}
""")
    else:
        print()

    # Note about deprecated mpop-agent
    print("  Note: mpop-agent is deprecated. Use server-agent instead: mpop setup agent <server> -x")
    print()

def cmd_full(args):
    """Full report: ALL dashboard tables + Trends + AI Summary"""
    lang = detect_system_language()
    gpt_model = "gpt-4o-mini"
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    vpn_ip = get_vpn_ip()
    my_name = get_my_name()

    print(f"""
  ╔═══════════════════════════════════════════════════════════════════════════╗
  ║  📊 FULL INFRASTRUCTURE REPORT                                             ║
  ║  Observer: {my_name:4} | {now}                                       ║
  ╚═══════════════════════════════════════════════════════════════════════════╝
""")

    # Collect all data
    print("  [1/5] Collecting basic data from all servers...")
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    # Collect extended info (security, processes, docker, updates)
    print("  [2/5] Collecting extended info (security, processes, docker)...")
    extended = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        def get_ext(name, ip):
            if ip == vpn_ip:
                return name, {}  # Skip local
            r = results.get(name, {})
            if not r.get("vssh"):
                return name, {}
            return name, get_extended_info(ip)

        futures = [ex.submit(get_ext, name, ip) for name, ip in SERVERS.items()]
        for future in as_completed(futures):
            name, ext = future.result()
            extended[name] = ext

    online = sum(1 for r in results.values() if r.get("ping") or r.get("me"))
    total = len(SERVERS)

    # ═══════════════════════════════════════════════════════════════
    # TABLE 1: CONNECTIVITY
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  1️⃣  CONNECTIVITY                                   [{online}/{total} online]  ┃
  ┣━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ VPN IP          ┃ Status                                     ┃
  ┣━━━━━━╋━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        r = results.get(name, {})
        ip = SERVERS[name]
        if r.get("me"):
            print(f"  ┃ {name:4} ┃ {ip:15} ┃ ◀ HERE (observer)                        ┃")
        elif not r.get("ping"):
            print(f"  ┃ {name:4} ┃ {ip:15} ┃ ❌ OFFLINE                                ┃")
        else:
            ssh_s = "✓" if r.get("ssh") else "✗"
            vssh_s = "✓" if r.get("vssh") else "✗"
            print(f"  ┃ {name:4} ┃ {ip:15} ┃ ssh:{ssh_s}  vssh:{vssh_s}                            ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 2: PERFORMANCE
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  2️⃣  PERFORMANCE                                                      ┃
  ┣━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Load (1/5/15 min)  ┃ CPU  ┃ Mem  ┃ Disk   ┃ GPU                ┃
  ┣━━━━━━╋━━━━━━━━━━━━━━━━━━━━╋━━━━━━╋━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        r = results.get(name, {})
        stats = r.get("stats") or {}

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃        -           ┃  -   ┃  -   ┃   -    ┃         -          ┃")
            continue

        load_full = stats.get("load_full", "-")[:16]
        cpu = stats.get("cpu", "-")[:4]
        mem = stats.get("mem", "-")[:4]
        disk = stats.get("disk", "-")

        # GPU info if available
        gpu = "-"
        if "gpu_util" in stats:
            gpu = f"{stats.get('gpu_util', '-')}%"

        # Load indicator
        try:
            l = float(stats.get("load", "0"))
            if l > 5:
                load_s = f"!!{load_full}"
            elif l > 2:
                load_s = f"* {load_full}"
            else:
                load_s = f"  {load_full}"
        except Exception:
            load_s = f"  {load_full}"

        # Disk indicator
        disk_i = disk_icon(disk)
        disk_s = f"{disk_i}{disk}"

        print(f"  ┃ {name:4} ┃ {load_s:18} ┃ {cpu:4} ┃ {mem:4} ┃ {disk_s:6} ┃ {gpu:18} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━━━━━━━━━━━╋━━━━━━┻━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 3: SERVICES & UPTIME
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  3️⃣  SERVICES & UPTIME                                                ┃
  ┣━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Uptime         ┃ Services                                     ┃
  ┣━━━━━━╋━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        r = results.get(name, {})
        stats = r.get("stats") or {}

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃       -        ┃                   -                      ┃")
            continue

        uptime = stats.get("uptime", "-")[:14]
        svcs = stats.get("services", "-")[:40]
        print(f"  ┃ {name:4} ┃ {uptime:14} ┃ {svcs:44} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 4: TRENDS (from agent)
    # ═══════════════════════════════════════════════════════════════
    print("\n  [3/5] Fetching trend data from agent...")
    trends = query_agent("/trend?hours=24")
    anomalies_data = query_agent("/anomalies?hours=24")
    anomalies = anomalies_data.get("anomalies", [])

    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  4️⃣  24H TRENDS                                                       ┃
  ┣━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Changes                                                        ┃
  ┣━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    trend_summary = []
    if "error" not in trends and trends.get("servers"):
        for name, t in trends.get("servers", {}).items():
            if "changes" not in t:
                print(f"  ┃ {name:4} ┃ No data                                                    ┃")
                continue

            changes = t.get("changes", {})
            parts = []

            if "disk" in changes:
                d = changes["disk"]
                rate = d.get("rate_per_day", 0)
                if rate != 0:
                    arrow = "↑" if rate > 0 else "↓"
                    parts.append(f"disk:{arrow}{abs(rate):.1f}%/day")
                    if rate > 2:
                        trend_summary.append(f"{name} disk +{rate}%/day")

            if "load" in changes:
                l = changes["load"]
                if l.get("max", 0) > 3:
                    parts.append(f"load:max={l['max']:.1f}")
                    if l.get("max", 0) > 5:
                        trend_summary.append(f"{name} load spike {l['max']}")

            if "memory" in changes:
                m = changes["memory"]
                parts.append(f"mem:avg={m.get('avg', 0):.0f}%")

            if "gpu" in changes:
                g = changes["gpu"]
                parts.append(f"gpu:avg={g.get('avg', 0):.0f}%")

            changes_str = ", ".join(parts) if parts else "stable"
            print(f"  ┃ {name:4} ┃ {changes_str:58} ┃")
    else:
        print(f"  ┃  -   ┃ Agent data not available (run: mpop agent collect)       ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # Anomalies
    if anomalies:
        print("\n  ⚠️  ANOMALIES:")
        for a in anomalies[:5]:
            sev = "🔴" if a.get("severity") == "critical" else "⚠️"
            print(f"     {sev} {a.get('server')}: {a.get('detail')}")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 5: SECURITY
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  5️⃣  SECURITY (24h)                                                     ┃
  ┣━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ SSH Fail┃ Banned ┃ Top Attackers                                ┃
  ┣━━━━━━╋━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    security_issues = []
    for name in SERVERS.keys():
        ext = extended.get(name, {})
        r = results.get(name, {})

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃    -    ┃   -    ┃                     -                     ┃")
            continue

        if r.get("me"):
            print(f"  ┃ {name:4} ┃    -    ┃   -    ┃ (local - use journalctl)                  ┃")
            continue

        ssh_fail = ext.get("ssh_fail_24h", "-")
        f2b = ext.get("f2b_banned", "-")
        attackers = ext.get("attackers", "-")[:40]

        # Track security issues
        try:
            if int(ssh_fail) > 100:
                security_issues.append(f"{name}: {ssh_fail} SSH attacks")
        except Exception: pass

        print(f"  ┃ {name:4} ┃ {ssh_fail:7} ┃ {f2b:6} ┃ {attackers:43} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 6: OPEN PORTS & FIREWALL
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  6️⃣  PORTS & FIREWALL                                                   ┃
  ┣━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Firewall ┃ Open Ports                                           ┃
  ┣━━━━━━╋━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        ext = extended.get(name, {})
        r = results.get(name, {})

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃    -     ┃                           -                     ┃")
            continue

        if r.get("me"):
            print(f"  ┃ {name:4} ┃    -     ┃ (local)                                         ┃")
            continue

        fw = ext.get("firewall", "-")[:8]
        ports = ext.get("ports", "-")[:50]
        print(f"  ┃ {name:4} ┃ {fw:8} ┃ {ports:51} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 7: TOP PROCESSES
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  7️⃣  TOP PROCESSES                                                      ┃
  ┣━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Top CPU consumers                                                ┃
  ┣━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        ext = extended.get(name, {})
        r = results.get(name, {})

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃                               -                             ┃")
            continue

        if r.get("me"):
            print(f"  ┃ {name:4} ┃ (local - use top)                                           ┃")
            continue

        procs = ext.get("procs_cpu", "-")[:60]
        print(f"  ┃ {name:4} ┃ {procs:63} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 8: DOCKER CONTAINERS
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  8️⃣  DOCKER CONTAINERS                                                  ┃
  ┣━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Count  ┃ Containers                                             ┃
  ┣━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    for name in SERVERS.keys():
        ext = extended.get(name, {})
        r = results.get(name, {})

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃   -    ┃                          -                       ┃")
            continue

        if r.get("me"):
            print(f"  ┃ {name:4} ┃   -    ┃ (local)                                          ┃")
            continue

        cnt = ext.get("docker_cnt", "0")
        docker = ext.get("docker", "-")[:50]
        print(f"  ┃ {name:4} ┃ {cnt:6} ┃ {docker:52} ┃")

    print("  ┗━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # TABLE 9: SCRIPTS & UPDATES
    # ═══════════════════════════════════════════════════════════════
    print(f"""
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃  9️⃣  SCRIPTS & UPDATES                                                  ┃
  ┣━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
  ┃ Node ┃ Updates ┃ Kernel ┃ Running Scripts                              ┃
  ┣━━━━━━╋━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫""")

    update_needed = []
    for name in SERVERS.keys():
        ext = extended.get(name, {})
        r = results.get(name, {})

        if not r.get("ping") and not r.get("me"):
            print(f"  ┃ {name:4} ┃    -    ┃   -    ┃                     -                     ┃")
            continue

        if r.get("me"):
            print(f"  ┃ {name:4} ┃    -    ┃   -    ┃ (local)                                   ┃")
            continue

        updates = ext.get("updates", "-")
        kernel = ext.get("kernel", "-")[:6]
        scripts = ext.get("scripts", "-")[:40]

        # Track servers needing updates
        try:
            if int(updates) > 50:
                update_needed.append(f"{name}: {updates} updates")
        except Exception: pass

        print(f"  ┃ {name:4} ┃ {updates:7} ┃ {kernel:6} ┃ {scripts:43} ┃")

    print("  ┗━━━━━━┻━━━━━━━━━┻━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")

    # ═══════════════════════════════════════════════════════════════
    # AI SUMMARY
    # ═══════════════════════════════════════════════════════════════
    print("\n  [4/5] Generating AI summary...")

    # Build compact data for GPT
    status_summary = ""
    for name, r in results.items():
        stats = r.get("stats") or {}
        ext = extended.get(name, {})
        if r.get("ping"):
            status_summary += f"{name}: load={stats.get('load','-')}, disk={stats.get('disk','-')}, mem={stats.get('mem','-')}, ssh_attacks={ext.get('ssh_fail_24h','-')}, docker={ext.get('docker_cnt','0')}\n"
        else:
            status_summary += f"{name}: OFFLINE\n"

    trend_text = ", ".join(trend_summary) if trend_summary else "No significant trends"
    security_text = ", ".join(security_issues) if security_issues else "No major attacks"
    update_text = ", ".join(update_needed) if update_needed else "All up to date"

    # Combined prompt: infrastructure status summary
    prompt = f"""You are an infrastructure analyst. Write a brief report based on this data.

Data:
{status_summary}
Trends: {trend_text}
Security: {security_text}
Updates: {update_text}

Write concisely (max 10 lines) in this format:

📊 Status Summary
[Overall server status in 1-2 lines]

⚠️ Issues & Risks
[Any concerns or warnings]

✅ Action Items
[What to do today, if anything]"""

    summary = ai_generate(prompt, lang=lang)

    print(f"""
  ═══════════════════════════════════════════════════════════════════════════
  {summary}
  ═══════════════════════════════════════════════════════════════════════════
  [5/5] Report complete | Generated by GPT ({gpt_model}) | mpop full v2.0
""")

def cmd_temp(args):
    """Show CPU/GPU temperature of all servers"""
    vpn_ip = get_vpn_ip()

    print(f"""
  🌡️  SERVER TEMPERATURES
  ════════════════════════════════════════════════════════════════════════
  Node │ CPU Temp │ GPU Temp │ Fans       │ Status
  ─────┼──────────┼──────────┼────────────┼────────────────────────────────""")

    def get_temp(name, ip):
        if ip == vpn_ip:
            return name, {"cpu": "-", "gpu": "-", "fans": "-", "status": "(local)"}

        cmd = r"""
echo "CPU:$(sensors 2>/dev/null | grep -E 'Core 0|Tctl|Package id' | head -1 | grep -oE '[0-9]+\.[0-9]+°C' | head -1 || cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk '{printf \"%.1f°C\", $1/1000}' || echo '-')"
echo "GPU:$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader 2>/dev/null | head -1 | xargs -I{} echo '{}°C' || echo '-')"
echo "FANS:$(sensors 2>/dev/null | grep -i fan | head -1 | grep -oE '[0-9]+ RPM' || echo '-')"
"""
        result = vssh_exec(ip, cmd.strip(), timeout=5)

        temp = {"cpu": "-", "gpu": "-", "fans": "-", "status": ""}
        if not result:
            temp["status"] = "offline"
            return name, temp

        for line in result.split('\n'):
            if line.startswith("CPU:"):
                temp["cpu"] = line[4:].strip()[:8]
            elif line.startswith("GPU:"):
                temp["gpu"] = line[4:].strip()[:8]
            elif line.startswith("FANS:"):
                temp["fans"] = line[5:].strip()[:10]

        # Status based on temp
        try:
            cpu_val = float(temp["cpu"].replace("°C", ""))
            if cpu_val > 80:
                temp["status"] = "🔴 HOT!"
            elif cpu_val > 60:
                temp["status"] = "⚠️ warm"
            else:
                temp["status"] = "✓ normal"
        except Exception:
            temp["status"] = "-"

        return name, temp

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = [ex.submit(get_temp, name, ip) for name, ip in SERVERS.items()]
        results = {}
        for future in as_completed(futures):
            name, temp = future.result()
            results[name] = temp

    for name in SERVERS.keys():
        t = results.get(name, {})
        cpu = t.get("cpu", "-")[:8]
        gpu = t.get("gpu", "-")[:8]
        fans = t.get("fans", "-")[:10]
        status = t.get("status", "-")[:30]
        print(f"  {name:4} │ {cpu:8} │ {gpu:8} │ {fans:10} │ {status}")

    print("  ════════════════════════════════════════════════════════════════════════")

def cmd_gpu(args):
    """Show GPU status and utilization"""
    vpn_ip = get_vpn_ip()

    print(f"""
  🎮  GPU STATUS
  ══════════════════════════════════════════════════════════════════════════════════
  Node │ GPU Model                  │ Util │ Mem  │ Temp │ Power    │ Processes
  ─────┼────────────────────────────┼──────┼──────┼──────┼──────────┼─────────────""")

    def get_gpu(name, ip):
        if ip == vpn_ip:
            return name, {"model": "-", "util": "-", "mem": "-", "temp": "-", "power": "-", "procs": "(local)"}

        cmd = """
OUT=$(nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits 2>/dev/null | head -1)
if [ -n "$OUT" ]; then
  name=$(echo "$OUT" | cut -d',' -f1 | xargs)
  util=$(echo "$OUT" | cut -d',' -f2 | tr -d ' ')
  memused=$(echo "$OUT" | cut -d',' -f3 | tr -d ' []')
  memtotal=$(echo "$OUT" | cut -d',' -f4 | tr -d ' []')
  echo "$memused" | grep -qE '^[0-9]+$' || memused=""
  echo "$memtotal" | grep -qE '^[0-9]+$' || memtotal=""
  temp=$(echo "$OUT" | cut -d',' -f5 | tr -d ' ')
  power=$(echo "$OUT" | cut -d',' -f6 | tr -d ' ')
  if [ -z "$memused" ] || [ -z "$memtotal" ] || [ "$memused" = "N/A" ] || [ "$memtotal" = "N/A" ]; then
    memfallback=$(nvidia-smi 2>/dev/null | grep -oE '[0-9]+ *MiB *\\/ *[0-9]+ *MiB' | head -1 | sed 's/MiB//g' | sed 's/ *\\/ */\\//' | tr -s ' ')
    if [ -n "$memfallback" ]; then
      memused=$(echo "$memfallback" | cut -d'/' -f1 | tr -d ' ')
      memtotal=$(echo "$memfallback" | cut -d'/' -f2 | tr -d ' ')
    fi
  fi
  echo "MODEL:$name"
  echo "UTIL:${util}%"
  if [ -n "$memused" ] && [ -n "$memtotal" ] && [ "$memused" != "N/A" ] && [ "$memtotal" != "N/A" ]; then
    echo "MEM:${memused}/${memtotal}MB"
  else
    echo "MEM:-"
  fi
  echo "TEMP:${temp}C"
  echo "POWER:${power}W"
else
  echo "MODEL:-"
fi
echo "PROCS:$(nvidia-smi --query-compute-apps=pid,process_name --format=csv,noheader 2>/dev/null | head -3 | cut -d',' -f2 | tr -d ' ' | tr '\n' ',' | sed 's/,$//' || echo '-')"
"""
        result = vssh_exec(ip, cmd.strip(), timeout=5)

        gpu = {"model": "-", "util": "-", "mem": "-", "temp": "-", "power": "-", "procs": "-"}
        if not result:
            gpu["model"] = "N/A"
            return name, gpu

        for line in result.split('\n'):
            if line.startswith("MODEL:"):
                val = line[6:].strip()
                gpu["model"] = val[:26] if val else "-"
            elif line.startswith("UTIL:"):
                gpu["util"] = line[5:].strip()[:4]
            elif line.startswith("MEM:"):
                gpu["mem"] = line[4:].strip()[:10]
            elif line.startswith("TEMP:"):
                gpu["temp"] = line[5:].strip()[:4]
            elif line.startswith("POWER:"):
                gpu["power"] = line[6:].strip()[:8]
            elif line.startswith("PROCS:"):
                gpu["procs"] = line[6:].strip()[:12]

        return name, gpu

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = [ex.submit(get_gpu, name, ip) for name, ip in SERVERS.items()]
        results = {}
        for future in as_completed(futures):
            name, gpu = future.result()
            results[name] = gpu

    has_gpu = False
    for name in SERVERS.keys():
        g = results.get(name, {})
        if g.get("model", "-") not in ["-", "N/A", ""]:
            has_gpu = True
            model = g.get("model", "-")[:26]
            util = g.get("util", "-")[:4]
            mem = g.get("mem", "-")[:10]
            temp = g.get("temp", "-")[:4]
            power = g.get("power", "-")[:8]
            procs = g.get("procs", "-")[:12]
            print(f"  {name:4} │ {model:26} │ {util:4} │ {mem:10} │ {temp:4} │ {power:8} │ {procs}")

    if not has_gpu:
        print("  (no GPU-equipped servers detected)")


def cmd_hw(args):
    """Hardware advisor - check network, disk, memory and give optimization advice"""
    vpn_ip = get_vpn_ip()
    target = args[0] if args else None

    print(f"""
  🔧  HARDWARE ADVISOR
  ══════════════════════════════════════════════════════════════════════════════════""")

    issues = []
    advice = []

    def check_local():
        """Check local machine hardware"""
        local_issues = []
        local_advice = []

        # Network interfaces
        print(f"\n  {C.BOLD}Network Interfaces:{C.R}")
        try:
            import subprocess
            if sys.platform == 'darwin':
                r = subprocess.run(['networksetup', '-listallhardwareports'],
                                   capture_output=True, text=True, timeout=5)
                lines = r.stdout.split('\n')
                i = 0
                while i < len(lines):
                    if 'Hardware Port:' in lines[i]:
                        port = lines[i].split(':', 1)[1].strip()
                        device = lines[i+1].split(':', 1)[1].strip() if i+1 < len(lines) else ''
                        # Check speed
                        r2 = subprocess.run(['ifconfig', device], capture_output=True, text=True, timeout=2)
                        speed = "unknown"
                        status = "down"
                        if 'status: active' in r2.stdout:
                            status = "active"
                            if '1000baseT' in r2.stdout or 'Gigabit' in r2.stdout.lower():
                                speed = "1Gbps"
                            elif '100baseT' in r2.stdout or '100M' in port:
                                speed = "100Mbps"
                                local_issues.append(f"{device}: Only 100Mbps!")
                                local_advice.append(f"Use Gigabit adapter instead of {port}")
                            elif '10baseT' in r2.stdout:
                                speed = "10Mbps"
                                local_issues.append(f"{device}: Only 10Mbps!")
                            elif '802.11' in r2.stdout or 'Wi-Fi' in port:
                                speed = "Wi-Fi"

                        if status == "active":
                            color = C.GREEN if speed in ['1Gbps', 'Wi-Fi'] else C.YELLOW if speed == '100Mbps' else C.RED
                            print(f"    {device:8} {color}{speed:10}{C.R} {port}")
                    i += 1
            else:
                r = subprocess.run(['ip', '-o', 'link', 'show'], capture_output=True, text=True, timeout=5)
                for line in r.stdout.split('\n'):
                    if 'state UP' in line:
                        parts = line.split(':')
                        if len(parts) >= 2:
                            device = parts[1].strip()
                            # Get speed
                            try:
                                with open(f'/sys/class/net/{device}/speed') as f:
                                    speed_mbps = int(f.read().strip())
                                    if speed_mbps >= 1000:
                                        speed = f"{speed_mbps//1000}Gbps"
                                        color = C.GREEN
                                    elif speed_mbps == 100:
                                        speed = "100Mbps"
                                        color = C.YELLOW
                                        local_issues.append(f"{device}: Only 100Mbps")
                                    else:
                                        speed = f"{speed_mbps}Mbps"
                                        color = C.RED
                                    print(f"    {device:15} {color}{speed:10}{C.R}")
                            except Exception:
                                pass
        except Exception as e:
            print(f"    {C.RED}Error checking network: {e}{C.R}")

        # Disk speed hint
        print(f"\n  {C.BOLD}Disk:{C.R}")
        try:
            if sys.platform == 'darwin':
                r = subprocess.run(['diskutil', 'info', '/'], capture_output=True, text=True, timeout=5)
                for line in r.stdout.split('\n'):
                    if 'Solid State' in line:
                        is_ssd = 'Yes' in line
                        print(f"    Root: {'SSD' if is_ssd else 'HDD'}")
                        if not is_ssd:
                            local_issues.append("Root disk is HDD, not SSD")
                            local_advice.append("Upgrade to SSD for 10x faster file operations")
                        break
            else:
                r = subprocess.run(['lsblk', '-d', '-o', 'NAME,ROTA'], capture_output=True, text=True, timeout=5)
                for line in r.stdout.split('\n')[1:]:
                    parts = line.split()
                    if len(parts) >= 2:
                        device, rota = parts[0], parts[1]
                        is_ssd = rota == '0'
                        print(f"    {device}: {'SSD' if is_ssd else 'HDD'}")
        except Exception:
            pass

        # Memory
        print(f"\n  {C.BOLD}Memory:{C.R}")
        try:
            if sys.platform == 'darwin':
                r = subprocess.run(['sysctl', 'hw.memsize'], capture_output=True, text=True, timeout=2)
                mem_bytes = int(r.stdout.split(':')[1].strip())
                mem_gb = mem_bytes / 1024 / 1024 / 1024
                print(f"    Total: {mem_gb:.0f}GB")
                if mem_gb < 8:
                    local_issues.append(f"Only {mem_gb:.0f}GB RAM")
                    local_advice.append("Consider upgrading to 16GB+ for better performance")
            else:
                with open('/proc/meminfo') as f:
                    for line in f:
                        if 'MemTotal' in line:
                            mem_kb = int(line.split()[1])
                            mem_gb = mem_kb / 1024 / 1024
                            print(f"    Total: {mem_gb:.0f}GB")
                            break
        except Exception:
            pass

        return local_issues, local_advice

    def check_remote(name, ip):
        """Check remote server hardware"""
        remote_issues = []
        remote_advice = []

        cmd = """
# Network speed
for iface in $(ls /sys/class/net/ 2>/dev/null); do
  speed=$(cat /sys/class/net/$iface/speed 2>/dev/null)
  state=$(cat /sys/class/net/$iface/operstate 2>/dev/null)
  if [ "$state" = "up" ] && [ -n "$speed" ] && [ "$speed" != "-1" ]; then
    echo "NET:$iface:$speed"
  fi
done

# Disk type
for disk in $(lsblk -d -n -o NAME 2>/dev/null); do
  rota=$(cat /sys/block/$disk/queue/rotational 2>/dev/null)
  echo "DISK:$disk:$rota"
done

# Memory
mem=$(awk '/MemTotal/ {print int($2/1024/1024)}' /proc/meminfo 2>/dev/null)
echo "MEM:$mem"

# CPU
cores=$(nproc 2>/dev/null)
echo "CPU:$cores"
"""
        result = vssh_exec(ip, cmd.strip(), timeout=5)
        if not result:
            return [(f"{name}: unreachable", "Check VPN connection")], []

        for line in result.split('\n'):
            if line.startswith('NET:'):
                parts = line.split(':')
                if len(parts) >= 3:
                    iface, speed = parts[1], int(parts[2])
                    if speed == 100:
                        remote_issues.append(f"{name} {iface}: 100Mbps")
                        remote_advice.append(f"{name}: Use Gigabit connection")
            elif line.startswith('DISK:'):
                parts = line.split(':')
                if len(parts) >= 3 and parts[2] == '1':
                    remote_issues.append(f"{name}: HDD detected ({parts[1]})")
                    remote_advice.append(f"{name}: Consider SSD upgrade")
            elif line.startswith('MEM:'):
                mem_gb = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
                if mem_gb < 8:
                    remote_issues.append(f"{name}: Only {mem_gb}GB RAM")

        return remote_issues, remote_advice

    # Check local
    print(f"\n  {C.CYAN}[Local Machine]{C.R}")
    local_issues, local_advice = check_local()
    issues.extend(local_issues)
    advice.extend(local_advice)

    # Check remote servers if requested
    if target:
        servers_to_check = {target: SERVERS.get(target)} if target in SERVERS else {}
    elif len(args) > 0 and args[0] == '--all':
        servers_to_check = SERVERS
    else:
        servers_to_check = {}

    if servers_to_check:
        print(f"\n  {C.CYAN}[Remote Servers]{C.R}")
        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = {ex.submit(check_remote, name, ip): name for name, ip in servers_to_check.items() if ip != vpn_ip}
            for future in as_completed(futures):
                name = futures[future]
                try:
                    r_issues, r_advice = future.result()
                    issues.extend(r_issues)
                    advice.extend(r_advice)
                except Exception:
                    pass

    # Summary
    print(f"\n  ══════════════════════════════════════════════════════════════════════════════════")

    if issues:
        print(f"\n  {C.YELLOW}⚠ Issues Found ({len(issues)}):{C.R}")
        for issue in issues:
            print(f"    • {issue}")

    if advice:
        print(f"\n  {C.GREEN}💡 Recommendations:{C.R}")
        for adv in advice:
            print(f"    • {adv}")

    if not issues and not advice:
        print(f"\n  {C.GREEN}✓ No issues found. Hardware looks good!{C.R}")

    print(f"""
  ─────────────────────────────────────────────────────────────────────────────────
  Usage: mpop hw              Check local machine
         mpop hw <server>     Check specific server
         mpop hw --all        Check all servers
  """)


    print("  ══════════════════════════════════════════════════════════════════════════════════")


def _get_dashboard_data():
    """Fetch server data from dashboard API (cached agent reports)"""
    try:
        import urllib.request
        dashboard_url = _get_config("dashboard_url", "").rstrip("/")
        if not dashboard_url:
            return None  # dashboard_url not configured
        url = dashboard_url + "/api/status"
        with urllib.request.urlopen(url, timeout=3) as resp:
            data = json.loads(resp.read())
            # Support both mesh v2 ("nodes") and legacy ("servers") formats
            normalized = _normalize_agent_response(data)
            return normalized.get("servers", [])
    except Exception:
        return None


def _advise_from_dashboard(servers_to_check):
    """Get advise data from dashboard (fast, cached)"""
    srv_list = _get_dashboard_data()
    if not srv_list:
        return None

    result = {}
    for srv in srv_list:
        name = srv.get("name", "")
        # Match server name
        matched_name = None
        for check_name in servers_to_check.keys():
            if check_name == name.lower() or name.lower().startswith(check_name):
                matched_name = check_name
                break
        if not matched_name:
            continue

        result[matched_name] = {
            "cpu_pct": srv.get("cpu_pct", 0),
            "mem_pct": srv.get("mem_pct", 0),
            "disk_pct": srv.get("disk_pct", 0),
            "load": str(srv.get("load", "-")),
            "uptime": str(srv.get("uptime", "-")),
            "online": srv.get("online", False),
            "hardware": srv.get("hardware", {}),
            "software": srv.get("software", {}),
            "docker": srv.get("docker", []),
            "services": srv.get("services", {}),
            "security": srv.get("security", {}),
            "memory": srv.get("memory", "-"),
            "disk_used": srv.get("disk_used", "-"),
        }
    return result


def _advise_with_ai(servers, vpn_ip):
    """Get AI advice using local Ollama LLM"""
    print(f"\n  {C.CYAN}🤖 AI INFRASTRUCTURE ADVISOR{C.R}")
    print(f"  Collecting data...")

    # Get dashboard data (fast)
    dashboard_data = _advise_from_dashboard(servers)

    if not dashboard_data:
        print(f"  {C.RED}Error: Could not fetch server data{C.R}")
        return

    # Build prompt
    prompt = f"""You are an infrastructure advisor. Analyze this server cluster data and provide recommendations.

## Server Data
{json.dumps(dashboard_data, indent=2, default=str)}

## Analysis Required
1. **Performance Issues**: Any servers with high CPU/memory/disk usage?
2. **Hardware Upgrades**: Old hardware that needs replacement? (HDD→SSD, more RAM, etc.)
3. **Software Updates**: Servers with many pending updates?
4. **Cost Optimization**: Any underutilized servers? VPS alternatives?
5. **Security**: Any concerns from the data?

## Output Format
Provide 3-5 specific, actionable recommendations. Be concise.
Use this format:
[PRIORITY] Server: Recommendation (reason)

Example:
[HIGH] v1: Upgrade to SSD (HDD causing I/O bottleneck)
[MEDIUM] g2: Apply 428 pending updates (security risk)
"""

    ollama_host = _get_ollama_host()
    print(f"  Asking Ollama ({ollama_host})...")

    try:
        response = _ai_call_ollama_text(prompt)
        if response:
            print(f"\n  {C.GREEN}━━━ AI RECOMMENDATIONS ━━━{C.R}\n")
            # Clean and print response
            for line in response.strip().split('\n'):
                if line.strip():
                    # Color code priorities
                    if '[HIGH]' in line or '[CRITICAL]' in line:
                        print(f"  {C.RED}{line}{C.R}")
                    elif '[MEDIUM]' in line:
                        print(f"  {C.YELLOW}{line}{C.R}")
                    elif '[LOW]' in line:
                        print(f"  {C.GREEN}{line}{C.R}")
                    else:
                        print(f"  {line}")
        else:
            print(f"  {C.RED}No response from AI{C.R}")
    except Exception as e:
        print(f"  {C.RED}AI Error: {e}{C.R}")

    print()


def _shorten_uptime(uptime_str):
    """Convert uptime to short format: '12 weeks' -> '12w', '3 days' -> '3d'"""
    if not uptime_str or uptime_str == "-":
        return "-"
    s = str(uptime_str).lower().strip()
    # Extract number and unit
    import re
    m = re.match(r'(\d+)\s*(week|day|hour|min|sec|w|d|h|m|s)', s)
    if m:
        n, unit = m.groups()
        u = unit[0]  # first char: w, d, h, m, s
        return f"{n}{u}"
    # Already short or unknown format
    return s[:6]


def _advise_show_dashboard(data, show_hw, show_sw, show_srv, show_sec, show_ops, show_vps):
    """Display advise using cached dashboard data (fast)"""

    issues = []

    # ========== SERVER STATUS (quick overview) ==========
    if show_srv:
        print(f"\n  {C.CYAN}━━━ SERVER STATUS ━━━{C.R}  (from agent cache)")
        print(f"""
  Node │ CPU% │ Mem% │ Disk │ Load  │ Uptime │ Status
  ─────┼──────┼──────┼──────┼───────┼────────┼────────""")

        for name in sorted(data.keys()):
            d = data[name]
            cpu = d.get("cpu_pct", 0)
            mem = d.get("mem_pct", 0)
            disk = d.get("disk_pct", 0)
            load = str(d.get("load", "-"))[:5]
            uptime = _shorten_uptime(d.get("uptime", "-"))
            online = d.get("online", False)

            cpu_c = C.RED if cpu > 90 else (C.YELLOW if cpu > 70 else C.GREEN)
            mem_c = C.RED if mem > 90 else (C.YELLOW if mem > 70 else C.GREEN)
            disk_c = C.RED if disk > 90 else (C.YELLOW if disk > 80 else C.GREEN)
            status = f"{C.GREEN}OK{C.R}" if online else f"{C.RED}DOWN{C.R}"

            print(f"  {name:4} │ {cpu_c}{cpu:3}%{C.R} │ {mem_c}{mem:3}%{C.R} │ {disk_c}{disk:3}%{C.R} │ {load:5} │ {uptime:6} │ {status}")

            if disk > 90:
                issues.append((name, f"Disk {disk}% full"))
            if mem > 95:
                issues.append((name, f"Memory {mem}% used"))

    # ========== HARDWARE ==========
    if show_hw:
        print(f"\n  {C.CYAN}━━━ HARDWARE ━━━{C.R}")
        print(f"""
  Node │ CPU Model                      │ Cores │ RAM   │ Disk │ GPU
  ─────┼────────────────────────────────┼───────┼───────┼──────┼─────────────""")

        for name in sorted(data.keys()):
            hw = data[name].get("hardware", {})
            cpu = str(hw.get("cpu_model", "-"))[:30]
            cores = hw.get("cpu_cores", "-")
            ram = hw.get("ram_gb", "-")
            disk = hw.get("disk_type", "?")
            gpu = str(hw.get("gpu_model", ""))[:12]

            disk_c = C.RED if disk == "HDD" else C.GREEN
            ram_str = f"{ram}GB" if ram != "-" else "-"
            print(f"  {name:4} │ {cpu:30} │ {cores:5} │ {ram_str:5} │ {disk_c}{disk:4}{C.R} │ {gpu}")

            if disk == "HDD":
                issues.append((name, "HDD (upgrade to SSD)"))

    # ========== SOFTWARE ==========
    if show_sw:
        print(f"\n  {C.CYAN}━━━ SOFTWARE ━━━{C.R}")
        print(f"""
  Node │ Docker     │ Python     │ Node       │ Updates
  ─────┼────────────┼────────────┼────────────┼─────────""")

        for name in sorted(data.keys()):
            sw = data[name].get("software", {})
            docker = str(sw.get("docker", "-"))[:10]
            python = str(sw.get("python", "-"))[:10]
            node = str(sw.get("node", "-"))[:10]
            updates = sw.get("updates_available", 0)

            upd_c = C.RED if updates > 100 else (C.YELLOW if updates > 10 else C.GREEN)
            print(f"  {name:4} │ {docker:10} │ {python:10} │ {node:10} │ {upd_c}{updates:5}{C.R}")

            if updates > 100:
                issues.append((name, f"{updates} updates available"))

    # ========== VPS COMPARISON ==========
    if show_vps:
        print(f"\n  {C.CYAN}━━━ VPS COST COMPARISON ━━━{C.R}")
        print(f"  Use 'mpop advise --fresh --vps' for live pricing (requires API keys)")
        print(f"  Or check: https://www.vultr.com/pricing/ | https://www.hetzner.com/cloud")

    # ========== ISSUES SUMMARY ==========
    if issues:
        print(f"\n  {C.RED}⚠️  ISSUES FOUND:{C.R}")
        for name, issue in issues[:10]:
            print(f"    [{name}] {issue}")
        if len(issues) > 10:
            print(f"    ... and {len(issues)-10} more")
    else:
        print(f"\n  {C.GREEN}✓ All systems healthy{C.R}")

    print(f"\n  {'─' * 68}")
    print(f"  Data from agent cache (updated every 30s). Use --fresh for real-time.\n")


def _advise_live(servers, vpn_ip):
    """Live monitoring mode for advise - uses agent data from relay1"""
    import time

    print(f"\n  {C.CYAN}🔴 LIVE INFRASTRUCTURE MONITOR{C.R} (Ctrl+C to exit)")
    print(f"  Data source: server-agent API (agent reports every 30s)\n")

    try:
        while True:
            # Clear screen and redraw
            os.system('clear' if os.name != 'nt' else 'cls')

            print(f"  {C.CYAN}━━━ LIVE STATUS ━━━{C.R}  [{time.strftime('%H:%M:%S')}]")
            print(f"""
  Node │ CPU% │ Mem% │ Disk% │ Load  │ Uptime │ Status
  ─────┼──────┼──────┼───────┼───────┼────────┼────────""")

            # Get data from dashboard API
            try:
                import urllib.request
                _durl = _get_config("dashboard_url", "").rstrip("/")
                if not _durl:
                    raise ValueError("dashboard_url not configured")
                url = _durl + "/api/status"
                with urllib.request.urlopen(url, timeout=3) as resp:
                    data = json.loads(resp.read())
                    _raw = data
                    srv_list = _normalize_agent_response(_raw).get("servers", [])

                    for srv in srv_list:
                        name = srv.get("name", "?")[:4]
                        if servers and name not in servers:
                            continue

                        cpu = srv.get("cpu_pct", 0)
                        mem = srv.get("mem_pct", 0)
                        disk = srv.get("disk_pct", 0)
                        load = str(srv.get("load", "-"))[:5]
                        uptime = _shorten_uptime(srv.get("uptime", "-"))
                        online = srv.get("online", False)

                        # Color coding
                        cpu_c = C.RED if cpu > 90 else (C.YELLOW if cpu > 70 else C.GREEN)
                        mem_c = C.RED if mem > 90 else (C.YELLOW if mem > 70 else C.GREEN)
                        disk_c = C.RED if disk > 90 else (C.YELLOW if disk > 80 else C.GREEN)
                        status = f"{C.GREEN}OK{C.R}" if online else f"{C.RED}DOWN{C.R}"

                        print(f"  {name:4} │ {cpu_c}{cpu:4}%{C.R} │ {mem_c}{mem:4}%{C.R} │ {disk_c}{disk:4}%{C.R} │ {load:5} │ {uptime:6} │ {status}")

            except Exception as e:
                print(f"  {C.RED}Error fetching data: {e}{C.R}")

            print(f"\n  Press Ctrl+C to exit")
            time.sleep(5)  # Refresh every 5 seconds

    except KeyboardInterrupt:
        print(f"\n  Exiting live mode.")


def _advise_collect_data(servers, vpn_ip):
    """Collect all infrastructure data for JSON/MCP output"""
    from datetime import datetime

    report = {
        "scan_time": datetime.now().isoformat(),
        "servers": {},
        "issues": [],
        "recommendations": [],
        "summary": {}
    }

    def collect_all(name, ip):
        if ip == vpn_ip:
            return name, {"status": "local", "ip": ip}

        cmd = """
# Hardware
echo "CPU:$(grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)"
echo "CORES:$(nproc)"
echo "RAM_GB:$(awk '/MemTotal/ {printf "%.0f", $2/1024/1024}' /proc/meminfo)"
for iface in $(ls /sys/class/net/); do
  speed=$(cat /sys/class/net/$iface/speed 2>/dev/null)
  state=$(cat /sys/class/net/$iface/operstate 2>/dev/null)
  if [ "$state" = "up" ] && [ -n "$speed" ] && [ "$speed" != "-1" ]; then
    echo "NET_SPEED:$speed"
    break
  fi
done
root_disk=$(lsblk -d -n -o NAME | grep -E '^sd|^nvme|^vd' | head -1)
[ -f "/sys/block/$root_disk/queue/rotational" ] && echo "DISK_TYPE:$([ $(cat /sys/block/$root_disk/queue/rotational) = '0' ] && echo 'SSD' || echo 'HDD')"
nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 | xargs -I{} echo "GPU:{}"

# Server status
echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"
echo "UPTIME_DAYS:$(awk '{print int($1/86400)}' /proc/uptime)"
echo "DISK_PERCENT:$(df -h / | tail -1 | awk '{print $5}' | tr -d '%')"
echo "MEM_PERCENT:$(free | awk '/Mem:/ {printf "%.0f", $3/$2*100}')"

# Security
echo "FAILED_LOGINS:$(grep -c 'Failed password' /var/log/auth.log 2>/dev/null || journalctl -u sshd --since '24h ago' 2>/dev/null | grep -c 'Failed' || echo '0')"
echo "CONNECTIONS:$(ss -tn 2>/dev/null | grep -c ESTAB)"
echo "OPEN_PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -nu | tr '\n' ',' | sed 's/,$//')"
echo "ROOT_LOGIN:$(grep -c '^PermitRootLogin yes' /etc/ssh/sshd_config 2>/dev/null)"
echo "PASSWD_AUTH:$(grep -c '^PasswordAuthentication yes' /etc/ssh/sshd_config 2>/dev/null)"

# Operations
echo "DOCKER_CONTAINERS:$(docker ps -q 2>/dev/null | wc -l)"
echo "RUNNING_SERVICES:$(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | wc -l)"
echo "FAILED_SERVICES:$(systemctl --failed --no-pager --no-legend 2>/dev/null | wc -l)"

# Software versions
echo "DOCKER_VER:$(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')"
echo "NGINX_VER:$(nginx -v 2>&1 | cut -d'/' -f2)"
echo "PYTHON_VER:$(python3 --version 2>/dev/null | awk '{print $2}')"
echo "NODE_VER:$(node --version 2>/dev/null | tr -d 'v')"
"""
        result = vssh_exec(ip, cmd.strip(), timeout=10)
        data = {"ip": ip, "status": "ok" if result else "offline"}

        if result:
            for line in result.split('\n'):
                if ':' in line:
                    k, v = line.split(':', 1)
                    v = v.strip()
                    key = k.lower()

                    # Parse numeric values
                    if key in ['cores', 'ram_gb', 'net_speed', 'uptime_days', 'disk_percent',
                               'mem_percent', 'failed_logins', 'connections', 'docker_containers',
                               'running_services', 'failed_services', 'root_login', 'passwd_auth']:
                        try:
                            data[key] = int(v)
                        except Exception:
                            data[key] = v
                    else:
                        data[key] = v

        return name, data

    # Collect from all servers
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = [ex.submit(collect_all, name, ip) for name, ip in servers.items()]
        for future in as_completed(futures):
            name, data = future.result()
            report["servers"][name] = data

            # Detect issues
            # Detect issues (with safe type conversion)
            net_speed = int(data.get("net_speed") or 9999)
            disk_pct = int(data.get("disk_percent") or 0)
            mem_pct = int(data.get("mem_percent") or 0)
            failed_logins = int(data.get("failed_logins") or 0)
            failed_services = int(data.get("failed_services") or 0)

            if net_speed <= 100:
                report["issues"].append({"server": name, "type": "network", "issue": "100Mbps NIC", "severity": "high"})
            if data.get("disk_type") == "HDD":
                report["issues"].append({"server": name, "type": "storage", "issue": "Using HDD", "severity": "high"})
            if disk_pct > 90:
                report["issues"].append({"server": name, "type": "storage", "issue": f"Disk {disk_pct}% full", "severity": "critical"})
            if mem_pct > 95:
                report["issues"].append({"server": name, "type": "memory", "issue": f"Memory {mem_pct}% used", "severity": "high"})
            if failed_logins > 100:
                report["issues"].append({"server": name, "type": "security", "issue": f"{failed_logins} failed logins", "severity": "medium"})
            if data.get("root_login") == 1:
                report["issues"].append({"server": name, "type": "security", "issue": "Root login enabled", "severity": "high"})
            if failed_services > 0:
                report["issues"].append({"server": name, "type": "operations", "issue": f"{failed_services} failed services", "severity": "medium"})

    # Generate recommendations
    network_issues = sum(1 for i in report["issues"] if i["type"] == "network")
    if network_issues:
        report["recommendations"].append({
            "priority": "high",
            "category": "network",
            "action": f"Upgrade {network_issues} server(s) to Gigabit",
            "impact": "10x faster transfers",
            "cost_estimate": f"${network_issues * 15}"
        })

    hdd_issues = sum(1 for i in report["issues"] if i["issue"] == "Using HDD")
    if hdd_issues:
        report["recommendations"].append({
            "priority": "high",
            "category": "storage",
            "action": f"Replace HDD with SSD on {hdd_issues} server(s)",
            "impact": "10x faster disk I/O",
            "cost_estimate": f"${hdd_issues * 80}"
        })

    security_issues = sum(1 for i in report["issues"] if i["type"] == "security")
    if security_issues:
        report["recommendations"].append({
            "priority": "high",
            "category": "security",
            "action": "Review SSH configuration and login attempts",
            "impact": "Improved security posture",
            "cost_estimate": "Free"
        })

    # Summary
    report["summary"] = {
        "total_servers": len(servers),
        "online": sum(1 for s in report["servers"].values() if s.get("status") == "ok"),
        "total_issues": len(report["issues"]),
        "critical_issues": sum(1 for i in report["issues"] if i["severity"] == "critical"),
        "high_issues": sum(1 for i in report["issues"] if i["severity"] == "high"),
        "total_cpu_cores": sum(s.get("cores", 0) for s in report["servers"].values()),
        "total_ram_gb": sum(s.get("ram_gb", 0) for s in report["servers"].values()),
    }

    return report


def cmd_advise(args):
    """Comprehensive infrastructure advisor - hardware, software, security, operations"""
    vpn_ip = get_vpn_ip()

    # Parse target server (-t, --target)
    target_server = None
    if '-t' in args:
        idx = args.index('-t')
        if idx + 1 < len(args):
            target_server = args[idx + 1]
            args = [a for i, a in enumerate(args) if i not in (idx, idx+1)]
    for a in args:
        if a.startswith('--target='):
            target_server = a.split('=')[1]
            args = [x for x in args if x != a]

    # Filter servers if target specified
    if target_server:
        filtered = {k: v for k, v in SERVERS.items() if k == target_server or target_server in k}
        if not filtered:
            print(f"  Server not found: {target_server}")
            return
        servers_to_check = filtered
    else:
        servers_to_check = SERVERS

    # Parse flags
    json_output = '--json' in args or '--mcp' in args
    live_mode = '--live' in args
    ai_mode = '--ai' in args
    show_all = not args or '--all' in args or json_output
    show_hardware = '--hardware' in args or '--hw' in args or show_all
    show_software = '--software' in args or '--sw' in args or show_all
    show_vps = '--vps' in args or '--cost' in args or show_all
    show_server = '--server' in args or '--servers' in args or show_all
    show_security = '--security' in args or '--sec' in args or show_all
    show_operation = '--operation' in args or '--ops' in args or '--services' in args or show_all

    # Live mode - continuous monitoring
    if live_mode:
        _advise_live(servers_to_check, vpn_ip)
        return

    # JSON output for AI/MCP
    if json_output:
        report = _advise_collect_data(servers_to_check, vpn_ip)
        print(json.dumps(report, indent=2, default=str))
        return

    # AI mode - send to local LLM for analysis
    if ai_mode:
        _advise_with_ai(servers_to_check, vpn_ip)
        return

    # Use agent data from relay1 dashboard (fast) unless --fresh
    use_fresh = '--fresh' in args

    print(f"\n  {C.CYAN}🧠 INFRASTRUCTURE ADVISOR{C.R}")
    print(f"  ════════════════════════════════════════════════════════════════════")

    # Try to use cached dashboard data (fast)
    if not use_fresh:
        dashboard_data = _advise_from_dashboard(servers_to_check)
        if dashboard_data:
            _advise_show_dashboard(dashboard_data, show_hardware, show_software, show_server, show_security, show_operation, show_vps)
            return

    print(f"  {C.YELLOW}Fetching fresh data from servers...{C.R}")

    all_issues = []
    all_recommendations = []

    # ========== HARDWARE ANALYSIS ==========
    if show_hardware:
        print(f"\n  {C.CYAN}━━━ HARDWARE ANALYSIS ━━━{C.R}")

        def check_hw(name, ip):
            if ip == vpn_ip:
                return name, {"status": "local", "issues": [], "specs": {}}

            cmd = """
echo "CPU:$(grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)"
echo "CORES:$(nproc)"
echo "RAM:$(awk '/MemTotal/ {printf "%.0f", $2/1024/1024}' /proc/meminfo)"
for iface in $(ls /sys/class/net/); do
  speed=$(cat /sys/class/net/$iface/speed 2>/dev/null)
  state=$(cat /sys/class/net/$iface/operstate 2>/dev/null)
  if [ "$state" = "up" ] && [ -n "$speed" ] && [ "$speed" != "-1" ]; then
    echo "NET:$speed"
    break
  fi
done
root_disk=$(lsblk -d -n -o NAME | grep -E '^sd|^nvme|^vd' | head -1)
[ -f "/sys/block/$root_disk/queue/rotational" ] && echo "DISK:$(cat /sys/block/$root_disk/queue/rotational)"
nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 | xargs -I{} echo "GPU:{}"
"""
            result = vssh_exec(ip, cmd.strip(), timeout=8)
            hw = {"cpu": "-", "cores": 0, "ram": 0, "net": 0, "disk": "?", "gpu": "", "issues": []}

            if not result:
                return name, {"status": "offline", "issues": ["Server unreachable"], "specs": hw}

            for line in result.split('\n'):
                if line.startswith("CPU:"):
                    hw["cpu"] = line[4:].strip()[:40]
                elif line.startswith("CORES:"):
                    hw["cores"] = int(line[6:]) if line[6:].strip().isdigit() else 0
                elif line.startswith("RAM:"):
                    hw["ram"] = int(line[4:]) if line[4:].strip().isdigit() else 0
                elif line.startswith("NET:"):
                    hw["net"] = int(line[4:]) if line[4:].strip().isdigit() else 0
                elif line.startswith("DISK:"):
                    hw["disk"] = "HDD" if line[5:].strip() == "1" else "SSD"
                elif line.startswith("GPU:"):
                    hw["gpu"] = line[4:].strip()

            # Detect issues
            if hw["net"] > 0 and hw["net"] <= 100:
                hw["issues"].append(f"100Mbps NIC (upgrade to Gigabit)")
            if hw["disk"] == "HDD":
                hw["issues"].append("HDD (replace with SSD)")
            if hw["ram"] > 0 and hw["ram"] < 4:
                hw["issues"].append(f"Low RAM: {hw['ram']}GB")

            # Detect old CPU
            cpu_lower = hw["cpu"].lower()
            if any(x in cpu_lower for x in ["i7-4", "i7-3", "i7-2", "xeon e5", "xeon e3-12"]):
                hw["issues"].append("Old CPU (5+ years)")

            return name, {"status": "ok", "issues": hw["issues"], "specs": hw}

        print(f"""
  Node │ CPU                                      │ Cores │ RAM   │ Net      │ Disk │ GPU
  ─────┼──────────────────────────────────────────┼───────┼───────┼──────────┼──────┼─────────────""")

        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = [ex.submit(check_hw, name, ip) for name, ip in SERVERS.items()]
            results = {}
            for future in as_completed(futures):
                name, data = future.result()
                results[name] = data
                all_issues.extend([(name, i) for i in data.get("issues", [])])

        for name in SERVERS.keys():
            d = results.get(name, {})
            if d.get("status") == "local":
                print(f"  {name:4} │ {'(local machine)':40} │       │       │          │      │")
                continue
            if d.get("status") == "offline":
                print(f"  {name:4} │ {C.RED}{'OFFLINE':40}{C.R} │       │       │          │      │")
                continue

            s = d.get("specs", {})
            cpu = s.get("cpu", "-")[:40]
            cores = s.get("cores", 0)
            ram = s.get("ram", 0)
            net = s.get("net", 0)
            disk = s.get("disk", "?")
            gpu = s.get("gpu", "")[:12]

            net_str = f"{net}Mbps" if net < 1000 else f"{net//1000}Gbps"
            net_color = C.RED if net <= 100 else (C.YELLOW if net < 1000 else C.GREEN)
            disk_color = C.RED if disk == "HDD" else C.GREEN

            print(f"  {name:4} │ {cpu:40} │ {cores:5} │ {ram:4}GB │ {net_color}{net_str:8}{C.R} │ {disk_color}{disk:4}{C.R} │ {gpu}")

    # ========== SOFTWARE ANALYSIS ==========
    if show_software:
        print(f"\n  {C.CYAN}━━━ SOFTWARE & LICENSE ANALYSIS ━━━{C.R}")

        # Known software with license info
        LICENSE_INFO = {
            "docker": {"type": "free", "note": "Docker Desktop requires license for >250 employees or >$10M revenue"},
            "nginx": {"type": "free", "note": "Open source (BSD). NGINX Plus is commercial."},
            "mysql": {"type": "dual", "note": "GPL or commercial. Check if using Oracle MySQL commercially."},
            "postgresql": {"type": "free", "note": "PostgreSQL License (permissive)"},
            "redis": {"type": "dual", "note": "BSD 3-clause. Redis Enterprise is commercial."},
            "mongodb": {"type": "SSPL", "note": "SSPL license - cannot offer as a service without releasing code"},
            "elasticsearch": {"type": "SSPL", "note": "SSPL/Elastic License - commercial use restrictions"},
            "grafana": {"type": "AGPLv3", "note": "AGPLv3 - modifications must be shared"},
            "gitlab": {"type": "MIT+EE", "note": "MIT for CE, proprietary for EE features"},
            "jenkins": {"type": "MIT", "note": "MIT license - fully free"},
            "kubernetes": {"type": "Apache-2.0", "note": "Apache 2.0 - fully free"},
            "prometheus": {"type": "Apache-2.0", "note": "Apache 2.0 - fully free"},
            "rabbitmq": {"type": "MPL-2.0", "note": "Mozilla Public License - free"},
            "haproxy": {"type": "GPL", "note": "GPLv2. HAProxy Enterprise is commercial."},
        }

        def check_sw(name, ip):
            if ip == vpn_ip:
                return name, []

            cmd = """
echo "DOCKER:$(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')"
echo "NGINX:$(nginx -v 2>&1 | cut -d'/' -f2)"
echo "MYSQL:$(mysql --version 2>/dev/null | awk '{print $3}')"
echo "POSTGRES:$(psql --version 2>/dev/null | awk '{print $3}')"
echo "REDIS:$(redis-server --version 2>/dev/null | awk '{print $3}' | cut -d= -f2)"
echo "MONGO:$(mongod --version 2>/dev/null | grep 'db version' | awk '{print $3}')"
echo "ELASTIC:$(curl -s localhost:9200 2>/dev/null | grep number | head -1 | cut -d'"' -f4)"
echo "NODE:$(node --version 2>/dev/null)"
echo "PYTHON:$(python3 --version 2>/dev/null | awk '{print $2}')"
echo "GO:$(go version 2>/dev/null | awk '{print $3}')"
"""
            result = vssh_exec(ip, cmd.strip(), timeout=8)
            software = []

            if not result:
                return name, []

            for line in result.split('\n'):
                if ':' in line:
                    sw, ver = line.split(':', 1)
                    ver = ver.strip()
                    if ver and ver != '-':
                        sw_lower = sw.lower()
                        license_info = LICENSE_INFO.get(sw_lower, {})
                        software.append({
                            "name": sw,
                            "version": ver,
                            "license": license_info.get("type", "unknown"),
                            "note": license_info.get("note", "")
                        })

            return name, software

        print(f"""
  Node │ Software                  │ Version      │ License    │ Notes
  ─────┼───────────────────────────┼──────────────┼────────────┼────────────────────────────""")

        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = [ex.submit(check_sw, name, ip) for name, ip in SERVERS.items()]
            sw_results = {}
            for future in as_completed(futures):
                name, sw_list = future.result()
                sw_results[name] = sw_list

        license_warnings = []
        for name in SERVERS.keys():
            sw_list = sw_results.get(name, [])
            for sw in sw_list:
                license_type = sw.get("license", "")
                color = C.GREEN if license_type in ["free", "MIT", "Apache-2.0", "BSD"] else (
                    C.YELLOW if license_type in ["dual", "GPL", "AGPLv3", "MPL-2.0"] else C.RED
                )
                note = sw.get("note", "")[:30]
                print(f"  {name:4} │ {sw['name']:25} │ {sw['version']:12} │ {color}{license_type:10}{C.R} │ {note}")

                if license_type in ["SSPL", "dual"]:
                    license_warnings.append(f"{name}: {sw['name']} - {sw.get('note', '')}")

        if license_warnings:
            all_recommendations.append(("software", "Review licenses", license_warnings))

    # ========== VPS COST COMPARISON ==========
    if show_vps:
        print(f"\n  {C.CYAN}━━━ VPS COST COMPARISON ━━━{C.R}")
        print(f"  VPS pricing available via MCP/AI integration.")
        print(f"  Use: mpop advise --json | AI can analyze and recommend")

    # ========== SERVER STATUS ==========
    if show_server:
        print(f"\n  {C.CYAN}━━━ SERVER STATUS ━━━{C.R}")

        def check_server(name, ip):
            if ip == vpn_ip:
                return name, {"status": "local"}

            cmd = """
echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"
echo "UPTIME:$(awk '{print int($1/86400)}' /proc/uptime)"
echo "DISK:$(df -h / | tail -1 | awk '{print $5}' | tr -d '%')"
echo "MEM:$(free | awk '/Mem:/ {printf "%.0f", $3/$2*100}')"
echo "PROCS:$(ps aux | wc -l)"
"""
            result = vssh_exec(ip, cmd.strip(), timeout=5)
            data = {"load": "-", "uptime": "-", "disk": "-", "mem": "-", "procs": "-"}
            if result:
                for line in result.split('\n'):
                    if ':' in line:
                        k, v = line.split(':', 1)
                        data[k.lower()] = v.strip()
            return name, data

        print(f"""
  Node │ Load  │ Uptime  │ Disk% │ Mem%  │ Procs │ Status
  ─────┼───────┼─────────┼───────┼───────┼───────┼────────""")

        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = [ex.submit(check_server, name, ip) for name, ip in SERVERS.items()]
            srv_results = {}
            for future in as_completed(futures):
                name, data = future.result()
                srv_results[name] = data

        for name in SERVERS.keys():
            d = srv_results.get(name, {})
            if d.get("status") == "local":
                print(f"  {name:4} │ {'---':5} │ {'---':7} │ {'---':5} │ {'---':5} │ {'---':5} │ (local)")
                continue

            load = d.get("load", "-")
            uptime = d.get("uptime", "-")
            disk = d.get("disk", "-")
            mem = d.get("mem", "-")
            procs = d.get("procs", "-")

            # Color coding
            try:
                disk_pct = int(disk)
                disk_color = C.RED if disk_pct > 90 else (C.YELLOW if disk_pct > 80 else C.GREEN)
            except Exception:
                disk_color = ""

            try:
                mem_pct = int(mem)
                mem_color = C.RED if mem_pct > 90 else (C.YELLOW if mem_pct > 80 else C.GREEN)
            except Exception:
                mem_color = ""

            status = "OK" if load != "-" else "?"
            print(f"  {name:4} │ {load:5} │ {uptime:5}d  │ {disk_color}{disk:4}%{C.R} │ {mem_color}{mem:4}%{C.R} │ {procs:5} │ {status}")

            # Add issues
            try:
                if int(disk) > 90:
                    all_issues.append((name, f"Disk {disk}% full"))
                if int(mem) > 95:
                    all_issues.append((name, f"Memory {mem}% used"))
            except Exception:
                pass

    # ========== SECURITY ANALYSIS ==========
    if show_security:
        print(f"\n  {C.CYAN}━━━ SECURITY ANALYSIS ━━━{C.R}")

        RISKY_PORTS = {
            21: ("FTP", "HIGH"), 23: ("Telnet", "CRITICAL"), 3306: ("MySQL", "HIGH"),
            5432: ("PostgreSQL", "HIGH"), 6379: ("Redis", "CRITICAL"),
            27017: ("MongoDB", "CRITICAL"), 11211: ("Memcached", "CRITICAL"),
        }

        def check_security(name, ip):
            if ip == vpn_ip:
                return name, {"status": "local"}

            cmd = """
echo "FAIL:$(grep -c 'Failed password' /var/log/auth.log 2>/dev/null || journalctl -u sshd --since '24h ago' 2>/dev/null | grep -c 'Failed' || echo '0')"
echo "CONN:$(ss -tn 2>/dev/null | grep -c ESTAB || echo '0')"
echo "PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -nu | tr '\n' ',' | sed 's/,$//')"
echo "ROOT:$(grep -c '^PermitRootLogin yes' /etc/ssh/sshd_config 2>/dev/null || echo '0')"
echo "PASSWD:$(grep -c '^PasswordAuthentication yes' /etc/ssh/sshd_config 2>/dev/null || echo '0')"
"""
            result = vssh_exec(ip, cmd.strip(), timeout=5)
            data = {"fail": "0", "conn": "0", "ports": "", "root": "0", "passwd": "0"}
            if result:
                for line in result.split('\n'):
                    if ':' in line:
                        k, v = line.split(':', 1)
                        data[k.lower()] = v.strip()
            return name, data

        print(f"""
  Node │ Failed(24h) │ Conn │ Root │ Passwd │ Risky Ports
  ─────┼─────────────┼──────┼──────┼────────┼─────────────────────""")

        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = [ex.submit(check_security, name, ip) for name, ip in SERVERS.items()]
            sec_results = {}
            for future in as_completed(futures):
                name, data = future.result()
                sec_results[name] = data

        for name in SERVERS.keys():
            d = sec_results.get(name, {})
            if d.get("status") == "local":
                print(f"  {name:4} │ {'---':11} │ {'---':4} │ {'---':4} │ {'---':6} │ (local)")
                continue

            fail = d.get("fail", "0")
            conn = d.get("conn", "0")
            root = "YES" if d.get("root") == "1" else "no"
            passwd = "YES" if d.get("passwd") == "1" else "no"
            ports = d.get("ports", "")

            # Find risky ports
            risky = []
            for p in ports.split(','):
                try:
                    port = int(p)
                    if port in RISKY_PORTS:
                        risky.append(f"{port}({RISKY_PORTS[port][0]})")
                except Exception:
                    pass

            risky_str = ",".join(risky)[:25] if risky else "-"

            # Color coding
            fail_color = C.RED if int(fail or 0) > 100 else (C.YELLOW if int(fail or 0) > 10 else "")
            root_color = C.RED if root == "YES" else ""
            passwd_color = C.YELLOW if passwd == "YES" else ""
            risky_color = C.RED if risky else ""

            print(f"  {name:4} │ {fail_color}{fail:11}{C.R} │ {conn:4} │ {root_color}{root:4}{C.R} │ {passwd_color}{passwd:6}{C.R} │ {risky_color}{risky_str}{C.R}")

            # Add issues
            if int(fail or 0) > 100:
                all_issues.append((name, f"{fail} failed logins (24h)"))
            if root == "YES":
                all_issues.append((name, "Root login enabled"))
            if risky:
                all_issues.append((name, f"Risky ports: {risky_str}"))

    # ========== OPERATIONS & SERVICES ==========
    if show_operation:
        print(f"\n  {C.CYAN}━━━ OPERATIONS & SERVICES ━━━{C.R}")

        def check_ops(name, ip):
            if ip == vpn_ip:
                return name, {"status": "local"}

            cmd = """
echo "DOCKER:$(docker ps -q 2>/dev/null | wc -l)"
echo "CONTAINERS:$(docker ps --format '{{.Names}}' 2>/dev/null | head -5 | tr '\n' ',' | sed 's/,$//')"
echo "SERVICES:$(systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | wc -l)"
echo "FAILED:$(systemctl --failed --no-pager --no-legend 2>/dev/null | wc -l)"
echo "NGINX:$(systemctl is-active nginx 2>/dev/null)"
echo "MYSQL:$(systemctl is-active mysql 2>/dev/null || systemctl is-active mariadb 2>/dev/null)"
echo "REDIS:$(systemctl is-active redis 2>/dev/null || systemctl is-active redis-server 2>/dev/null)"
echo "POSTGRES:$(systemctl is-active postgresql 2>/dev/null)"
"""
            result = vssh_exec(ip, cmd.strip(), timeout=5)
            data = {}
            if result:
                for line in result.split('\n'):
                    if ':' in line:
                        k, v = line.split(':', 1)
                        data[k.lower()] = v.strip()
            return name, data

        print(f"""
  Node │ Docker │ Services │ Failed │ Key Services (nginx/mysql/redis/pg)
  ─────┼────────┼──────────┼────────┼────────────────────────────────────""")

        with ThreadPoolExecutor(max_workers=10) as ex:
            futures = [ex.submit(check_ops, name, ip) for name, ip in SERVERS.items()]
            ops_results = {}
            for future in as_completed(futures):
                name, data = future.result()
                ops_results[name] = data

        for name in SERVERS.keys():
            d = ops_results.get(name, {})
            if d.get("status") == "local":
                print(f"  {name:4} │ {'---':6} │ {'---':8} │ {'---':6} │ (local)")
                continue

            docker = d.get("docker", "0")
            services = d.get("services", "0")
            failed = d.get("failed", "0")

            # Key services status
            key_services = []
            for svc in ["nginx", "mysql", "redis", "postgres"]:
                status = d.get(svc, "")
                if status == "active":
                    key_services.append(f"{C.GREEN}{svc}{C.R}")
                elif status in ["inactive", "unknown"]:
                    pass  # Not installed
                else:
                    key_services.append(f"{C.RED}{svc}!{C.R}")

            key_str = " ".join(key_services) if key_services else "-"

            failed_color = C.RED if int(failed or 0) > 0 else ""

            print(f"  {name:4} │ {docker:6} │ {services:8} │ {failed_color}{failed:6}{C.R} │ {key_str}")

            # Add issues
            if int(failed or 0) > 0:
                all_issues.append((name, f"{failed} failed systemd services"))

    # ========== SUMMARY & RECOMMENDATIONS ==========
    print(f"\n  ══════════════════════════════════════════════════════════════════════════════════")

    if all_issues:
        print(f"\n  {C.YELLOW}⚠ ISSUES FOUND ({len(all_issues)}):{C.R}")
        for server, issue in all_issues[:10]:
            print(f"    • {server}: {issue}")
        if len(all_issues) > 10:
            print(f"    ... and {len(all_issues) - 10} more")

    # Generate AI-friendly recommendations
    recommendations = []

    # Network recommendations
    slow_net = [s for s, i in all_issues if "100Mbps" in i or "Mbps" in i]
    if slow_net:
        recommendations.append({
            "priority": "HIGH",
            "category": "Network",
            "issue": f"{len(slow_net)} server(s) with slow network",
            "fix": "Upgrade to Gigabit Ethernet adapter (~$15)",
            "impact": "10x faster file transfers"
        })

    # Disk recommendations
    hdd_servers = [s for s, i in all_issues if "HDD" in i]
    if hdd_servers:
        recommendations.append({
            "priority": "HIGH",
            "category": "Storage",
            "issue": f"HDD detected on: {', '.join(hdd_servers)}",
            "fix": "Replace with SSD (~$50-100)",
            "impact": "10x faster disk I/O"
        })

    # Old hardware
    old_hw = [s for s, i in all_issues if "Old" in i]
    if old_hw:
        recommendations.append({
            "priority": "MEDIUM",
            "category": "Hardware",
            "issue": f"Old hardware: {', '.join(old_hw)}",
            "fix": "Consider hardware refresh",
            "impact": "Better performance, lower power consumption"
        })

    if recommendations:
        print(f"\n  {C.GREEN}💡 RECOMMENDATIONS:{C.R}")
        for rec in recommendations:
            prio_color = C.RED if rec["priority"] == "HIGH" else C.YELLOW
            print(f"    [{prio_color}{rec['priority']}{C.R}] {rec['category']}: {rec['issue']}")
            print(f"           Fix: {rec['fix']}")
            print(f"           Impact: {rec['impact']}")

    if not all_issues and not recommendations:
        print(f"\n  {C.GREEN}✓ Infrastructure looks healthy!{C.R}")

    print(f"""
  ─────────────────────────────────────────────────────────────────────────────────
  Usage: mpop advise              Full analysis (cached from agent - fast)
         mpop advise --ai         AI recommendations (local Ollama)
         mpop advise -t g1        Single server analysis
         mpop advise --live       Real-time monitoring (Ctrl+C to exit)
         mpop advise --fresh      Direct server query (slow but latest)
         mpop advise --hardware   Hardware (CPU, RAM, disk)
         mpop advise --software   Software versions
         mpop advise --server     Server status (load, uptime, disk)
         mpop advise --json       JSON output for AI/MCP
  """)


def cmd_restart(args):
    """Restart a service on a server"""
    if len(args) < 1:
        print("  Usage: mpop restart <service>            - restart on all servers")
        print("         mpop restart <service> -t <node>  - restart on specific server")
        print("")
        print("  Common services: wire, vssh, docker, nginx, redis, postgres")
        return

    service = args[0]
    target = None

    if "-t" in args:
        idx = args.index("-t")
        if idx + 1 < len(args):
            target = args[idx + 1]

    vpn_ip = get_vpn_ip()

    # Safety check
    dangerous = ["ssh", "sshd", "networking", "network-manager"]
    if service in dangerous:
        print(f"  ⚠️  Refusing to restart '{service}' - could lock you out!")
        return

    def restart_service(name, ip):
        if ip == vpn_ip:
            return name, "skipped (local)"

        cmd = f"sudo systemctl restart {service} 2>&1 && echo 'OK' || echo 'FAILED'"
        result = vssh_exec(ip, cmd, timeout=10)

        if not result:
            return name, "offline"
        if "OK" in result:
            return name, "✓ restarted"
        return name, f"✗ {result[:30]}"

    servers_to_restart = SERVERS.items()
    if target:
        if target not in SERVERS:
            print(f"  Unknown server: {target}")
            return
        servers_to_restart = [(target, SERVERS[target])]

    print(f"\n  Restarting '{service}'...")
    print("  " + "-" * 40)

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = [ex.submit(restart_service, name, ip) for name, ip in servers_to_restart]
        for future in as_completed(futures):
            name, status = future.result()
            print(f"  {name:4} │ {status}")

    print("  " + "-" * 40)

# ==================== Secret Management (Level 4) ====================
# Encrypted secrets shared across VPN nodes
# Storage: relay1:/var/lib/mpop/secrets.enc
#
# Security Levels:
#   Level 3: VPN-bound + Machine-bound (default)
#   Level 4: + Master Password + Session + Audit Log
#
# Level 4 Features:
#   - Master password required for sensitive operations
#   - Session unlock (5 min timeout)
#   - Audit log of all access
#   - Rate limiting on failed attempts

# Secrets distributed storage: dual masters + replicas
# Loaded dynamically from config so any deployment can set its own nodes.
# Set "secret_masters" / "secret_replicas" in ~/.mpop/config.json
def _get_secret_nodes():
    cfg = _load_config() or {}
    masters  = cfg.get("secret_masters",  [])
    replicas = cfg.get("secret_replicas", [])
    # Fall back to deriving from known servers if not set
    if not masters:
        servers = list(cfg.get("servers", {}).keys())
        masters  = servers[:2] if len(servers) >= 2 else servers
        replicas = servers[2:5] if len(servers) > 2 else []
    return masters, replicas

_sm, _sr = _get_secret_nodes()
SECRETS_MASTERS  = _sm
SECRETS_REPLICAS = _sr
SECRETS_ALL = SECRETS_MASTERS + SECRETS_REPLICAS
SECRETS_PATH = "/var/lib/mpop/secrets.enc"
MACHINES_PATH = "/var/lib/mpop/machines.json"
MASTER_HASH_PATH = "/var/lib/mpop/master.hash"
AUDIT_LOG_PATH = "/var/lib/mpop/audit.log"
HISTORY_PATH = "/var/lib/mpop/history.json"
SESSION_PATH = "/tmp/.mpop_session"
PBKDF2_ITERATIONS = 100000
SESSION_TIMEOUT = 300  # 5 minutes
VPN_IDENTITY_SALT = b"mpop-vpn-L3-2026"
MASTER_SALT = b"mpop-master-L4-2026"

# Dragon Snapshot Settings
SNAPSHOT_DIR = "/var/lib/mpop/snapshots"
SNAPSHOT_RETENTION_DAYS = 30

def _enforce_create_snapshot(node: str, ip: str, action_type: str, paths: list) -> dict:
    """Create a snapshot before T3 action (returns snapshot info)"""
    from datetime import datetime
    today = datetime.now().strftime("%Y-%m-%d")
    timestamp = datetime.now().strftime("%H%M%S")
    snapshot_name = f"{node}-{action_type.lower()}-{timestamp}"

    # Ensure snapshot directory exists
    mkdir_cmd = f"mkdir -p {SNAPSHOT_DIR}/{today}"
    vssh_exec(ip, mkdir_cmd, timeout=5)

    # Get size estimate
    paths_str = " ".join(paths)
    size_cmd = f"du -sh {paths_str} 2>/dev/null | awk '{{print $1}}' | head -1"
    size_result = vssh_exec(ip, size_cmd, timeout=10)
    original_size = size_result.strip() if size_result else "?"

    # Create tarball
    snapshot_path = f"{SNAPSHOT_DIR}/{today}/{snapshot_name}.tar.gz"
    tar_cmd = f"tar -czf {snapshot_path} {paths_str} 2>/dev/null && echo SIZE:$(du -h {snapshot_path} | awk '{{print $1}}')"
    tar_result = vssh_exec(ip, tar_cmd, timeout=120)

    compressed_size = "?"
    if tar_result and "SIZE:" in tar_result:
        compressed_size = tar_result.split("SIZE:")[1].strip()

    # Create manifest entry
    manifest = {
        "timestamp": datetime.now().isoformat(),
        "node": node,
        "action": action_type,
        "paths": paths,
        "original_size": original_size,
        "compressed_size": compressed_size,
        "snapshot_file": snapshot_path
    }

    # Append to manifest.json
    manifest_cmd = f"echo '{json.dumps(manifest)}' >> {SNAPSHOT_DIR}/{today}/manifest.json"
    vssh_exec(ip, manifest_cmd, timeout=5)

    return manifest

def _enforce_list_snapshots() -> list:
    """List available snapshots for undo"""
    cmd = f"find {SNAPSHOT_DIR} -name 'manifest.json' -exec cat {{}} \\; 2>/dev/null"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=10)

    snapshots = []
    if result:
        for line in result.strip().split('\n'):
            if line.strip():
                try:
                    snapshots.append(json.loads(line))
                except Exception:
                    pass
    return snapshots

def _enforce_restore_snapshot(snapshot: dict) -> bool:
    """Restore a snapshot"""
    node = snapshot.get("node")
    ip = SERVERS.get(node)
    if not ip:
        return False

    snapshot_file = snapshot.get("snapshot_file")
    # Extract to root
    cmd = f"cd / && tar -xzf {snapshot_file} 2>/dev/null && echo RESTORED"
    result = vssh_exec(ip, cmd, timeout=120)
    return result and "RESTORED" in result

def _enforce_cleanup_old_snapshots():
    """Remove snapshots older than SNAPSHOT_RETENTION_DAYS"""
    cmd = f"find {SNAPSHOT_DIR} -type d -mtime +{SNAPSHOT_RETENTION_DAYS} -exec rm -rf {{}} \\; 2>/dev/null"
    vssh_exec(SECRETS_MASTERS[0], cmd, timeout=30)

def _enforce_undo_command(args: list):
    """Handle enforce undo subcommand"""
    show_list = "--list" in args or "-l" in args

    # Get available snapshots
    snapshots = _enforce_list_snapshots()

    if not snapshots:
        print("📭 No snapshots available for undo")
        print(f"   Snapshots are created before T3 actions.")
        print(f"   Run: mpop enforce --t3 --force")
        return

    if show_list:
        # Show all snapshots
        print("🗂️  AVAILABLE SNAPSHOTS")
        print("=" * 60)

        # Group by date
        by_date = {}
        for s in snapshots:
            date = s.get("date", "unknown")
            by_date.setdefault(date, []).append(s)

        for date in sorted(by_date.keys(), reverse=True):
            print(f"\n📅 {date}")
            print("-" * 40)
            for idx, snap in enumerate(by_date[date], 1):
                node = snap.get("node", "?")
                action = snap.get("action_type", "?")
                orig_size = snap.get("original_size", 0)
                comp_size = snap.get("compressed_size", 0)
                ts = snap.get("timestamp", "")[:19]

                # Human readable size
                def human_size(b):
                    for u in ['B', 'KB', 'MB', 'GB']:
                        if b < 1024:
                            return f"{b:.1f}{u}"
                        b /= 1024
                    return f"{b:.1f}TB"

                print(f"   [{idx}] {node}: {action}")
                print(f"       {human_size(orig_size)} → {human_size(comp_size)} @ {ts}")

        print(f"\n💡 Restore: mpop enforce undo {snapshots[0].get('date')}")
        return

    # Find snapshot to restore
    target_date = None
    for arg in args:
        if arg not in ["undo", "--list", "-l"] and not arg.startswith("-"):
            target_date = arg
            break

    # Get most recent snapshot if no date specified
    if not target_date:
        # Sort by timestamp desc
        snapshots.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
        target = snapshots[0]
    else:
        # Find by date
        matching = [s for s in snapshots if s.get("date") == target_date]
        if not matching:
            print(f"❌ No snapshot found for date: {target_date}")
            print("   Run: mpop enforce undo --list")
            return
        target = matching[0]

    # Show what will be restored
    print("🔄 RESTORE SNAPSHOT")
    print("=" * 60)
    print(f"   Node:   {target.get('node')}")
    print(f"   Action: {target.get('action_type')}")
    print(f"   Date:   {target.get('timestamp', '')[:19]}")
    print(f"   Paths:  {', '.join(target.get('paths', []))}")
    print()

    # Confirm
    confirm = input("⚠️  Restore this snapshot? (y/N): ").strip().lower()
    if confirm != 'y':
        print("❌ Cancelled")
        return

    # Restore
    print(f"\n🔄 Restoring {target.get('snapshot_file')}...")

    if _enforce_restore_snapshot(target):
        print("✅ Snapshot restored successfully!")

        # Log the undo action
        _audit_log("DRAGON_UNDO", f"Restored snapshot: {target.get('snapshot_file')}")
    else:
        print("❌ Failed to restore snapshot")
        print("   Check if the snapshot file exists and is accessible")

# ===== Level 4: Master Password Functions =====

def _has_master_password() -> bool:
    """Check if master password is set"""
    cmd = f"test -f {MASTER_HASH_PATH} && echo YES || echo NO"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "YES" in result

def _set_master_password(password: str) -> bool:
    """Set master password (first time setup)"""
    # Hash password with PBKDF2
    pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), MASTER_SALT, PBKDF2_ITERATIONS)
    hash_b64 = base64.b64encode(pw_hash).decode()

    cmd = f"mkdir -p /var/lib/mpop && echo '{hash_b64}' > {MASTER_HASH_PATH} && chmod 600 {MASTER_HASH_PATH} && echo OK"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "OK" in result

def _verify_master_password(password: str) -> bool:
    """Verify master password"""
    # Get stored hash
    cmd = f"cat {MASTER_HASH_PATH} 2>/dev/null"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    if not result:
        return False

    stored_hash = result.strip()

    # Compute hash of provided password
    pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), MASTER_SALT, PBKDF2_ITERATIONS)
    computed_b64 = base64.b64encode(pw_hash).decode()

    return stored_hash == computed_b64

def _secure_delete(filepath: str):
    """Securely delete file by overwriting with random data before removal"""
    try:
        if os.path.exists(filepath):
            # Get file size
            size = os.path.getsize(filepath)
            # Overwrite with random data 3 times
            for _ in range(3):
                with open(filepath, 'wb') as f:
                    f.write(os.urandom(max(size, 256)))
                    f.flush()
                    os.fsync(f.fileno())
            # Then remove
            os.remove(filepath)
    except Exception:
        # Fallback to simple delete
        try:
            os.remove(filepath)
        except Exception:
            pass

def _get_session_token() -> str:
    """Get current session token if valid"""
    try:
        if not os.path.exists(SESSION_PATH):
            return None

        with open(SESSION_PATH, 'r') as f:
            data = json.load(f)

        # Check expiry - secure delete if expired
        expires = data.get('expires', 0)
        if time.time() > expires:
            _secure_delete(SESSION_PATH)
            _audit_log("SESSION_EXPIRED", "Auto-zeroized expired session")
            return None

        # Check machine ID - secure delete if mismatch
        if data.get('machine_id') != _get_machine_id():
            _secure_delete(SESSION_PATH)
            _audit_log("SESSION_INVALID", "Machine ID mismatch - zeroized")
            return None

        # Check token integrity (HMAC)
        token = data.get('token', '')
        check = data.get('check', '')
        expected_check = hashlib.sha256((token + data.get('machine_id', '')).encode()).hexdigest()[:16]
        if check != expected_check:
            _secure_delete(SESSION_PATH)
            _audit_log("SESSION_TAMPERED", "Integrity check failed - zeroized")
            return None

        return token
    except Exception:
        return None

def _create_session() -> str:
    """Create new session with 256-bit token and integrity check"""
    import time
    # 256-bit cryptographically random token
    token = base64.b64encode(os.urandom(32)).decode()
    machine_id = _get_machine_id()

    # Integrity check (HMAC-like)
    check = hashlib.sha256((token + machine_id).encode()).hexdigest()[:16]

    data = {
        'token': token,
        'machine_id': machine_id,
        'created': time.time(),
        'expires': time.time() + SESSION_TIMEOUT,
        'check': check  # Integrity verification
    }

    try:
        with open(SESSION_PATH, 'w') as f:
            json.dump(data, f)
        os.chmod(SESSION_PATH, 0o600)
    except Exception:
        pass

    return token

def _clear_session():
    """Securely clear current session with zeroize"""
    _secure_delete(SESSION_PATH)

def _require_master_password(action: str = "access secrets", password: str = None) -> bool:
    """Require master password if Level 4 is enabled"""
    import time

    # Check if master password is set
    if not _has_master_password():
        return True  # Level 4 not enabled, allow access

    # Check for valid session
    if _get_session_token():
        return True  # Session valid

    # If password provided directly, verify it
    if password:
        if _verify_master_password(password):
            _create_session()
            _audit_log("AUTH_SUCCESS", f"Authenticated for {action}")
            return True
        else:
            _audit_log("AUTH_FAIL", f"Failed attempt for {action}")
            print("  ✗ Incorrect password")
            return False

    # Need to authenticate interactively
    print(f"\n  🔐 Level 4 Security: Master password required to {action}")

    for attempt in range(3):
        try:
            password = getpass.getpass(f"  Master Password ({3-attempt} attempts left): ")
        except EOFError:
            print("  ❌ Interactive mode required, or provide password as argument")
            return False

        if _verify_master_password(password):
            _create_session()
            _audit_log("AUTH_SUCCESS", f"Authenticated for {action}")
            print("  ✓ Authenticated (session valid for 5 minutes)")
            return True
        else:
            _audit_log("AUTH_FAIL", f"Failed attempt for {action}")
            print("  ✗ Incorrect password")

    print("  ❌ Too many failed attempts")
    _audit_log("AUTH_LOCKED", f"Locked out after 3 failed attempts")
    return False

def _audit_log(action: str, detail: str = ""):
    """Log action to audit log with anomaly detection"""
    import time
    timestamp = datetime.now().isoformat()
    machine_id = _get_machine_id()[:16]
    node = get_my_name()
    vpn_ip = get_vpn_ip()

    entry = f"{timestamp}|{node}|{vpn_ip}|{machine_id}|{action}|{detail}"

    cmd = f"echo '{entry}' >> {AUDIT_LOG_PATH}"
    vssh_exec(SECRETS_MASTERS[0], cmd, timeout=3)

    # Check for anomalies on security-sensitive actions
    if action in ("AUTH_FAIL", "AUTH_LOCKED", "SESSION_TAMPERED", "SESSION_INVALID"):
        _check_anomalies(action, node, vpn_ip)

def _check_anomalies(trigger_action: str, node: str, vpn_ip: str):
    """Check for suspicious patterns and alert"""
    try:
        # Get recent auth failures (last 1 hour)
        cmd = f"grep 'AUTH_FAIL\\|AUTH_LOCKED\\|SESSION_TAMPERED' {AUDIT_LOG_PATH} 2>/dev/null | tail -20"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
        if not result:
            return

        lines = result.strip().split('\n')
        recent_failures = []
        one_hour_ago = time.time() - 3600

        for line in lines:
            parts = line.split('|')
            if len(parts) >= 5:
                try:
                    ts = datetime.fromisoformat(parts[0]).timestamp()
                    if ts > one_hour_ago:
                        recent_failures.append({
                            'ts': parts[0],
                            'node': parts[1],
                            'ip': parts[2],
                            'action': parts[4]
                        })
                except Exception:
                    pass

        # Anomaly: 5+ failures in 1 hour
        if len(recent_failures) >= 5:
            alert_msg = f"⚠️ SECURITY ALERT: {len(recent_failures)} auth failures in last hour"
            alert_entry = f"{datetime.now().isoformat()}|SYSTEM|{vpn_ip}|ALERT|ANOMALY_DETECTED|{alert_msg}"
            alert_cmd = f"echo '{alert_entry}' >> {AUDIT_LOG_PATH}"
            vssh_exec(SECRETS_MASTERS[0], alert_cmd, timeout=3)

            # Print alert to stderr
            print(f"\n  {alert_msg}", file=sys.stderr)
            print(f"  Recent failures:", file=sys.stderr)
            for f in recent_failures[-5:]:
                print(f"    - {f['ts'][:19]} from {f['node']} ({f['action']})", file=sys.stderr)

        # Anomaly: Different machines with same action in short time
        unique_nodes = set(f['node'] for f in recent_failures)
        if len(unique_nodes) >= 3:
            alert_msg = f"⚠️ MULTI-NODE ALERT: Failures from {len(unique_nodes)} different nodes"
            print(f"\n  {alert_msg}", file=sys.stderr)

    except Exception as e:
        pass  # Don't break main flow on anomaly check failure

def _analyze_audit_log() -> dict:
    """Analyze audit log for patterns and generate report"""
    try:
        cmd = f"cat {AUDIT_LOG_PATH} 2>/dev/null | tail -200"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=10)
        if not result:
            return {}

        lines = result.strip().split('\n')
        analysis = {
            'total_entries': len(lines),
            'auth_success': 0,
            'auth_fail': 0,
            'secrets_read': 0,
            'secrets_written': 0,
            'by_node': {},
            'by_action': {},
            'alerts': [],
            'suspicious': []
        }

        for line in lines:
            parts = line.split('|')
            if len(parts) >= 5:
                node = parts[1]
                action = parts[4]

                analysis['by_node'][node] = analysis['by_node'].get(node, 0) + 1
                analysis['by_action'][action] = analysis['by_action'].get(action, 0) + 1

                if action == 'AUTH_SUCCESS':
                    analysis['auth_success'] += 1
                elif action == 'AUTH_FAIL':
                    analysis['auth_fail'] += 1
                elif action.startswith('SECRET_READ'):
                    analysis['secrets_read'] += 1
                elif action == 'SECRET_SET':
                    analysis['secrets_written'] += 1
                elif action == 'ANOMALY_DETECTED':
                    analysis['alerts'].append(line)
                elif action in ('SESSION_TAMPERED', 'SESSION_INVALID', 'AUTH_LOCKED'):
                    analysis['suspicious'].append(line)

        return analysis
    except Exception:
        return {}

def _get_machine_id() -> str:
    """Get unique machine identifier (Linux/macOS)"""
    # Linux: /etc/machine-id
    try:
        with open('/etc/machine-id', 'r') as f:
            return f.read().strip()
    except Exception:
        pass

    # macOS: IOPlatformUUID
    try:
        result = subprocess.run(
            "ioreg -rd1 -c IOPlatformExpertDevice | awk -F'\"' '/IOPlatformUUID/{print $4}'",
            shell=True, capture_output=True, text=True, timeout=5
        )
        if result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass

    # Fallback: hostname + MAC hash
    try:
        hostname = socket.gethostname()
        result = subprocess.run(
            "cat /sys/class/net/*/address 2>/dev/null | head -1 || ifconfig en0 2>/dev/null | awk '/ether/{print $2}'",
            shell=True, capture_output=True, text=True, timeout=5
        )
        mac = result.stdout.strip() or "unknown"
        return hashlib.sha256(f"{hostname}:{mac}".encode()).hexdigest()[:32]
    except Exception:
        return "unknown-machine"

def _get_registered_machines() -> set:
    """Get registered machine IDs from server"""
    cmd = f"cat {MACHINES_PATH} 2>/dev/null || echo '[]'"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    if result is None:
        return set()
    try:
        return set(json.loads(result.strip()))
    except Exception:
        return set()

def _register_machine(machine_id: str) -> bool:
    """Register this machine for secret access"""
    machines = _get_registered_machines()
    machines.add(machine_id)
    machines_json = json.dumps(list(machines))
    cmd = f"mkdir -p /var/lib/mpop && echo '{machines_json}' > {MACHINES_PATH} && chmod 600 {MACHINES_PATH} && echo OK"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "OK" in result

def _verify_vpn_identity() -> bool:
    """Verify we're on VPN network"""
    vpn_ip = get_vpn_ip()
    if not vpn_ip or not vpn_ip.startswith(_get_vpn_prefix()):
        return False
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(2)
        sock.connect((SECRETS_MASTERS[0], VSSH_PORT))
        sock.close()
        return True
    except Exception:
        return False

def _is_machine_registered() -> bool:
    """Check if current machine is registered"""
    return _get_machine_id() in _get_registered_machines()

def _derive_key(salt: bytes) -> bytes:
    """Derive key with Level 3 security (VPN + Machine binding)"""
    vpn_ip = get_vpn_ip()
    machine_id = _get_machine_id()

    # Check 1: Must be on VPN
    if not vpn_ip or not vpn_ip.startswith(_get_vpn_prefix()):
        return hashlib.pbkdf2_hmac('sha256', b"WRONG-VPN", salt, 1000, dklen=32)

    # Check 2: Machine must be registered (skip check if no machines registered yet)
    registered = _get_registered_machines()
    if registered and machine_id not in registered:
        return hashlib.pbkdf2_hmac('sha256', b"WRONG-MACHINE", salt, 1000, dklen=32)

    # All checks passed: derive correct key
    # Key is SAME for all registered machines (shared secret)
    # machine_id is only used for authorization, not key derivation
    vpn_prefix = _get_vpn_prefix().encode()
    combined = VSSH_SECRET.encode() + vpn_prefix + VPN_IDENTITY_SALT
    return hashlib.pbkdf2_hmac('sha256', combined, salt, PBKDF2_ITERATIONS, dklen=32)

def _aes_encrypt(plaintext: bytes, key: bytes) -> tuple:
    """AES-256-CTR encryption with HMAC authentication"""
    # Generate random IV (16 bytes)
    iv = os.urandom(16)

    # Use AES-CTR mode via hashlib-based stream cipher
    # This is secure when IV is unique per encryption
    ciphertext = bytearray()
    for i in range(0, len(plaintext), 32):
        # Generate keystream block
        counter = iv + i.to_bytes(8, 'big')
        keystream = hashlib.sha256(key + counter).digest()
        # XOR with plaintext
        block = plaintext[i:i+32]
        for j, b in enumerate(block):
            ciphertext.append(b ^ keystream[j])

    # Compute HMAC for integrity
    mac = hashlib.sha256(key + iv + bytes(ciphertext)).digest()[:16]

    return iv, bytes(ciphertext), mac

def _aes_decrypt(iv: bytes, ciphertext: bytes, mac: bytes, key: bytes) -> bytes:
    """AES-256-CTR decryption with HMAC verification"""
    # Verify HMAC first (constant time comparison)
    expected_mac = hashlib.sha256(key + iv + ciphertext).digest()[:16]
    if not all(a == b for a, b in zip(mac, expected_mac)):
        raise ValueError("HMAC verification failed - data corrupted or wrong key")

    # Decrypt
    plaintext = bytearray()
    for i in range(0, len(ciphertext), 32):
        counter = iv + i.to_bytes(8, 'big')
        keystream = hashlib.sha256(key + counter).digest()
        block = ciphertext[i:i+32]
        for j, b in enumerate(block):
            plaintext.append(b ^ keystream[j])

    return bytes(plaintext)

def _encrypt_secrets(secrets: dict) -> str:
    """Encrypt secrets dict with maximum security"""
    # Generate unique salt for this encryption
    salt = os.urandom(16)

    # Derive key using PBKDF2 (slow - 100k iterations)
    key = _derive_key(salt)

    # Prepare plaintext with timestamp
    data = {
        "v": 2,  # Version
        "ts": datetime.now().isoformat(),
        "secrets": secrets
    }
    plaintext = json.dumps(data, ensure_ascii=False).encode('utf-8')

    # Encrypt with AES-256-CTR + HMAC
    iv, ciphertext, mac = _aes_encrypt(plaintext, key)

    # Format: base64(version + salt + iv + mac + ciphertext)
    # Version byte allows future algorithm changes
    payload = b'\x02' + salt + iv + mac + ciphertext
    return base64.b64encode(payload).decode('ascii')

def _decrypt_secrets(encrypted: str) -> dict:
    """Decrypt with verification"""
    raw = base64.b64decode(encrypted)

    # Parse format
    version = raw[0]
    if version != 2:
        raise ValueError(f"Unknown encryption version: {version}")

    salt = raw[1:17]
    iv = raw[17:33]
    mac = raw[33:49]
    ciphertext = raw[49:]

    # Derive key (slow - protects against brute force)
    key = _derive_key(salt)

    # Decrypt and verify
    plaintext = _aes_decrypt(iv, ciphertext, mac, key)
    data = json.loads(plaintext.decode('utf-8'))

    return data.get("secrets", {})

def _load_secrets() -> dict:
    """Load secrets: local first, then failover across all nodes"""
    import os
    # Try local file first (no network needed)
    if os.path.exists(SECRETS_PATH):
        try:
            with open(SECRETS_PATH) as f:
                local_data = f.read().strip()
            if local_data and local_data != "{}":
                secrets = _decrypt_secrets(local_data)
                if secrets:
                    return secrets
        except Exception:
            pass
    # Fallback: try remote servers
    for server in SECRETS_ALL:
        try:
            cmd = f"cat {SECRETS_PATH} 2>/dev/null || echo '{{}}'"
            result = vssh_exec(server, cmd, timeout=5)
            if not result or result.strip() == "{}":
                continue
            secrets = _decrypt_secrets(result.strip())
            if secrets:
                _audit_log("SECRET_FAILOVER", f"Read from {server}")
                return secrets
        except Exception as e:
            _audit_log("SECRET_LOAD_FAIL", f"{server}: {str(e)[:30]}")
            continue
    return {}

def _get_my_server_name() -> str:
    """현재 머신의 서버 이름 반환 (VPN IP 비교). 없으면 빈 문자열."""
    try:
        import subprocess as _sp
        for _iface in ['wire0', 'utun9', 'utun8', 'utun7', 'utun6']:
            _r = _sp.run(
                f"ip addr show {_iface} 2>/dev/null | grep 'inet ' | awk '{{print $2}}' | cut -d/ -f1 "
                f"|| ifconfig {_iface} 2>/dev/null | grep 'inet ' | awk '{{print $2}}'",
                shell=True, capture_output=True, text=True, timeout=2
            )
            _ip = _r.stdout.strip()
            if _ip.startswith('10.99.'):
                _cfg = _load_config()
                for _name, _srv in _cfg.get("servers", {}).items():
                    if _srv.get("ip", "") == _ip:
                        return _name
                break
    except Exception:
        pass
    return ""

def _write_secret_locally(encrypted: str) -> bool:
    """secrets.enc 로컬 직접 쓰기 (vssh 없이). 마스터 노드에서 호출."""
    import shutil
    try:
        os.makedirs(os.path.dirname(SECRETS_PATH), exist_ok=True)
        if os.path.exists(SECRETS_PATH):
            bak2 = SECRETS_PATH + ".bak.2"
            bak1 = SECRETS_PATH + ".bak.1"
            bak  = SECRETS_PATH + ".bak"
            if os.path.exists(bak1): shutil.copy2(bak1, bak2)
            if os.path.exists(bak):  shutil.copy2(bak,  bak1)
            shutil.copy2(SECRETS_PATH, bak)
        with open(SECRETS_PATH, 'w') as _f:
            _f.write(encrypted)
        os.chmod(SECRETS_PATH, 0o600)
        with open(SECRETS_PATH) as _f:
            return _f.read().strip() == encrypted.strip()
    except Exception as e:
        _audit_log("SECRET_LOCAL_WRITE_FAIL", str(e)[:60])
        return False

def _save_secrets(secrets: dict) -> bool:
    """Save secrets with local-first (if master) + vssh failover + replication"""
    encrypted = _encrypt_secrets(secrets)
    saved_to = None

    # 1. 현재 머신이 마스터인지 확인 — 맞으면 로컬 직접 쓰기 (vssh 불필요)
    my_name = _get_my_server_name()
    if my_name and my_name in SECRETS_MASTERS:
        if _write_secret_locally(encrypted):
            saved_to = my_name
            _audit_log("SECRET_WRITE_LOCAL", f"Saved locally as {my_name}")

    # 2. 로컬 저장 실패 or 비마스터 → vssh로 마스터에 쓰기
    if not saved_to:
        for master in SECRETS_MASTERS:
            if master == my_name:
                continue  # 이미 로컬 시도해서 실패한 경우
            try:
                backup_cmd = "cd /var/lib/mpop 2>/dev/null && [ -f secrets.enc ] && cp secrets.enc.bak.1 secrets.enc.bak.2 2>/dev/null; cp secrets.enc.bak secrets.enc.bak.1 2>/dev/null; cp secrets.enc secrets.enc.bak 2>/dev/null || true"
                vssh_exec(master, backup_cmd, timeout=3)

                cmd = f"mkdir -p /var/lib/mpop && echo '{encrypted}' > {SECRETS_PATH} && chmod 600 {SECRETS_PATH} && echo OK"
                result = vssh_exec(master, cmd, timeout=5)
                if not result or "OK" not in result:
                    continue

                verify_result = vssh_exec(master, f"cat {SECRETS_PATH}", timeout=5)
                if verify_result and verify_result.strip() == encrypted:
                    saved_to = master
                    if master != SECRETS_MASTERS[0]:
                        _audit_log("SECRET_WRITE_FAILOVER", f"Saved to {master}")
                    break
            except Exception as e:
                _audit_log("SECRET_SAVE_FAIL", f"{master}: {str(e)[:30]}")
                continue

    if not saved_to:
        _audit_log("SECRET_SAVE_FAIL", "All masters failed")
        return False

    _replicate_secrets(encrypted, skip=saved_to)
    return True

def _replicate_secrets(encrypted: str, skip: str = ""):
    """Replicate to all nodes except skip, plus NAS"""
    my_name = _get_my_server_name()
    targets = [s for s in SECRETS_ALL if s != skip]
    for target in targets:
        try:
            if target == my_name:
                if _write_secret_locally(encrypted):
                    _audit_log("SECRET_REPLICATE", f"Replicated locally to {target}")
                continue
            cmd = f"mkdir -p /var/lib/mpop && echo '{encrypted}' > {SECRETS_PATH} && chmod 600 {SECRETS_PATH} && echo OK"
            result = vssh_exec(target, cmd, timeout=5)
            if result and "OK" in result:
                _audit_log("SECRET_REPLICATE", f"Replicated to {target}")
        except Exception:
            pass
    # NAS backup (storage1)
    try:
        nas_cmd = f"mkdir -p /volume1/backup/secrets && echo '{encrypted}' > /volume1/backup/secrets/secrets.enc && chmod 600 /volume1/backup/secrets/secrets.enc && echo OK"
        _nas = _get_config("nas_server", "").strip()
        if not _nas:
            _audit_log("SECRET_NAS_BACKUP_SKIP", "nas_server not configured")
        else:
            nas_result = vssh_exec(_nas, nas_cmd, timeout=10)
            if nas_result and "OK" in nas_result:
                _audit_log("SECRET_NAS_BACKUP", f"Backed up to NAS {_nas}")
    except Exception:
        pass

def _restore_secrets_backup(backup_num: int = 0) -> bool:
    """Restore secrets from backup (0=latest, 1=previous, 2=oldest)"""
    suffix = "" if backup_num == 0 else f".{backup_num}"
    backup_file = f"secrets.enc.bak{suffix}"
    cmd = f"cp /var/lib/mpop/{backup_file} {SECRETS_PATH} 2>/dev/null && echo OK"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "OK" in result

# ===== Secret Version History =====

def _load_history() -> dict:
    """Load version history from v1"""
    cmd = f"cat {HISTORY_PATH} 2>/dev/null || echo '{{}}'"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    try:
        return json.loads(result.strip()) if result else {}
    except Exception:
        return {}

def _save_history(history: dict) -> bool:
    """Save version history to v1"""
    history_json = json.dumps(history, ensure_ascii=False)
    # Escape for shell
    history_escaped = history_json.replace("'", "'\"'\"'")
    cmd = f"echo '{history_escaped}' > {HISTORY_PATH} && chmod 600 {HISTORY_PATH} && echo OK"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "OK" in result

def _record_history(key: str, old_value: str, new_value: str, action: str = "update"):
    """Record a change to secret history"""
    history = _load_history()

    if key not in history:
        history[key] = []

    # Create history entry (store masked values for security)
    entry = {
        "ts": datetime.now().isoformat(),
        "action": action,  # create, update, delete
        "node": get_my_name(),
        "old_preview": (old_value[:4] + "..." + old_value[-4:]) if old_value and len(old_value) > 8 else ("***" if old_value else None),
        "new_preview": (new_value[:4] + "..." + new_value[-4:]) if new_value and len(new_value) > 8 else ("***" if new_value else None),
        "old_hash": hashlib.sha256(old_value.encode()).hexdigest()[:16] if old_value else None,
        "new_hash": hashlib.sha256(new_value.encode()).hexdigest()[:16] if new_value else None,
        "version": len(history[key]) + 1
    }

    history[key].append(entry)

    # Keep only last 20 versions per key
    if len(history[key]) > 20:
        history[key] = history[key][-20:]

    _save_history(history)

def _get_key_history(key: str) -> list:
    """Get history for a specific key"""
    history = _load_history()
    return history.get(key, [])

def cmd_secret(args):
    """Manage encrypted secrets across VPN"""
    # Check VPN connectivity
    vpn_ip = get_vpn_ip()
    if not vpn_ip or not vpn_ip.startswith(_get_vpn_prefix()):
        print(f"  ❌ Error: Must be connected to VPN ({_get_vpn_prefix()}x.x)")
        return

    if not args:
        machine_id = _get_machine_id()
        registered = _is_machine_registered()
        reg_status = "✓ registered" if registered else "✗ not registered"
        has_master = _has_master_password()
        level4_status = "✓ enabled" if has_master else "✗ disabled"
        session_valid = "✓ unlocked" if _get_session_token() else "✗ locked" if has_master else "-"

        print(f"""
  mpop secret - Encrypted secrets (Level 4 Security)

  This machine: {machine_id[:16]}... ({reg_status})
  Level 4:      {level4_status}
  Session:      {session_valid}

  COMMANDS:
    mpop secret list              List all secret keys
    mpop secret get <key>         Get secret value (stdout)
    mpop secret set <key>         Set secret (interactive)
    mpop secret delete <key>      Delete a secret
    mpop secret register          Register this machine
    mpop secret machines          List registered machines
    mpop secret unregister <id>   Remove machine access
    mpop secret export            Export with backup password
    mpop secret import <backup>   Import from backup

  LEVEL 4 (Master Password):
    mpop secret level4 setup      Enable Level 4 with master password
    mpop secret unlock            Authenticate (5 min session)
    mpop secret lock              Clear session
    mpop secret audit             View access audit log
    mpop secret level4 disable    Disable Level 4

  SECURITY:
    Level 3: VPN + Machine binding
    Level 4: + Master password + Session + Audit log

  EXAMPLES:
    mpop secret level4 setup      # Enable Level 4
    mpop secret unlock            # Authenticate
    mpop secret get api-key       # Use secrets
""")
        return

    subcmd = args[0]

    # Level 4 commands
    if subcmd == "level4":
        if len(args) < 2:
            print("  Usage: mpop secret level4 <setup|disable>")
            return

        action = args[1]

        if action == "setup":
            if _has_master_password():
                print("  ℹ️  Level 4 already enabled")
                print("     Use 'mpop secret level4 disable' first to reset")
                return

            # Check for direct password argument
            if len(args) >= 3:
                password = args[2]
            else:
                print("  🔐 Level 4 Security Setup")
                print("  " + "-" * 40)
                print("  This adds master password protection.")
                print("  You'll need this password to access secrets.\n")

                try:
                    password = getpass.getpass("  Create Master Password: ")
                except EOFError:
                    print("  ❌ Interactive mode required, or use: mpop secret level4 setup <password>")
                    return

                confirm = getpass.getpass("  Confirm Password: ")
                if password != confirm:
                    print("  ❌ Passwords don't match")
                    return

            if len(password) < 8:
                print("  ❌ Password too short (min 8 chars)")
                return

            if _set_master_password(password):
                _create_session()  # Auto-unlock after setup
                _audit_log("LEVEL4_ENABLED", "Master password set")
                print("\n  ✓ Level 4 Security enabled!")
                print("    Session active for 5 minutes.")
            else:
                print("  ❌ Failed to set master password")

        elif action == "disable":
            if not _has_master_password():
                print("  ℹ️  Level 4 not enabled")
                return

            if not _require_master_password("disable Level 4"):
                return

            confirm = input("  Type 'DISABLE' to confirm: ")
            if confirm != "DISABLE":
                print("  Cancelled.")
                return

            cmd = f"rm -f {MASTER_HASH_PATH} && echo OK"
            result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
            if result and "OK" in result:
                _clear_session()
                _audit_log("LEVEL4_DISABLED", "Master password removed")
                print("  ✓ Level 4 disabled")
            else:
                print("  ❌ Failed")

    elif subcmd == "unlock":
        if not _has_master_password():
            print("  ℹ️  Level 4 not enabled. Nothing to unlock.")
            return

        if _get_session_token():
            print("  ℹ️  Already unlocked")
            return

        # Accept password as argument
        password = args[1] if len(args) > 1 else None
        if _require_master_password("unlock session", password):
            print("  ✓ Session active for 5 minutes")

    elif subcmd == "lock":
        _clear_session()
        _audit_log("SESSION_LOCKED", "Manual lock")
        print("  ✓ Session locked")

    elif subcmd == "audit":
        if not _require_master_password("view audit log"):
            return

        cmd = f"tail -50 {AUDIT_LOG_PATH} 2>/dev/null || echo '(no logs)'"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=10)

        print("\n  📋 Audit Log (last 50 entries):")
        print("  " + "-" * 70)

        if result and "(no logs)" not in result:
            for line in result.strip().split('\n'):
                parts = line.split('|')
                if len(parts) >= 5:
                    ts = parts[0][:19]
                    node = parts[1]
                    action = parts[4]
                    detail = parts[5] if len(parts) > 5 else ""
                    print(f"  {ts} | {node:4} | {action:15} | {detail[:30]}")
        else:
            print("  (no audit logs)")

        print()
        _audit_log("AUDIT_VIEW", "Viewed audit log")

    elif subcmd == "list":
        # Level 4: require auth for listing
        if _has_master_password() and not _require_master_password("list secrets"):
            return

        secrets = _load_secrets()
        if not secrets:
            print("  (no secrets stored)")
            return
        print("  Stored secrets:")
        for key in sorted(secrets.keys()):
            val = secrets[key]
            # preview hidden for security
            print(f"    • {key}")
        _audit_log("SECRET_LIST", f"Listed {len(secrets)} secrets")

    elif subcmd == "get":
        if len(args) < 2:
            print("  Usage: mpop secret get <key> [options]", file=sys.stderr)
            print("         Default: masked output (first 4 + last 4 chars)", file=sys.stderr)
            print("         --raw     Print full value (with confirmation)", file=sys.stderr)
            print("         --force   Skip confirmation (for scripts)", file=sys.stderr)
            print("         --clip    Copy to clipboard (no stdout)", file=sys.stderr)
            print("", file=sys.stderr)
            print("  Examples:", file=sys.stderr)
            print("    mpop secret get openai              # masked", file=sys.stderr)
            print("    mpop secret get openai --clip       # clipboard", file=sys.stderr)
            print("    mpop secret get openai --raw --force # script use", file=sys.stderr)
            sys.exit(1)

        key = args[1]
        raw_mode = "--raw" in args
        force_mode = "--force" in args or "-f" in args
        clip_mode = "--clip" in args

        # Level 4: require auth for reading
        if _has_master_password() and not _require_master_password(f"read '{key}'"):
            sys.exit(1)

        secrets = _load_secrets()
        if key not in secrets:
            print(f"  Secret '{key}' not found", file=sys.stderr)
            sys.exit(1)

        value = secrets[key]

        if clip_mode:
            # Copy to clipboard with auto-clear after 30 seconds
            try:
                import platform
                is_mac = platform.system() == "Darwin"

                # Copy to clipboard
                if is_mac:
                    proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
                else:
                    proc = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE)
                proc.communicate(value.encode())

                # Spawn background process to clear clipboard after 30 seconds
                clear_script = '''
import subprocess, time, platform
time.sleep(30)
is_mac = platform.system() == "Darwin"
if is_mac:
    subprocess.run(["pbcopy"], input=b"[clipboard cleared by mpop]", check=False)
else:
    subprocess.run(["xclip", "-selection", "clipboard"], input=b"[clipboard cleared by mpop]", check=False)
'''
                # Run in background, detached
                subprocess.Popen(
                    ['python3', '-c', clear_script],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    start_new_session=True
                )

                print(f"  ✓ '{key}' copied to clipboard", file=sys.stderr)
                print(f"  ⏱️  Auto-clears in 30 seconds", file=sys.stderr)
                _audit_log("SECRET_CLIP", f"Copied key: {key} (30s auto-clear)")
            except Exception as e:
                print(f"  ❌ Clipboard failed: {e}", file=sys.stderr)
                sys.exit(1)
        elif raw_mode:
            # Raw mode: --force skips confirmation (for scripts)
            if not force_mode:
                print("  ⚠️  RAW MODE: value will be printed to stdout.", file=sys.stderr)
                print("     This may leak into terminal history, logs, screen recordings.", file=sys.stderr)
                try:
                    confirm = input("  Continue? (y/N): ")
                except EOFError:
                    confirm = "y"  # Non-interactive (piping) - allow
                if confirm.lower() != 'y':
                    print("  Cancelled.", file=sys.stderr)
                    sys.exit(1)
            # Output value only (no newline for clean piping)
            sys.stdout.write(value)
            sys.stdout.flush()
            _audit_log("SECRET_READ_RAW", f"Read key (raw): {key}")
        else:
            # Default: masked output
            if len(value) <= 8:
                masked = value[:2] + "*" * (len(value) - 2)
            else:
                masked = value[:4] + "*" * (len(value) - 8) + value[-4:]
            print(f"  {key}: {masked}", file=sys.stderr)
            print(f"  (use --raw for full value, --clip to copy)", file=sys.stderr)
            _audit_log("SECRET_READ", f"Read key (masked): {key}")

    elif subcmd == "inject":
        # Inject secrets as environment variables (for scripts)
        if len(args) < 2:
            print("""  Usage: mpop secret inject <key> [ENV_VAR_NAME]
         mpop secret inject --all
         mpop secret inject --keys key1,key2,key3

  Outputs shell commands to set environment variables.
  The secret value never appears in terminal history.

  Examples:
    eval $(mpop secret inject openai OPENAI_API_KEY)
    eval $(mpop secret inject anthropic ANTHROPIC_API_KEY)
    eval $(mpop secret inject --keys openai,anthropic)

  Common mappings (auto-detected):
    openai     → OPENAI_API_KEY
    anthropic  → ANTHROPIC_API_KEY
    apify      → APIFY_TOKEN
""", file=sys.stderr)
            return

        # Auto-mapping for common keys
        auto_map = {
            'openai': 'OPENAI_API_KEY',
            'anthropic': 'ANTHROPIC_API_KEY',
            'apify': 'APIFY_TOKEN',
            'github': 'GITHUB_TOKEN',
            'aws-key': 'AWS_ACCESS_KEY_ID',
            'aws-secret': 'AWS_SECRET_ACCESS_KEY',
            'hf': 'HF_TOKEN',
            'huggingface': 'HF_TOKEN',
        }

        # Level 4: require auth
        if _has_master_password() and not _require_master_password("inject secrets"):
            return

        secrets = _load_secrets()

        if args[1] == "--all":
            # Inject all secrets with auto-naming
            for key, value in secrets.items():
                env_name = auto_map.get(key, key.upper().replace('-', '_'))
                # Escape single quotes in value
                safe_value = value.replace("'", "'\"'\"'")
                print(f"export {env_name}='{safe_value}'")
            _audit_log("SECRET_INJECT", f"Injected all {len(secrets)} secrets")

        elif args[1] == "--keys":
            if len(args) < 3:
                print("  Error: specify keys like --keys openai,anthropic", file=sys.stderr)
                return
            keys = args[2].split(',')
            for key in keys:
                key = key.strip()
                if key not in secrets:
                    print(f"# Warning: '{key}' not found", file=sys.stderr)
                    continue
                value = secrets[key]
                env_name = auto_map.get(key, key.upper().replace('-', '_'))
                safe_value = value.replace("'", "'\"'\"'")
                print(f"export {env_name}='{safe_value}'")
            _audit_log("SECRET_INJECT", f"Injected keys: {','.join(keys)}")

        else:
            # Single key injection
            key = args[1]
            if key not in secrets:
                print(f"  Error: '{key}' not found", file=sys.stderr)
                sys.exit(1)

            value = secrets[key]
            # Use provided env name or auto-map
            env_name = args[2] if len(args) > 2 else auto_map.get(key, key.upper().replace('-', '_'))
            # Escape single quotes
            safe_value = value.replace("'", "'\"'\"'")
            print(f"export {env_name}='{safe_value}'")
            _audit_log("SECRET_INJECT", f"Injected {key} as {env_name}")

    elif subcmd == "set":
        if len(args) < 2:
            print("  Usage: mpop secret set <key> [value]")
            return
        key = args[1]

        # Level 4: require auth for writing
        if _has_master_password() and not _require_master_password(f"set '{key}'"):
            return

        # Get value from argument, stdin, or prompt
        if len(args) >= 3:
            if args[2] == "-":
                # Read from stdin
                value = sys.stdin.read().strip()
            else:
                value = " ".join(args[2:])
        else:
            # Interactive prompt (hidden)
            value = getpass.getpass(f"  Enter value for '{key}': ")

        if not value:
            print("  ❌ Empty value, not saved")
            return

        secrets = _load_secrets()
        old_value = secrets.get(key, "")
        is_new = key not in secrets
        secrets[key] = value

        if _save_secrets(secrets):
            # Record history
            action = "create" if is_new else "update"
            _record_history(key, old_value, value, action)
            print(f"  ✓ Secret '{key}' saved")
            _audit_log("SECRET_SET", f"Set key: {key}")
        else:
            print(f"  ❌ Failed to save secret")

    elif subcmd == "delete":
        if len(args) < 2:
            print("  Usage: mpop secret delete <key>")
            return
        key = args[1]

        # Level 4: require auth for deleting
        if _has_master_password() and not _require_master_password(f"delete '{key}'"):
            return

        secrets = _load_secrets()
        if key not in secrets:
            print(f"  Secret '{key}' not found")
            return
        old_value = secrets[key]
        del secrets[key]
        if _save_secrets(secrets):
            # Record deletion in history
            _record_history(key, old_value, "", "delete")
            print(f"  ✓ Secret '{key}' deleted")
            _audit_log("SECRET_DELETE", f"Deleted key: {key}")
        else:
            print(f"  ❌ Failed to delete secret")

    elif subcmd == "history":
        # Show version history for a key
        if len(args) < 2:
            print("  Usage: mpop secret history <key>")
            print("         mpop secret history --all")
            return

        if args[1] == "--all":
            # Show summary of all keys with history
            history = _load_history()
            if not history:
                print("  (no history recorded)")
                return
            print(f"\n  📜 Secret History ({len(history)} keys)")
            print("  " + "-" * 50)
            for key in sorted(history.keys()):
                entries = history[key]
                last = entries[-1] if entries else {}
                last_ts = last.get("ts", "?")[:19]
                last_action = last.get("action", "?")
                print(f"  {key:20} | v{len(entries):2} | {last_action:6} | {last_ts}")
        else:
            key = args[1]
            entries = _get_key_history(key)
            if not entries:
                print(f"  No history for '{key}'")
                return

            print(f"\n  📜 History: {key}")
            print("  " + "-" * 60)
            print(f"  {'Ver':<4} {'Timestamp':<20} {'Action':<8} {'Preview':<20} {'Hash':<10}")
            print("  " + "-" * 60)

            for entry in entries:
                ver = entry.get("version", "?")
                ts = entry.get("ts", "?")[:19]
                action = entry.get("action", "?")
                preview = entry.get("new_preview", "***") or "***"
                hash_val = entry.get("new_hash", "")[:8] if entry.get("new_hash") else "-"
                node = entry.get("node", "?")

                # Color code actions
                if action == "create":
                    action_str = "create"
                elif action == "delete":
                    action_str = "delete"
                    preview = "(deleted)"
                else:
                    action_str = "update"

                print(f"  v{ver:<3} {ts:<20} {action_str:<8} {preview:<20} {hash_val}")

            print()
            print(f"  Total: {len(entries)} versions")

    elif subcmd == "export":
        # Level 4: require auth for export
        if _has_master_password() and not _require_master_password("export secrets"):
            return

        # Export with separate backup password (not VPN-bound)
        secrets = _load_secrets()
        if not secrets:
            print("  (no secrets to export)")
            return

        print("  Export secrets with backup password")
        print("  This creates a portable backup that works anywhere")
        print()
        password = getpass.getpass("  Enter backup password: ")
        password2 = getpass.getpass("  Confirm password: ")

        if password != password2:
            print("  ❌ Passwords don't match")
            return
        if len(password) < 8:
            print("  ❌ Password too short (min 8 chars)")
            return

        # Encrypt with user password (not VPN-bound)
        salt = os.urandom(16)
        key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PBKDF2_ITERATIONS, dklen=32)

        data = {"v": 3, "type": "backup", "secrets": secrets}
        plaintext = json.dumps(data).encode('utf-8')
        iv, ciphertext, mac = _aes_encrypt(plaintext, key)

        # Format: MPOP-BACKUP: + base64
        payload = b'\x03' + salt + iv + mac + ciphertext
        backup = "MPOP-BACKUP:" + base64.b64encode(payload).decode('ascii')

        print()
        print("  ✓ Backup created. Save this somewhere safe:")
        print()
        print(backup)
        print()

    elif subcmd == "import":
        # Import from backup
        if len(args) < 2:
            print("  Usage: mpop secret import <backup-string>")
            print("         cat backup.txt | mpop secret import -")
            return

        if args[1] == "-":
            backup = sys.stdin.read().strip()
        else:
            backup = args[1]

        if not backup.startswith("MPOP-BACKUP:"):
            print("  ❌ Invalid backup format")
            return

        password = getpass.getpass("  Enter backup password: ")

        try:
            raw = base64.b64decode(backup[12:])  # Skip "MPOP-BACKUP:"
            version = raw[0]
            if version != 3:
                raise ValueError("Wrong backup version")

            salt = raw[1:17]
            iv = raw[17:33]
            mac = raw[33:49]
            ciphertext = raw[49:]

            key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PBKDF2_ITERATIONS, dklen=32)
            plaintext = _aes_decrypt(iv, ciphertext, mac, key)
            data = json.loads(plaintext)

            imported = data.get("secrets", {})
            if not imported:
                print("  ❌ No secrets in backup")
                return

            # Merge with existing
            existing = _load_secrets()
            merged = {**existing, **imported}

            if _save_secrets(merged):
                print(f"  ✓ Imported {len(imported)} secrets")
            else:
                print("  ❌ Failed to save")

        except Exception as e:
            print(f"  ❌ Import failed: wrong password or corrupted backup")

    elif subcmd == "import-csv":
        # Import from Chrome/Safari CSV export
        if len(args) < 2:
            print("""
  mpop secret import-csv - Import passwords from browser CSV

  USAGE:
    mpop secret import-csv <file.csv>
    mpop secret import-csv passwords.csv --prefix=chrome

  SUPPORTED FORMATS:
    • Chrome:  name,url,username,password
    • Safari:  Title,URL,Username,Password,Notes,OTPAuth
    • Firefox: "hostname","username","password",...

  HOW TO EXPORT:
    Chrome:  Settings → Passwords → ⋮ → Export passwords
    Safari:  Settings → Passwords → ⋮ → Export All Passwords
    Firefox: about:logins → ⋮ → Export Logins

  OPTIONS:
    --prefix=<name>   Add prefix to keys (e.g., chrome-github)
    --dry-run         Show what would be imported
    --filter=<site>   Only import matching sites
""")
            return

        csv_file = args[1]
        prefix = ""
        dry_run = False
        site_filter = None

        for arg in args[2:]:
            if arg.startswith("--prefix="):
                prefix = arg.split("=")[1] + "-"
            elif arg == "--dry-run":
                dry_run = True
            elif arg.startswith("--filter="):
                site_filter = arg.split("=")[1].lower()

        # Level 4 check
        if _has_master_password() and not _require_master_password("import passwords"):
            return

        if not os.path.exists(csv_file):
            print(f"  ❌ File not found: {csv_file}")
            return

        import csv

        try:
            with open(csv_file, 'r', encoding='utf-8') as f:
                # Detect format by header
                first_line = f.readline().strip().lower()
                f.seek(0)

                reader = csv.DictReader(f)
                imported = {}
                skipped = 0

                for row in reader:
                    # Normalize field names (different browsers use different names)
                    name = row.get('name') or row.get('title') or row.get('hostname', '')
                    url = row.get('url') or row.get('origin', '')
                    username = row.get('username') or row.get('login', '')
                    password = row.get('password', '')

                    if not name and url:
                        # Extract domain from URL
                        try:
                            from urllib.parse import urlparse
                            name = urlparse(url).netloc.replace('www.', '')
                        except Exception:
                            name = url[:30]

                    if not password:
                        skipped += 1
                        continue

                    # Apply filter
                    if site_filter and site_filter not in name.lower() and site_filter not in url.lower():
                        skipped += 1
                        continue

                    # Create key name
                    key = f"{prefix}{name}"
                    # Sanitize key (remove special chars)
                    key = ''.join(c if c.isalnum() or c in '-_.' else '-' for c in key)
                    key = key.lower().strip('-')

                    if not key:
                        skipped += 1
                        continue

                    # Store as JSON with metadata
                    value = json.dumps({
                        "username": username,
                        "password": password,
                        "url": url
                    }, ensure_ascii=False)

                    imported[key] = value

                if dry_run:
                    print(f"\n  📋 DRY RUN - Would import {len(imported)} passwords:\n")
                    for key in sorted(imported.keys())[:20]:
                        data = json.loads(imported[key])
                        print(f"    • {key}: {data['username'][:15]}... → {data['url'][:30]}")
                    if len(imported) > 20:
                        print(f"    ... and {len(imported) - 20} more")
                    print(f"\n  Skipped: {skipped}")
                    print(f"\n  Run without --dry-run to import.")
                    return

                # Merge with existing secrets
                existing = _load_secrets()
                merged = {**existing, **imported}

                if _save_secrets(merged):
                    print(f"  ✓ Imported {len(imported)} passwords")
                    print(f"    Skipped: {skipped}")
                    _audit_log("CSV_IMPORT", f"Imported {len(imported)} from {csv_file}")

                    print(f"\n  Usage example:")
                    if imported:
                        example_key = list(imported.keys())[0]
                        print(f"    mpop secret get {example_key} | jq -r '.password'")
                else:
                    print("  ❌ Failed to save")

        except Exception as e:
            print(f"  ❌ Import failed: {e}")

    elif subcmd == "register":
        # Register this machine for secret access
        machine_id = _get_machine_id()

        if _is_machine_registered():
            print(f"  ℹ️  This machine is already registered")
            print(f"     Machine ID: {machine_id[:16]}...")
            return

        print(f"  Registering machine: {machine_id[:16]}...")

        if _register_machine(machine_id):
            print(f"  ✓ Machine registered successfully")
            print(f"    This machine can now access secrets")
        else:
            print(f"  ❌ Failed to register machine")
            print(f"    Check VPN connection and relay1 accessibility")

    elif subcmd == "machines":
        # List registered machines
        machines = _get_registered_machines()
        my_machine_id = _get_machine_id()

        if not machines:
            print("  No machines registered yet")
            print("  Run 'mpop secret register' to register this machine")
            return

        print(f"  Registered machines ({len(machines)}):")
        for mid in sorted(machines):
            indicator = " ← this machine" if mid == my_machine_id else ""
            print(f"    • {mid[:16]}...{indicator}")

    elif subcmd == "unregister":
        # Unregister a machine
        if len(args) < 2:
            print("  Usage: mpop secret unregister <machine-id-prefix>")
            print("         mpop secret machines  # to see IDs")
            return

        prefix = args[1]
        machines = _get_registered_machines()

        # Find matching machine
        matches = [m for m in machines if m.startswith(prefix)]

        if not matches:
            print(f"  ❌ No machine matching '{prefix}'")
            return
        if len(matches) > 1:
            print(f"  ❌ Multiple matches for '{prefix}':")
            for m in matches:
                print(f"      {m[:16]}...")
            print("  Use a longer prefix")
            return

        target = matches[0]
        machines.remove(target)
        machines_json = json.dumps(list(machines))
        cmd = f"echo '{machines_json}' > {MACHINES_PATH} && echo OK"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)

        if result and "OK" in result:
            print(f"  ✓ Machine unregistered: {target[:16]}...")
        else:
            print(f"  ❌ Failed to unregister")

    else:
        print(f"  Unknown subcommand: {subcmd}")
        print("  Run 'mpop secret' for help")

# ==================== Logs Command ====================

def cmd_logs(args):
    """Real-time log viewing for a server"""
    if not args:
        print("""
  mpop logs - Real-time log viewer

  USAGE:
    mpop logs <node>              View system logs (journalctl -f)
    mpop logs <node> <service>    View specific service logs
    mpop logs <node> auth         View auth/SSH logs
    mpop logs <node> nginx        View nginx access logs
    mpop logs <node> docker       View docker logs

  EXAMPLES:
    mpop logs node1               # All system logs on node1
    mpop logs g2 nginx            # nginx logs on g2
    mpop logs v2 auth             # SSH/auth logs on v2
""")
        return

    node = args[0]
    if node not in SERVERS:
        print(f"  ❌ Unknown server: {node}")
        print(f"     Available: {', '.join(SERVERS.keys())}")
        return

    ip = SERVERS[node]
    service = args[1] if len(args) > 1 else None

    # Build the log command based on service
    if service == "auth":
        log_cmd = "tail -f /var/log/auth.log 2>/dev/null || journalctl -f -u sshd"
    elif service == "nginx":
        log_cmd = "tail -f /var/log/nginx/access.log 2>/dev/null || tail -f /var/log/nginx/*.log"
    elif service == "docker":
        log_cmd = "docker logs -f $(docker ps -q | head -1) 2>/dev/null || journalctl -f -u docker"
    elif service:
        log_cmd = f"journalctl -f -u {service}"
    else:
        log_cmd = "journalctl -f -n 50"

    print(f"  📋 Streaming logs from {node} ({ip})")
    print(f"     Command: {log_cmd}")
    print(f"     Press Ctrl+C to stop\n")
    print("  " + "=" * 70)

    # Use SSH for real-time streaming (vssh doesn't support streaming well)
    try:
        # Determine SSH command based on server
        # Read user and port from config
        servers_cfg = _get_config("servers", {})
        srv_cfg = servers_cfg.get(node, {})
        user = srv_cfg.get("user", "root")
        ssh_port = srv_cfg.get("ssh_port", 22)
        port_opt = f"-p {ssh_port} " if ssh_port != 22 else ""
        ssh_cmd = f"ssh {port_opt}-o ConnectTimeout=5 -o StrictHostKeyChecking=no {user}@{ip} '{log_cmd}'"

        subprocess.run(ssh_cmd, shell=True)
    except KeyboardInterrupt:
        print("\n  Log stream stopped.")

# ==================== Alert Command ====================

ALERT_CONFIG_PATH = "/var/lib/mpop/alert.json"

def _load_alert_config():
    """Load alert configuration from relay1"""
    cmd = f"cat {ALERT_CONFIG_PATH} 2>/dev/null || echo '{{}}'"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    try:
        return json.loads(result.strip())
    except Exception:
        return {}

def _save_alert_config(config):
    """Save alert configuration to relay1"""
    config_json = json.dumps(config)
    cmd = f"mkdir -p /var/lib/mpop && echo '{config_json}' > {ALERT_CONFIG_PATH} && chmod 600 {ALERT_CONFIG_PATH} && echo OK"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)
    return result and "OK" in result

def _send_telegram(bot_token, chat_id, message):
    """Send a Telegram message"""
    import urllib.request
    import urllib.parse
    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
    data = urllib.parse.urlencode({
        "chat_id": chat_id,
        "text": message,
        "parse_mode": "Markdown"
    }).encode()
    try:
        req = urllib.request.Request(url, data=data)
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.loads(resp.read().decode())
    except Exception as e:
        return {"error": str(e)}

def _check_alerts():
    """Check all servers and return alerts"""
    alerts = []
    vpn_ip = get_vpn_ip()

    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name = futures[future]
            r = future.result()

            if not r.get("ping") and not r.get("me"):
                alerts.append({"node": name, "type": "DOWN", "msg": f"{name} is offline"})

            if r.get("stats"):
                try:
                    load = float(r["stats"].get("load", "0"))
                    if load > 5:
                        alerts.append({"node": name, "type": "HIGH_LOAD", "msg": f"{name} load: {load}"})

                    disk = int(r["stats"].get("disk", "0%").replace('%', ''))
                    if disk > 90:
                        alerts.append({"node": name, "type": "DISK_FULL", "msg": f"{name} disk: {disk}%"})

                    failed = int(r["stats"].get("failed_logins", "0"))
                    if failed > 100:
                        alerts.append({"node": name, "type": "ATTACK", "msg": f"{name} SSH attacks: {failed}"})
                except Exception:
                    pass

    return alerts

def cmd_alert(args):
    """Manage alerts and notifications"""
    if not args:
        config = _load_alert_config()
        has_telegram = bool(config.get("telegram_token"))

        print(f"""
  mpop alert - Alert & Notification System

  STATUS:
    Telegram: {"✓ configured" if has_telegram else "✗ not configured"}

  COMMANDS:
    mpop alert setup telegram     Configure Telegram bot
    mpop alert test               Send test alert
    mpop alert check              Check for alerts now
    mpop alert status             Show current alert status
    mpop alert history            Show alert history

  THRESHOLDS:
    • Server DOWN: ping fails
    • HIGH_LOAD: load > 5.0
    • DISK_FULL: disk > 90%
    • ATTACK: SSH failures > 100/24h

  To get a Telegram bot token:
    1. Message @BotFather on Telegram
    2. /newbot → follow instructions
    3. Copy the token
    4. Get chat_id: message your bot, then visit:
       https://api.telegram.org/bot<TOKEN>/getUpdates
""")
        return

    subcmd = args[0]

    if subcmd == "setup":
        if len(args) < 2:
            print("  Usage: mpop alert setup telegram")
            return

        if args[1] == "telegram":
            print("  Telegram Alert Setup")
            print("  " + "-" * 40)

            token = input("  Bot Token: ").strip()
            chat_id = input("  Chat ID: ").strip()

            if not token or not chat_id:
                print("  ❌ Both token and chat_id are required")
                return

            # Test the configuration
            print("  Testing connection...")
            result = _send_telegram(token, chat_id, "🔧 mpop alert test - configuration successful!")

            if "error" in result:
                print(f"  ❌ Failed: {result['error']}")
                return

            # Save configuration
            config = _load_alert_config()
            config["telegram_token"] = token
            config["telegram_chat_id"] = chat_id

            if _save_alert_config(config):
                print("  ✓ Telegram configured successfully!")
                print("    You should have received a test message.")
            else:
                print("  ❌ Failed to save configuration")

    elif subcmd == "test":
        config = _load_alert_config()
        if not config.get("telegram_token"):
            print("  ❌ Telegram not configured. Run: mpop alert setup telegram")
            return

        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        message = f"""🔔 *mpop Alert Test*

Time: {now}
From: {get_my_name()} ({get_vpn_ip()})

This is a test alert. If you see this, alerts are working!"""

        result = _send_telegram(config["telegram_token"], config["telegram_chat_id"], message)

        if "error" in result:
            print(f"  ❌ Failed: {result['error']}")
        else:
            print("  ✓ Test alert sent!")

    elif subcmd == "check":
        print("  Checking all servers for alerts...")
        alerts = _check_alerts()

        if not alerts:
            print("  ✓ All systems normal - no alerts")
            return

        print(f"\n  ⚠️  Found {len(alerts)} alert(s):\n")
        for alert in alerts:
            print(f"    [{alert['type']}] {alert['msg']}")

        # Send to Telegram if configured
        config = _load_alert_config()
        if config.get("telegram_token"):
            send = input("\n  Send to Telegram? [y/N]: ").strip().lower()
            if send == "y":
                now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                alert_text = "\n".join([f"• [{a['type']}] {a['msg']}" for a in alerts])
                message = f"""🚨 *mpop Alert*

Time: {now}

{alert_text}"""
                result = _send_telegram(config["telegram_token"], config["telegram_chat_id"], message)
                if "error" not in result:
                    print("  ✓ Alert sent to Telegram")

    elif subcmd == "status":
        config = _load_alert_config()
        print("\n  Alert Configuration Status:")
        print("  " + "-" * 40)
        print(f"    Telegram: {'✓ configured' if config.get('telegram_token') else '✗ not set'}")
        if config.get("telegram_chat_id"):
            print(f"    Chat ID: {config['telegram_chat_id']}")
        print()

    else:
        print(f"  Unknown subcommand: {subcmd}")
        print("  Run 'mpop alert' for help")

# ==================== History Command ====================

HISTORY_PATH = "/var/lib/mpop/history.log"

CMDLOG_PATH = "/var/lib/mpop/cmdlog.txt"

def _log_history(command, result="OK"):
    """Log command to history"""
    now = datetime.now().isoformat()
    node = get_my_name()
    entry = f"{now}|{node}|{command}|{result}"
    # Escape single quotes
    entry = entry.replace("'", "'\\''")
    cmd = f"echo '{entry}' >> {CMDLOG_PATH}"
    try:
        vssh_exec(SECRETS_MASTERS[0], cmd, timeout=3)
    except Exception:
        pass  # Don't fail if logging fails


def cmd_completion(args):
    """Generate shell completion scripts for zsh/bash"""

    shell = "zsh"  # default
    install = "--install" in args

    for arg in args:
        if arg in ("zsh", "bash"):
            shell = arg

    commands = [
        "dashboard", "live", "matrix", "network", "vpn", "services",
        "security", "audit", "watch", "report", "roles", "info",
        "exec", "servers", "trend", "ai", "agent", "full",
        "temp", "gpu", "restart", "secret", "logs", "alert", "history",
        "backup", "deploy", "enforce", "heal", "export",
        "diff", "predict", "map", "peers", "dns", "acl", "tunnel", "voice"
    ]

    servers = list(SERVERS.keys())

    if shell == "zsh":
        script = f'''#compdef mpop

_mpop() {{
    local -a commands servers

    commands=(
        {' '.join(f'"{c}"' for c in commands)}
    )

    servers=(
        {' '.join(f'"{s}"' for s in servers)}
    )

    _arguments -C \\
        '1: :->command' \\
        '*: :->args'

    case $state in
        command)
            _describe 'command' commands
            ;;
        args)
            case $words[2] in
                info|logs|roles|diff|heal|predict)
                    _describe 'server' servers
                    ;;
                exec|restart)
                    _describe 'server' servers
                    ;;
                secret)
                    local -a secret_cmds
                    secret_cmds=("list" "get" "set" "register" "machines" "export" "import")
                    _describe 'secret command' secret_cmds
                    ;;
                deploy)
                    _files
                    ;;
            esac
            ;;
    esac
}}

compdef _mpop mpop
'''
        comp_file = os.path.expanduser("~/.zsh/completions/_mpop")

    else:  # bash
        script = f'''# mpop bash completion

_mpop_completions() {{
    local cur="${{COMP_WORDS[COMP_CWORD]}}"
    local prev="${{COMP_WORDS[COMP_CWORD-1]}}"

    local commands="{' '.join(commands)}"
    local servers="{' '.join(servers)}"

    case ${{COMP_CWORD}} in
        1)
            COMPREPLY=($(compgen -W "$commands" -- "$cur"))
            ;;
        2)
            case $prev in
                info|logs|roles|diff|heal|predict)
                    COMPREPLY=($(compgen -W "$servers" -- "$cur"))
                    ;;
                secret)
                    COMPREPLY=($(compgen -W "list get set register machines export import" -- "$cur"))
                    ;;
            esac
            ;;
    esac
}}

complete -F _mpop_completions mpop
'''
        comp_file = os.path.expanduser("~/.bash_completion.d/mpop")

    if install:
        os.makedirs(os.path.dirname(comp_file), exist_ok=True)
        with open(comp_file, 'w') as f:
            f.write(script)
        print(f"  {C.GREEN}✓ Completion installed:{C.R} {comp_file}")
        if shell == "zsh":
            print(f"\n  Add to ~/.zshrc:")
            print(f"    fpath=(~/.zsh/completions $fpath)")
            print(f"    autoload -Uz compinit && compinit")
        else:
            print(f"\n  Add to ~/.bashrc:")
            print(f"    source {comp_file}")
        print(f"\n  Then restart your shell or run: exec $SHELL")
    else:
        print(f"  {C.BOLD}Shell completion script ({shell}):{C.R}\n")
        print(script)
        print(f"  {C.DIM}To install: mpop completion {shell} --install{C.R}")


def cmd_map(args):
    """Service dependency map - visualize server roles and connections"""

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🗺️  SERVICE MAP{C.R} - Infrastructure Topology
  {C.BOLD}═══════════════════════════════════════════════════════════════════════════{C.R}
""")

    vpn_ip = get_vpn_ip()

    # Collect services from all servers
    print(f"  {C.DIM}Scanning services...{C.R}\n")

    services_data = {}
    for name, ip in SERVERS.items():
        cmd = '''echo "PORTS:$(ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ',')"
echo "DOCKER:$(docker ps --format '{{.Names}}:{{.Ports}}' 2>/dev/null | head -5 | tr '\n' '|')"
echo "SYSTEMD:$(systemctl list-units --type=service --state=running 2>/dev/null | grep -E 'nginx|redis|postgres|mysql|mongo|elastic|ollama|turn|wire' | awk '{print $1}' | tr '\n' ',')"'''
        result = vssh_exec(ip, cmd, timeout=8)
        if result:
            parsed = {}
            for line in result.split('\n'):
                if line.startswith("PORTS:"):
                    parsed["ports"] = [p for p in line[6:].strip(",").split(",") if p]
                elif line.startswith("DOCKER:"):
                    parsed["docker"] = [d for d in line[7:].split("|") if d.strip()]
                elif line.startswith("SYSTEMD:"):
                    parsed["systemd"] = [s for s in line[8:].strip(",").split(",") if s]
            services_data[name] = parsed

    # Define service categories (short names)
    categories = {
        "VPN": {"ports": ["8790", "51830", "48291"], "keywords": ["wire", "wireguard", "tailscale"]},
        "Web": {"ports": ["80", "443", "8080", "3000"], "keywords": ["nginx", "apache", "caddy"]},
        "DB": {"ports": ["3306", "5432", "6379", "27017"], "keywords": ["mysql", "postgres", "redis", "mongo"]},
        "AI": {"ports": ["11434"], "keywords": ["ollama", "vllm", "torch"]},
        "Media": {"ports": ["3478", "8001"], "keywords": ["turn", "coturn", "icecast", "liquidsoap"]},
        "Mon": {"ports": ["8721", "9090"], "keywords": ["prometheus", "grafana", "agent"]},
    }

    # Build the map
    print(f"  {C.BOLD}Server  Role              Services{C.R}")
    print(f"  ────────────────────────────────────────────────────────────")

    server_roles = {}
    for name in SERVERS.keys():
        data = services_data.get(name, {})
        ports = set(data.get("ports", []))
        systemd = " ".join(data.get("systemd", []))
        docker = " ".join(data.get("docker", []))
        all_services = (systemd + " " + docker).lower()

        roles = []
        key_services = []

        for cat, criteria in categories.items():
            if any(p in ports for p in criteria["ports"]) or any(k in all_services for k in criteria["keywords"]):
                roles.append(cat)
                for kw in criteria["keywords"]:
                    if kw in all_services and kw not in key_services:
                        key_services.append(kw)

        if not roles:
            roles = ["--"]

        server_roles[name] = roles
        name_color = C.CYAN if name.startswith('v') else (C.GREEN if name.startswith('g') else C.BLUE)
        role_str = "/".join(roles[:3])[:15]
        svc_str = ", ".join(key_services[:4])[:30] or "-"
        print(f"  {name_color}{name:<6}{C.R}  {role_str:<16}  {svc_str}")

    # ASCII topology
    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}TOPOLOGY{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════════════════{C.R}

                              ┌─────────────┐
                              │  {C.YELLOW}INTERNET{C.R}   │
                              └──────┬──────┘
                                     │
              ┌──────────────────────┼──────────────────────┐
              │                      │                      │
        ┌─────┴─────┐          ┌─────┴─────┐          ┌─────┴─────┐
        │ {C.CYAN}v1{C.R} (VPN) │          │ {C.CYAN}v2{C.R} (VPN) │          │ {C.CYAN}v3{C.R} (VPN) │
        │  Primary  │          │ Secondary │          │ Tertiary  │
        └─────┬─────┘          └─────┬─────┘          └─────┬─────┘
              │                      │                      │
              └──────────────────────┼──────────────────────┘
                                     │
                          ┌──────────┴──────────┐
                          │    {C.GREEN}VPN MESH{C.R} (10.99.x)  │
                          └──────────┬──────────┘
                                     │
         ┌───────────┬───────────┬───┴───┬───────────────────────┐
         │           │           │       │                       │
    ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │  your servers here    │
    │  node1  │ │  node2  │ │  node3  │ │  (mpop init to setup) │
    │         │ │         │ │         │ └───────────────────────┘
    └─────────┘ └─────────┘ └─────────┘
""")

    # Dependencies — shown from live server config
    print(f"  {C.BOLD}ACTIVE NODES:{C.R}")
    for name in sorted(SERVERS.keys())[:8]:
        role = _get_config(f"server_roles.{name}", "")
        role_str = f" — {role}" if role else ""
        print(f"  • {C.CYAN}{name}{C.R}{role_str}")
    if len(SERVERS) > 8:
        print(f"  • ... and {len(SERVERS) - 8} more (mpop servers)")
    print()


def cmd_predict(args):
    """Predict future issues based on trends (disk, memory, load)"""
    from datetime import datetime, timedelta

    target = None
    for arg in args:
        if arg in SERVERS:
            target = arg

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}📈 PREDICTIVE ANALYSIS{C.R} - Forecasting potential issues
   Target: {target or 'ALL servers'}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    servers_to_check = {target: SERVERS[target]} if target else SERVERS
    vpn_ip = get_vpn_ip()

    # Collect historical data from server-agent
    print(f"  {C.DIM}Fetching trend data from server-agent...{C.R}")
    trend_data = query_server_agent("/api/trends?hours=168")  # 7 days

    if "error" in trend_data:
        print(f"  {C.YELLOW}Warning:{C.R} Could not fetch trend data. Using current snapshot only.")
        trend_data = {}

    # Get current stats
    print(f"  {C.DIM}Collecting current metrics...{C.R}")
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in servers_to_check.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    predictions = []

    for name, r in results.items():
        if not r.get("ping") or r.get("me"):
            continue

        stats = r.get("stats", {})

        # Disk prediction
        try:
            disk_pct = int(str(stats.get("disk", "0")).replace("%", ""))
            # Estimate growth: assume 1-2% per day based on typical usage
            if disk_pct > 70:
                days_to_full = max(1, (100 - disk_pct) // 2)  # ~2% per day
                if days_to_full <= 14:
                    severity = "CRITICAL" if days_to_full <= 3 else "WARNING"
                    predictions.append({
                        "server": name,
                        "type": "DISK_FULL",
                        "current": f"{disk_pct}%",
                        "prediction": f"Full in ~{days_to_full} days",
                        "severity": severity,
                        "action": "Clean logs/cache or expand storage"
                    })
        except Exception: pass

        # Memory prediction (if consistently high)
        try:
            mem_pct = int(str(stats.get("mem", "0")).replace("%", ""))
            if mem_pct > 80:
                predictions.append({
                    "server": name,
                    "type": "MEM_PRESSURE",
                    "current": f"{mem_pct}%",
                    "prediction": "Memory pressure likely",
                    "severity": "WARNING",
                    "action": "Consider adding swap or optimizing services"
                })
        except Exception: pass

        # Load prediction
        try:
            load_val = float(str(stats.get("load", "0")).split(",")[0])
            # If load is consistently high
            if load_val > 3:
                predictions.append({
                    "server": name,
                    "type": "HIGH_LOAD",
                    "current": f"{load_val:.2f}",
                    "prediction": "Sustained high load",
                    "severity": "WARNING",
                    "action": "Check CPU-intensive processes"
                })
        except Exception: pass

    # Display predictions
    if not predictions:
        print(f"""
  {C.GREEN}✓ All systems healthy!{C.R}

  No issues predicted in the next 14 days.
  All servers operating within normal parameters.
""")
        return

    print(f"  {C.BOLD}Found {len(predictions)} prediction(s):{C.R}\n")

    # Group by severity
    critical = [p for p in predictions if p["severity"] == "CRITICAL"]
    warning = [p for p in predictions if p["severity"] == "WARNING"]

    if critical:
        print(f"  {C.RED}━━━ CRITICAL (action required soon) ━━━{C.R}\n")
        for p in critical:
            print(f"  {C.RED}⚠{C.R} {C.CYAN}{p['server']}{C.R}: {p['type']}")
            print(f"    Current: {p['current']} → {C.RED}{p['prediction']}{C.R}")
            print(f"    {C.DIM}Action: {p['action']}{C.R}")
            print()

    if warning:
        print(f"  {C.YELLOW}━━━ WARNING (monitor closely) ━━━{C.R}\n")
        for p in warning:
            print(f"  {C.YELLOW}○{C.R} {C.CYAN}{p['server']}{C.R}: {p['type']}")
            print(f"    Current: {p['current']} → {p['prediction']}")
            print(f"    {C.DIM}Action: {p['action']}{C.R}")
            print()

    # Summary
    print(f"  {C.BOLD}Summary:{C.R}")
    print(f"  • {C.RED}Critical:{C.R} {len(critical)} | {C.YELLOW}Warning:{C.R} {len(warning)}")
    print(f"  • Run {C.BOLD}mpop heal{C.R} to see auto-fix options")
    print()


def cmd_diff(args):
    """Compare two servers - packages, config, services"""

    if len(args) < 2:
        print(f"""
  {C.BOLD}Usage:{C.R} mpop diff <server1> <server2>

  {C.BOLD}Examples:{C.R}
    mpop diff node1 node2  # Compare node1 and node2
    mpop diff g1 g2     # Compare GPU servers
""")
        return

    storage1, storage2 = args[0], args[1]
    if storage1 not in SERVERS or storage2 not in SERVERS:
        print(f"  {C.RED}Error:{C.R} Unknown server. Available: {', '.join(SERVERS.keys())}")
        return

    ip1, ip2 = SERVERS[storage1], SERVERS[storage2]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}SERVER DIFF{C.R}: {s1} vs {s2}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    # Collect comparison data
    compare_cmds = r'''
echo "===OS==="
cat /etc/os-release 2>/dev/null | grep -E "^(NAME|VERSION)=" | head -2
echo "===KERNEL==="
uname -r
echo "===PKGS==="
dpkg -l 2>/dev/null | wc -l || rpm -qa 2>/dev/null | wc -l
echo "===SERVICES==="
systemctl list-units --type=service --state=running 2>/dev/null | grep -E "^\s*\w" | awk '{print $1}' | sort | head -20
echo "===PORTS==="
ss -tln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ' '
echo ""
echo "===DOCKER==="
docker ps --format '{{.Names}}' 2>/dev/null | sort | head -10 || echo "N/A"
echo "===DISK==="
df -h / | tail -1 | awk '{print $5}'
echo "===MEM==="
free -h 2>/dev/null | awk '/Mem:/ {print $3"/"$2}'
'''

    print(f"  {C.DIM}Collecting data from {storage1} and {storage2}...{C.R}")

    with ThreadPoolExecutor(max_workers=2) as ex:
        f1 = ex.submit(vssh_exec, ip1, compare_cmds, 15)
        f2 = ex.submit(vssh_exec, ip2, compare_cmds, 15)
        r1 = f1.result() or ""
        r2 = f2.result() or ""

    def parse_sections(text):
        sections = {}
        current = None
        for line in text.split('\n'):
            if line.startswith("===") and line.endswith("==="):
                current = line.strip("=")
                sections[current] = []
            elif current:
                sections[current].append(line.strip())
        return {k: [v for v in vals if v] for k, vals in sections.items()}

    worker1 = parse_sections(r1)
    worker2 = parse_sections(r2)

    # Compare each section
    def show_diff(label, relay1, relay2):
        v1_str = ' '.join(relay1) if isinstance(relay1, list) else str(relay1)
        v2_str = ' '.join(relay2) if isinstance(relay2, list) else str(relay2)
        if v1_str == v2_str:
            print(f"  {C.DIM}{label:12}{C.R} │ {v1_str[:30]:30} │ {C.GREEN}={C.R}")
        else:
            print(f"  {C.YELLOW}{label:12}{C.R} │ {v1_str[:30]:30} │ {v2_str[:30]:30}")

    print(f"\n  {'Category':12} │ {storage1:30} │ {storage2:30}")
    print(f"  {'-'*12}-┼-{'-'*30}-┼-{'-'*30}")

    show_diff("OS", worker1.get("OS", ["-"]), worker2.get("OS", ["-"]))
    show_diff("Kernel", worker1.get("KERNEL", ["-"]), worker2.get("KERNEL", ["-"]))
    show_diff("Packages", worker1.get("PKGS", ["-"]), worker2.get("PKGS", ["-"]))
    show_diff("Disk Used", worker1.get("DISK", ["-"]), worker2.get("DISK", ["-"]))
    show_diff("Memory", worker1.get("MEM", ["-"]), worker2.get("MEM", ["-"]))
    show_diff("Ports", worker1.get("PORTS", ["-"]), worker2.get("PORTS", ["-"]))

    # Services diff - filter only .service entries
    svc1 = set(s for s in worker1.get("SERVICES", []) if s.endswith(".service"))
    svc2 = set(s for s in worker2.get("SERVICES", []) if s.endswith(".service"))
    only1 = svc1 - svc2
    only2 = svc2 - svc1

    # Shorten service names for display
    def short_svc(name):
        return name.replace(".service", "")[:20]

    print(f"\n  {C.BOLD}Services:{C.R}")
    print(f"  {C.GREEN}Both:{C.R} {len(svc1 & svc2)} | {C.YELLOW}Only {storage1}:{C.R} {len(only1)} | {C.YELLOW}Only {storage2}:{C.R} {len(only2)}")
    if only1:
        print(f"  {C.YELLOW}Only in {storage1}:{C.R} {', '.join(short_svc(s) for s in list(only1)[:5])}")
    if only2:
        print(f"  {C.YELLOW}Only in {storage2}:{C.R} {', '.join(short_svc(s) for s in list(only2)[:5])}")

    # Docker diff
    dock1 = set(worker1.get("DOCKER", []))
    dock2 = set(worker2.get("DOCKER", []))
    if dock1 != {"N/A"} or dock2 != {"N/A"}:
        print(f"\n  {C.BOLD}Docker Containers:{C.R}")
        if dock1 - {"N/A"}:
            print(f"  {storage1}: {', '.join(dock1 - {'N/A'})}")
        if dock2 - {"N/A"}:
            print(f"  {storage2}: {', '.join(dock2 - {'N/A'})}")

    print()


def cmd_export(args):
    """Export comprehensive HTML report with all mpop data"""
    from datetime import datetime

    output_file = None
    for arg in args:
        if arg.endswith(".html"):
            output_file = arg

    if not output_file:
        output_file = f"/tmp/mpop-report-{datetime.now().strftime('%Y%m%d-%H%M%S')}.html"

    print(f"  {C.BOLD}Generating comprehensive HTML report...{C.R}")

    vpn_ip = get_vpn_ip()
    my_name = get_my_name()
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    # 1. Collect server status
    print(f"  {C.DIM}[1/4] Collecting server metrics...{C.R}")
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in SERVERS.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    online = sum(1 for r in results.values() if r.get("ping"))
    total = len(SERVERS)

    # 2. Collect security data
    print(f"  {C.DIM}[2/4] Collecting security data...{C.R}")
    security_data = {}
    for name, ip in SERVERS.items():
        if not results.get(name, {}).get("vssh"):
            continue
        cmd = '''echo "FAIL24:$(journalctl -u sshd --since '24 hours ago' 2>/dev/null | grep -c 'Failed' || grep -c 'Failed' /var/log/auth.log 2>/dev/null || echo '0')"
echo "BANNED:$(sudo fail2ban-client status sshd 2>/dev/null | grep 'Currently banned' | awk '{print $NF}' || echo '0')"'''
        result = vssh_exec(ip, cmd, timeout=5)
        if result:
            parsed = {}
            for line in result.split('\n'):
                if line.startswith("FAIL24:"):
                    parsed["fail24"] = line[7:].strip()
                elif line.startswith("BANNED:"):
                    parsed["banned"] = line[7:].strip()
            security_data[name] = parsed

    # 3. Collect services data
    print(f"  {C.DIM}[3/4] Collecting services...{C.R}")
    services_data = {}
    for name, ip in SERVERS.items():
        if not results.get(name, {}).get("vssh"):
            continue
        cmd = r'''systemctl list-units --type=service --state=running 2>/dev/null | grep -E "^\s*\w" | awk '{print $1}' | grep -v "^$" | head -10 | tr '\n' ','
echo ""
docker ps --format '{{.Names}}' 2>/dev/null | head -5 | tr '\n' ',' || echo ""'''
        result = vssh_exec(ip, cmd, timeout=5)
        if result:
            lines = result.strip().split('\n')
            services_data[name] = {
                "systemd": lines[0] if lines else "",
                "docker": lines[1] if len(lines) > 1 else ""
            }

    # 4. VPN status
    print(f"  {C.DIM}[4/4] Checking VPN status...{C.R}")
    ssh_config = _get_server_ssh()
    vpn_servers = []
    for name, ip in SERVERS.items():
        if ssh_config.get(name, {}).get("public_ip"):
            public_ip = ssh_config[name]["public_ip"]
            vpn_servers.append({"name": name, "vpn_ip": ip, "public_ip": public_ip})

    # Count issues
    disk_warn = sum(1 for r in results.values() if int(str(r.get("stats", {}).get("disk", "0")).replace("%", "") or 0) > 70)
    disk_crit = sum(1 for r in results.values() if int(str(r.get("stats", {}).get("disk", "0")).replace("%", "") or 0) > 90)
    def _safe_int(v, default=0):
        try: return int(v)
        except (TypeError, ValueError): return default
    total_attacks = sum(_safe_int(security_data.get(n, {}).get("fail24", 0)) for n in security_data)
    total_banned = sum(_safe_int(security_data.get(n, {}).get("banned", 0)) for n in security_data)

    # Build HTML
    html = f'''<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>MeshPOP Report - {now}</title>
    <style>
        * {{ box-sizing: border-box; }}
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 40px; background: #1a1a2e; color: #eee; }}
        .container {{ max-width: 1200px; margin: 0 auto; }}
        h1 {{ color: #4CAF50; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }}
        h2 {{ color: #00bcd4; margin-top: 30px; }}
        .summary {{ display: flex; flex-wrap: wrap; gap: 15px; margin: 20px 0; }}
        .card {{ background: #16213e; padding: 20px 30px; border-radius: 10px; text-align: center; min-width: 150px; }}
        .card.ok {{ border-left: 4px solid #4CAF50; }}
        .card.warn {{ border-left: 4px solid #FF9800; }}
        .card.error {{ border-left: 4px solid #f44336; }}
        .card.info {{ border-left: 4px solid #2196F3; }}
        .card h3 {{ margin: 0; font-size: 28px; color: #fff; }}
        .card p {{ margin: 5px 0 0 0; color: #888; font-size: 13px; }}
        table {{ width: 100%; border-collapse: collapse; margin-top: 15px; background: #16213e; border-radius: 8px; overflow: hidden; }}
        th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #2a2a4a; }}
        th {{ background: #0f3460; color: #fff; font-weight: 500; }}
        tr:hover {{ background: #1a1a3e; }}
        .ok {{ color: #4CAF50; }}
        .warn {{ color: #FF9800; }}
        .error {{ color: #f44336; }}
        .info {{ color: #2196F3; }}
        .section {{ background: #16213e; padding: 20px; border-radius: 10px; margin-top: 20px; }}
        .two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
        @media (max-width: 800px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
        .footer {{ margin-top: 40px; text-align: center; color: #666; font-size: 12px; padding: 20px; }}
        .progress {{ background: #2a2a4a; border-radius: 10px; height: 8px; overflow: hidden; }}
        .progress-bar {{ height: 100%; border-radius: 10px; }}
        .tag {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin: 2px; background: #2a2a4a; }}
    </style>
</head>
<body>
<div class="container">
    <h1>🖥️ MeshPOP Infrastructure Report</h1>
    <p style="color:#888">Generated: {now} | Observer: {my_name} ({vpn_ip})</p>

    <div class="summary">
        <div class="card ok"><h3>{online}/{total}</h3><p>Servers Online</p></div>
        <div class="card {'error' if disk_crit else 'ok'}"><h3>{disk_crit}</h3><p>Disk Critical</p></div>
        <div class="card {'warn' if disk_warn else 'ok'}"><h3>{disk_warn}</h3><p>Disk Warning</p></div>
        <div class="card info"><h3>{total_attacks:,}</h3><p>SSH Attacks (24h)</p></div>
        <div class="card info"><h3>{total_banned}</h3><p>IPs Banned</p></div>
    </div>

    <h2>📊 Server Status</h2>
    <table>
        <tr><th>Node</th><th>VPN IP</th><th>Status</th><th>Load</th><th>Disk</th><th>Memory</th><th>Uptime</th><th>Connections</th></tr>
'''

    for name in SERVERS.keys():
        r = results.get(name, {})
        stats = r.get("stats") or {}
        ip = SERVERS[name]

        if r.get("me"):
            status = '<span class="ok">● LOCAL</span>'
        elif not r.get("ping"):
            status = '<span class="error">● OFFLINE</span>'
        else:
            status = '<span class="ok">● ONLINE</span>'

        load = str(stats.get("load", "-")).split(",")[0]
        disk = str(stats.get("disk", "-")).split()[0] if stats.get("disk") else "-"
        mem = str(stats.get("mem", "-")).split()[0] if stats.get("mem") else "-"
        uptime = str(stats.get("uptime", "-"))[:20]
        conn = str(stats.get("connections", "-")).split()[0] if stats.get("connections") else "-"

        disk_val = int(disk.replace("%", "")) if disk != "-" and "%" in disk else 0
        disk_class = "error" if disk_val > 90 else ("warn" if disk_val > 70 else "")
        disk_html = f'<span class="{disk_class}">{disk}</span>' if disk_class else disk

        html += f'        <tr><td><b>{name}</b></td><td>{ip}</td><td>{status}</td><td>{load}</td><td>{disk_html}</td><td>{mem}</td><td>{uptime}</td><td>{conn}</td></tr>\n'

    html += '''    </table>

    <div class="two-col">
'''

    # Security section
    html += '''        <div class="section">
            <h2 style="margin-top:0">🔒 Security (24h)</h2>
            <table>
                <tr><th>Server</th><th>Failed Logins</th><th>Banned IPs</th></tr>
'''
    for name in SERVERS.keys():
        sec = security_data.get(name, {})
        fail24 = sec.get("fail24", "-")
        banned = sec.get("banned", "-")
        fail_class = "error" if _safe_int(fail24) > 100 else ("warn" if _safe_int(fail24) > 20 else "")
        html += f'                <tr><td>{name}</td><td class="{fail_class}">{fail24}</td><td>{banned}</td></tr>\n'
    html += '''            </table>
        </div>
'''

    # Services section
    html += '''        <div class="section">
            <h2 style="margin-top:0">⚙️ Running Services</h2>
'''
    for name in SERVERS.keys():
        svc = services_data.get(name, {})
        systemd = svc.get("systemd", "").strip(",").split(",")[:5]
        docker = svc.get("docker", "").strip(",").split(",")[:3]
        if systemd or docker:
            html += f'            <p><b>{name}:</b> '
            for s in systemd:
                if s.strip():
                    html += f'<span class="tag">{s.strip()[:20]}</span>'
            for d in docker:
                if d.strip():
                    html += f'<span class="tag" style="background:#1565c0">🐳 {d.strip()}</span>'
            html += '</p>\n'
    html += '''        </div>
    </div>

    <h2>🌐 VPN Infrastructure</h2>
    <div class="section">
        <table>
            <tr><th>Server</th><th>VPN IP</th><th>Public IP</th><th>Role</th></tr>
'''
    # roles come from config: {"server_roles": {"v1": "Primary VPN", ...}}
    roles = _get_config("server_roles", {})
    for vs in vpn_servers:
        html += f'            <tr><td><b>{vs["name"]}</b></td><td>{vs["vpn_ip"]}</td><td>{vs["public_ip"]}</td><td>{roles.get(vs["name"], "")}</td></tr>\n'
    html += '''        </table>
    </div>
'''

    # Predictions section
    predictions_html = ""
    for name, r in results.items():
        if not r.get("ping") or r.get("me"):
            continue
        stats = r.get("stats", {})
        try:
            disk_pct = int(str(stats.get("disk", "0")).replace("%", ""))
            if disk_pct > 70:
                days = max(1, (100 - disk_pct) // 2)
                sev = "error" if days <= 3 else "warn"
                predictions_html += f'            <tr><td>{name}</td><td>Disk Full</td><td class="{sev}">~{days} days</td><td>Clean logs or expand storage</td></tr>\n'
        except Exception: pass

    if predictions_html:
        html += '''    <h2>📈 Predictions</h2>
    <div class="section">
        <table>
            <tr><th>Server</th><th>Issue</th><th>ETA</th><th>Recommended Action</th></tr>
'''
        html += predictions_html
        html += '''        </table>
    </div>
'''

    html += f'''
    <div class="footer">
        <p>MeshPOP v{VERSION} | Generated automatically | <a href="#" style="color:#4CAF50">Refresh</a></p>
    </div>
</div>
</body>
</html>
'''

    # Write file
    with open(output_file, 'w') as f:
        f.write(html)

    print(f"  {C.GREEN}✓ Report saved:{C.R} {output_file}")
    print(f"  {C.DIM}Open with: open {output_file}{C.R}\n")

    # Try to open in browser
    import subprocess
    try:
        subprocess.run(["open", output_file], check=False, capture_output=True)
    except Exception:
        pass


def cmd_history(args):
    """Show command history

    Usage:
        mpop history          # Last 20 commands
        mpop history 50       # Last 50 commands
        mpop history -f local    # Filter by source node (who ran)
        mpop history -t node1    # Filter by target server (where ran)
    """
    limit = 100  # Fetch more, filter later
    filter_from = None  # Source node
    filter_to = None    # Target server

    i = 0
    while i < len(args):
        if args[i] == "-f" and i + 1 < len(args):
            filter_from = args[i + 1]
            i += 2
        elif args[i] == "-t" and i + 1 < len(args):
            filter_to = args[i + 1]
            i += 2
        elif args[i].isdigit():
            limit = int(args[i])
            i += 1
        else:
            i += 1

    cmd = f"tail -n {limit} {CMDLOG_PATH} 2>/dev/null || echo ''"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)

    if not result or not result.strip():
        print("  (no command history yet)")
        print("  Commands are logged when using mpop exec, deploy, etc.")
        return

    lines = result.strip().split('\n')
    filtered = []

    for line in lines:
        parts = line.split('|')
        if len(parts) >= 4:
            node = parts[1]
            command = parts[2]
            # Filter by source node
            if filter_from and node != filter_from:
                continue
            # Filter by target (in command like "node1:uptime")
            if filter_to and not command.startswith(f"{filter_to}:"):
                continue
            filtered.append(parts)

    if not filtered:
        print(f"  (no matching history)")
        return

    # Show last 20 after filtering
    filtered = filtered[-20:]

    title = "Recent Commands"
    if filter_from:
        title += f" (from {filter_from})"
    if filter_to:
        title += f" (on {filter_to})"

    print(f"\n  {title}:")
    print("  " + "-" * 70)
    print(f"  {'Time':<20} {'From':<6} {'Command':<30} {'Result':<10}")
    print("  " + "-" * 70)

    for parts in filtered:
        time_str = parts[0][:19]
        node = parts[1][:6]
        command = parts[2][:28]
        res = parts[3][:8]
        print(f"  {time_str:<20} {node:<6} {command:<30} {res:<10}")

    print()

# ==================== Backup Command ====================

BACKUP_PATH = "/var/lib/mpop/backups"

def cmd_backup(args):
    """Backup and restore configurations"""
    if not args:
        print("""
  mpop backup - Configuration Backup & Restore

  COMMANDS:
    mpop backup create [name]     Create backup of current node
    mpop backup create-all        Create backup of all nodes
    mpop backup list              List available backups
    mpop backup restore <name>    Restore from backup
    mpop backup download <name>   Download backup to local

  WHAT'S BACKED UP:
    • /etc/wireguard/*.conf
    • /etc/systemd/system/mpop*.service
    • /var/lib/mpop/*.json
    • ~/.ssh/authorized_keys
    • Crontabs

  EXAMPLES:
    mpop backup create            # Backup current node
    mpop backup create-all        # Backup all nodes
    mpop backup list              # Show backups
    mpop backup restore node1-2026  # Restore specific backup
""")
        return

    subcmd = args[0]

    if subcmd == "create":
        # Backup current node or specified node
        target = args[1] if len(args) > 1 else None
        nodes = [target] if target and target in SERVERS else [get_my_name()]

        if target and target not in SERVERS and not target.startswith("-"):
            # It's a backup name
            backup_name = target
            nodes = [get_my_name()]
        else:
            backup_name = None

        for node in nodes:
            if node == "local" or node == get_my_name():
                # Local backup
                _create_local_backup(backup_name)
            else:
                # Remote backup
                _create_remote_backup(node, backup_name)

    elif subcmd == "create-all":
        print("  Creating backups for all nodes...")
        now = datetime.now().strftime("%Y%m%d-%H%M")

        for name, ip in SERVERS.items():
            if name == get_my_name():
                continue
            print(f"  {name}... ", end="", flush=True)
            backup_name = f"{name}-{now}"
            result = _create_remote_backup(name, backup_name, quiet=True)
            print("OK" if result else "FAIL")

    elif subcmd == "list":
        cmd = f"ls -la {BACKUP_PATH}/*.tar.gz 2>/dev/null | tail -20"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=10)

        if not result or "No such file" in result:
            print("  (no backups found)")
            return

        print("\n  Available Backups:")
        print("  " + "-" * 60)
        for line in result.strip().split('\n'):
            parts = line.split()
            if len(parts) >= 9:
                size = parts[4]
                date = f"{parts[5]} {parts[6]} {parts[7]}"
                name = parts[8].split('/')[-1].replace('.tar.gz', '')
                print(f"    {name:<30} {size:>10} bytes  {date}")
        print()

    elif subcmd == "restore":
        if len(args) < 2:
            print("  Usage: mpop backup restore <backup-name>")
            return

        backup_name = args[1]
        if not backup_name.endswith('.tar.gz'):
            backup_name += '.tar.gz'

        print(f"  ⚠️  This will restore: {backup_name}")
        confirm = input("  Are you sure? [y/N]: ").strip().lower()

        if confirm != 'y':
            print("  Cancelled.")
            return

        # Download and extract
        print("  Downloading backup...")
        cmd = f"cat {BACKUP_PATH}/{backup_name} | base64"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=30)

        if not result or "No such file" in result:
            print(f"  ❌ Backup not found: {backup_name}")
            return

        # Save locally and extract
        import tempfile
        with tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False) as f:
            import base64
            f.write(base64.b64decode(result.strip()))
            temp_path = f.name

        print(f"  Extracting to /tmp/mpop-restore/...")
        subprocess.run(f"mkdir -p /tmp/mpop-restore && tar -xzf {temp_path} -C /tmp/mpop-restore", shell=True)
        print(f"  ✓ Backup extracted to /tmp/mpop-restore/")
        print(f"    Review files and copy manually to restore.")

    elif subcmd == "download":
        if len(args) < 2:
            print("  Usage: mpop backup download <backup-name>")
            return

        backup_name = args[1]
        if not backup_name.endswith('.tar.gz'):
            backup_name += '.tar.gz'

        local_path = f"./{backup_name}"

        print(f"  Downloading {backup_name}...")
        cmd = f"cat {BACKUP_PATH}/{backup_name} 2>/dev/null | base64"
        result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=60)

        if not result or "No such file" in result:
            print(f"  ❌ Backup not found")
            return

        import base64
        with open(local_path, 'wb') as f:
            f.write(base64.b64decode(result.strip()))

        print(f"  ✓ Downloaded to {local_path}")

    else:
        print(f"  Unknown subcommand: {subcmd}")

def _create_local_backup(name=None):
    """Create backup of local node"""
    node = get_my_name()
    if not name:
        name = f"{node}-{datetime.now().strftime('%Y%m%d-%H%M')}"

    print(f"  Creating backup: {name}")

    # Files to backup
    files = [
        "/etc/wireguard/*.conf",
        "/etc/systemd/system/mpop*.service",
        "/var/lib/mpop/*.json",
        "/var/lib/mpop/*.enc",
        "~/.ssh/authorized_keys",
    ]

    # Create tar
    tar_cmd = f"tar -czf /tmp/{name}.tar.gz {' '.join(files)} 2>/dev/null"
    subprocess.run(tar_cmd, shell=True)

    # Upload to relay1
    upload_cmd = f"cat /tmp/{name}.tar.gz | base64"
    result = subprocess.run(upload_cmd, shell=True, capture_output=True, text=True)

    if result.stdout:
        save_cmd = f"mkdir -p {BACKUP_PATH} && echo '{result.stdout.strip()}' | base64 -d > {BACKUP_PATH}/{name}.tar.gz && echo OK"
        _relay_ip = SERVERS.get(SECRETS_MASTERS[0], SECRETS_MASTERS[0]) if SECRETS_MASTERS else None
        save_result = vssh_exec(_relay_ip, save_cmd, timeout=30) if _relay_ip else None
        if save_result and "OK" in save_result:
            print(f"  ✓ Backup saved: {name}")
            subprocess.run(f"rm /tmp/{name}.tar.gz", shell=True)
            return True

    print(f"  ❌ Backup failed (relay: {SECRETS_MASTERS[0] if SECRETS_MASTERS else 'none'})")
    return False

def _create_remote_backup(node, name=None, quiet=False):
    """Create backup of remote node"""
    if not name:
        name = f"{node}-{datetime.now().strftime('%Y%m%d-%H%M')}"

    ip = SERVERS.get(node)
    if not ip:
        if not quiet:
            print(f"  ❌ Unknown node: {node}")
        return False

    # Files to backup
    backup_script = '''
    tar -czf /tmp/mpop-backup.tar.gz \
        /etc/wireguard/*.conf \
        /etc/systemd/system/mpop*.service \
        /var/lib/mpop/*.json \
        /var/lib/mpop/*.enc \
        ~/.ssh/authorized_keys \
        2>/dev/null
    cat /tmp/mpop-backup.tar.gz | base64
    rm /tmp/mpop-backup.tar.gz
    '''

    result = vssh_exec(ip, backup_script.strip(), timeout=30)

    if result and len(result) > 100:
        # Save to relay1
        save_cmd = f"mkdir -p {BACKUP_PATH} && echo '{result.strip()}' | base64 -d > {BACKUP_PATH}/{name}.tar.gz && echo OK"
        _relay_ip = SERVERS.get(SECRETS_MASTERS[0], SECRETS_MASTERS[0]) if SECRETS_MASTERS else None
        save_result = vssh_exec(_relay_ip, save_cmd, timeout=30) if _relay_ip else None
        if save_result and "OK" in save_result:
            if not quiet:
                print(f"  ✓ Backup saved: {name}")
            return True

    if not quiet:
        err = (result or "no response")[:200]
        print(f"  ❌ Backup failed for {node}: {err}")
    return False

# ==================== Deploy Command ====================

def cmd_deploy(args):
    """Deploy files or scripts to servers"""
    if not args:
        print("""
  mpop deploy - Deploy files to servers

  COMMANDS:
    mpop deploy <file> [nodes...]       Deploy file to /tmp on nodes
    mpop deploy <file> --dest=<path>    Deploy to specific path
    mpop deploy <file> --all            Deploy to all nodes
    mpop deploy --script <file>         Deploy and execute script
    mpop deploy mpop                    Deploy mpop to ALL servers
    mpop deploy mpop v1 node1 node2           Deploy mpop to specific servers

  OPTIONS:
    --all           Deploy to all servers
    --from-github   Each server fetches single mpop.py from GitHub
    --from-pypi     Each server runs: pip install meshpop --upgrade
    --from-git      Each server runs: pip install git+https://.../mpop.git (full package)
    --dest=<path>   Destination path (default: /tmp/)
    --script        Execute file after deployment
    --sudo          Use sudo for destination

  EXAMPLES:
    mpop deploy fix.sh node1 node2     # Deploy fix.sh to node1, node2
    mpop deploy config.json --all       # Deploy to all servers
    mpop deploy --script setup.sh node1 # Deploy and run setup.sh
    mpop deploy mpop                    # Update mpop on all servers (local file)
    mpop deploy mpop --from-github      # Each server fetches from GitHub
    mpop deploy mpop --from-pypi        # Each server: pip install meshpop --upgrade
""")
        return

    # Parse arguments
    file_path = None
    nodes = []
    dest_path = "/tmp/"
    run_script = False
    use_sudo = False
    deploy_all = False
    from_github = False
    from_pypi = False
    from_git = False

    for arg in args:
        if arg == "--all":
            deploy_all = True
        elif arg == "--from-github":
            from_github = True
        elif arg == "--from-pypi":
            from_pypi = True
        elif arg == "--from-git":
            from_git = True
        elif arg == "--script":
            run_script = True
        elif arg == "--sudo":
            use_sudo = True
        elif arg.startswith("--dest="):
            dest_path = arg.split("=", 1)[1]
        elif arg in SERVERS:
            nodes.append(arg)
        elif not file_path:
            file_path = arg

    if not file_path:
        print("  ❌ No file specified")
        return

    # Special case: deploy mpop itself
    if file_path == "mpop":
        target_nodes = nodes if nodes else None
        _deploy_mpop(target_nodes, from_github=from_github, from_pypi=from_pypi, from_git=from_git)
        return

    # Expand file path
    file_path = os.path.expanduser(file_path)
    if not os.path.exists(file_path):
        print(f"  ❌ File not found: {file_path}")
        return

    filename = os.path.basename(file_path)

    # Determine target nodes
    if deploy_all:
        nodes = [n for n in SERVERS.keys() if n != get_my_name()]
    elif not nodes:
        print("  ❌ No target nodes specified. Use --all or list nodes.")
        return

    print(f"  Deploying {filename} to {len(nodes)} node(s)...")

    # Read file content
    with open(file_path, 'rb') as f:
        content = f.read()

    content_b64 = base64.b64encode(content).decode()

    # Deploy to each node
    for node in nodes:
        ip = SERVERS.get(node)
        if not ip:
            print(f"  {node}: ❌ Unknown node")
            continue

        dest = dest_path if dest_path.endswith('/') else dest_path
        full_dest = f"{dest}{filename}" if dest.endswith('/') else dest

        # Build command
        if use_sudo:
            deploy_cmd = f"echo '{content_b64}' | base64 -d | sudo tee {full_dest} > /dev/null && sudo chmod +x {full_dest} && echo OK"
        else:
            deploy_cmd = f"echo '{content_b64}' | base64 -d > {full_dest} && chmod +x {full_dest} && echo OK"

        result = vssh_exec(ip, deploy_cmd, timeout=30)

        if result and "OK" in result:
            status = "✓"
            if run_script:
                # Execute the script
                exec_cmd = f"sudo {full_dest}" if use_sudo else full_dest
                exec_result = vssh_exec(ip, exec_cmd, timeout=60)
                status = "✓ (executed)" if exec_result else "✓ (exec failed)"
        else:
            status = "❌"

        print(f"  {node}: {status}")

def vssh_put(ip, local_path_or_content, remote_path, timeout=60):
    """Upload file via vssh PUT protocol. local_path_or_content: file path (str) or content (bytes)"""
    if isinstance(local_path_or_content, bytes):
        content = local_path_or_content
    elif os.path.exists(local_path_or_content):
        with open(local_path_or_content, 'rb') as f:
            content = f.read()
    else:
        return False, "File not found"

    size = len(content)
    md5 = hashlib.md5(content).hexdigest()

    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        sock.connect((ip, VSSH_PORT))

        # Send PUT request
        sock.sendall(f"PUT:{VSSH_SECRET}:{remote_path}:{size}:{md5}\n".encode())

        # Get response
        resp = sock.recv(64).decode().strip()
        if resp == 'SKIP':
            sock.close()
            return True, "skip"  # File already exists with same hash
        if resp != 'OK':
            sock.close()
            return False, resp

        # Send file data in chunks
        chunk_size = 1024 * 1024  # 1MB chunks
        sent = 0
        while sent < size:
            end = min(sent + chunk_size, size)
            sock.sendall(content[sent:end])
            sent = end

        # Get final response
        resp = sock.recv(64).decode().strip()
        sock.close()

        if resp == 'OK':
            return True, "ok"
        return False, resp

    except Exception as e:
        return False, str(e)


def _deploy_mpop(target_nodes=None, from_github=False, from_pypi=False, from_git=False):
    """Deploy mpop CLI to servers. from_github: curl single file. from_pypi: pip install meshpop. from_git: pip install from GitHub."""
    my_name = get_my_name()
    servers = get_servers()

    # GitHub URL: config or default (meshpop/mpop main branch)
    update_url = _get_config("mpop_update_url", "") or os.environ.get("MPOP_UPDATE_URL", "")
    if from_github and not update_url:
        _default = "https://raw.githubusercontent.com/meshpop/mpop/main/mpop_repo/mpop.py"
        print(f"  ℹ️  Set mpop_update_url in config for your repo, or use default:")
        print(f"     mpop config set mpop_update_url {_default}")
        update_url = _default

    # Determine target nodes
    if target_nodes:
        nodes_to_deploy = {n: servers.get(n) for n in target_nodes if n in servers and n != my_name}
    else:
        nodes_to_deploy = {n: ip for n, ip in servers.items() if n != my_name}

    if not nodes_to_deploy:
        print("  ❌ No valid target nodes")
        return

    git_url = "git+https://github.com/meshpop/mpop.git"
    if from_git:
        # pip install from GitHub — full package (mpop, agent_reporter, dashboard_server)
        print(f"""
  ═══════════════════════════════════════════════════════════
   📦 Deploy mpop from GitHub (pip install)
   URL:    {git_url}
   Targets: {', '.join(nodes_to_deploy.keys())}
   (pip upgrade + install)
  ═══════════════════════════════════════════════════════════
""")
        success_count = 0
        success_nodes = []
        pip_up = "pip3 install --upgrade pip -q 2>/dev/null || pip install --upgrade pip -q 2>/dev/null"
        pip_install = f'pip3 install "{git_url}" -q 2>/dev/null || pip install "{git_url}" -q 2>/dev/null'
        pip_alt = f"{pip_install} --break-system-packages 2>/dev/null"
        pip_user = f'{pip_install} --user 2>/dev/null'
        cmd = f"{pip_up}; {pip_alt} || {pip_user} || {pip_install}"
        for node, ip in nodes_to_deploy.items():
            print(f"  {node}... ", end="", flush=True)
            result = vssh_exec(ip, cmd, timeout=180)
            v_result = vssh_exec(ip, "mpop --version 2>/dev/null | head -1", timeout=10)
            if v_result and "mpop" in v_result.lower():
                print(f"✅")
                success_count += 1
                success_nodes.append(node)
            else:
                print(f"❌")
        print(f"\n  Done! ✅ {success_count} | ❌ {len(nodes_to_deploy) - success_count}")
        # Auto-install server-agent on each deployed server (so reports flow)
        if success_nodes:
            print(f"\n  📡 Installing server-agent on {len(success_nodes)} node(s)...")
            for node in success_nodes:
                _setup_agent([node, "-x"])
        return

    if from_pypi:
        # Each server: pip install meshpop --upgrade (PyPI)
        print(f"""
  ═══════════════════════════════════════════════════════════
   📦 Deploy mpop from PyPI (pip install meshpop --upgrade)
   Targets: {', '.join(nodes_to_deploy.keys())}
  ═══════════════════════════════════════════════════════════
""")
        success_count = 0
        success_nodes = []
        pip_cmd = "pip3 install meshpop --upgrade -q 2>/dev/null || pip install meshpop --upgrade -q 2>/dev/null"
        pip_cmd_alt = f"{pip_cmd} --break-system-packages 2>/dev/null"  # newer Python on Linux
        for node, ip in nodes_to_deploy.items():
            print(f"  {node}... ", end="", flush=True)
            cmd = f"{pip_cmd_alt} || {pip_cmd} --user || {pip_cmd}"
            result = vssh_exec(ip, cmd, timeout=120)
            if result is not None:
                v_result = vssh_exec(ip, "mpop --version 2>/dev/null | head -1", timeout=5)
                if v_result and "mpop" in v_result.lower():
                    print(f"✅")
                    success_count += 1
                    success_nodes.append(node)
                    continue
            print(f"❌")
        print(f"\n  Done! ✅ {success_count} | ❌ {len(nodes_to_deploy) - success_count}")
        if success_nodes:
            print(f"\n  📡 Installing server-agent on {len(success_nodes)} node(s)...")
            for node in success_nodes:
                _setup_agent([node, "-x"])
        return

    if from_github and update_url:
        # Each server fetches from GitHub - no local path, no path issues
        print(f"""
  ═══════════════════════════════════════════════════════════
   📦 Deploy mpop from GitHub
   URL:    {update_url}
   Targets: {', '.join(nodes_to_deploy.keys())}
  ═══════════════════════════════════════════════════════════
""")
        success_count = 0
        for node, ip in nodes_to_deploy.items():
            print(f"  {node}... ", end="", flush=True)
            cmd = f"curl -fsSL '{update_url}' -o /tmp/mpop_new 2>/dev/null && chmod +x /tmp/mpop_new && sudo mv /tmp/mpop_new /usr/local/bin/mpop && mpop --version 2>/dev/null | head -1"
            result = vssh_exec(ip, cmd, timeout=60)
            if result and ("mpop v" in result or "mpop" in result.lower()):
                print(f"✅")
                success_count += 1
            else:
                # Fallback: try without sudo (user install)
                cmd2 = f"mkdir -p ~/.local/bin; curl -fsSL '{update_url}' -o /tmp/mpop_new && chmod +x /tmp/mpop_new && mv /tmp/mpop_new ~/.local/bin/mpop && ~/.local/bin/mpop --version"
                result2 = vssh_exec(ip, cmd2, timeout=60)
                if result2 and "mpop" in result2.lower():
                    print(f"✅ (~/.local/bin)")
                    success_count += 1
                else:
                    print(f"❌")
        print(f"\n  Done! ✅ {success_count} | ❌ {len(nodes_to_deploy) - success_count}")
        return

    # Get local mpop path - dev paths first, then standard install
    _candidates = [
        os.path.expanduser("~/MeshPOP/mpop_repo/mpop.py"),
        os.path.expanduser("~/MeshPOP/mpop/mpop"),
        os.path.realpath(sys.argv[0]) if sys.argv and sys.argv[0] else "",
        "/usr/local/bin/mpop",
    ]
    local_mpop = None
    for p in _candidates:
        if p and os.path.exists(p) and os.path.isfile(p):
            local_mpop = p
            break
    if not local_mpop:
        print("  ❌ Local mpop not found (tried: MeshPOP/mpop_repo, mpop/mpop, argv[0], /usr/local/bin/mpop)")
        return

    # Get version
    with open(local_mpop, 'r') as f:
        for line in f:
            if line.startswith('VERSION'):
                version = line.split('=')[1].strip().strip('"').strip("'")
                break
        else:
            version = "unknown"

    size = os.path.getsize(local_mpop)

    print(f"""
  ═══════════════════════════════════════════════════════════
   📦 Deploy mpop v{version} ({size:,} bytes)
   Targets: {', '.join(nodes_to_deploy.keys())}
   Methods: vssh PUT → vssh exec → SSH/SCP
  ═══════════════════════════════════════════════════════════
""")

    success_count = 0
    fail_count = 0

    for node, ip in nodes_to_deploy.items():
        print(f"  {node}... ", end="", flush=True)

        deployed = False
        method = ""

        # Method 1: vssh PUT (best for large files)
        if not deployed:
            try:
                ok, msg = vssh_put(ip, local_mpop, "/usr/local/bin/mpop", timeout=30)
                if ok:
                    deployed = True
                    method = "vssh PUT"
            except Exception:
                pass

        # Method 2: vssh exec with base64 (for small files or when PUT fails)
        if not deployed:
            try:
                with open(local_mpop, 'rb') as f:
                    content_b64 = base64.b64encode(f.read()).decode()
                # Split into chunks if too large
                if len(content_b64) < 100000:  # ~75KB limit for command line
                    cmd = f"echo '{content_b64}' | base64 -d > /tmp/mpop_new && chmod +x /tmp/mpop_new && mv /tmp/mpop_new /usr/local/bin/mpop && echo OK"
                    result = vssh_exec(ip, cmd, timeout=60)
                    if result and "OK" in result:
                        deployed = True
                        method = "vssh exec"
            except Exception:
                pass

        # Method 3: HTTP download (if dashboard is serving)
        if not deployed and SERVER_AGENT_URL:
            try:
                update_url = f"{SERVER_AGENT_URL}/mpop"
                cmd = f"curl -fsSL '{update_url}' -o /tmp/mpop_new 2>/dev/null && chmod +x /tmp/mpop_new && mv /tmp/mpop_new /usr/local/bin/mpop && echo OK"
                result = vssh_exec(ip, cmd, timeout=30)
                if result and "OK" in result:
                    deployed = True
                    method = "HTTP"
            except Exception:
                pass

        # Method 4: SCP via VPN (fallback)
        if not deployed:
            try:
                # Determine SSH user and port for this node
                ssh_info = SERVER_SSH.get(node, {})
                user = ssh_info.get("user", "root" if node.startswith("v") else os.environ.get("MPOP_USER", "user"))
                port = ssh_info.get("ssh_port", 51830)

                # Try SCP
                import subprocess
                scp_cmd = f"scp -P {port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no {local_mpop} {user}@{ip}:/tmp/mpop_new"
                scp_result = subprocess.run(scp_cmd, shell=True, capture_output=True, timeout=30)

                if scp_result.returncode == 0:
                    # Move to /usr/local/bin with sudo
                    mv_cmd = f"ssh -p {port} -o ConnectTimeout=5 {user}@{ip} 'sudo mv /tmp/mpop_new /usr/local/bin/mpop && sudo chmod +x /usr/local/bin/mpop'"
                    mv_result = subprocess.run(mv_cmd, shell=True, capture_output=True, timeout=15)
                    if mv_result.returncode == 0:
                        deployed = True
                        method = "SCP"
            except Exception:
                pass

        if deployed:
            print(f"✅ ({method})")
            success_count += 1
        else:
            print(f"❌")
            fail_count += 1

    print()
    print(f"  Done! ✅ {success_count} | ❌ {fail_count}")


# ==================== Self-Update Command ====================

# Update server derived from dashboard_url config
def _get_update_server():
    url = _get_config("dashboard_url", "").strip()
    if url:
        host = url.split("//")[-1].split(":")[0].split("/")[0]
        return host
    return ""  # no update server — self-update disabled

MPOP_UPDATE_SERVER = _get_update_server()
MPOP_UPDATE_PORT = 8721
MPOP_UPDATE_PATH = "/mpop"

def cmd_self_update(args):
    """Self-update mpop from central server or GitHub"""
    import urllib.request
    import urllib.error
    import tempfile
    import shutil
    import hashlib

    force = "--force" in args
    check_only = "--check" in args
    from_github = "--from-github" in args
    from_pypi = "--from-pypi" in args

    # PyPI: pip install meshpop --upgrade
    if from_pypi:
        print(f"""
  ═══════════════════════════════════════════════════════════
   🔄 mpop Self-Update (from PyPI)
   Current: v{VERSION}
   Command: pip install meshpop --upgrade
  ═══════════════════════════════════════════════════════════
""")
        try:
            r = subprocess.run(
                "pip3 install meshpop --upgrade -q 2>/dev/null || pip install meshpop --upgrade -q",
                shell=True, capture_output=True, text=True, timeout=120
            )
            if r.returncode != 0:
                r = subprocess.run(
                    "pip3 install meshpop --upgrade -q --break-system-packages 2>/dev/null || pip install meshpop --upgrade -q --user",
                    shell=True, capture_output=True, text=True, timeout=120
                )
            if r.returncode == 0:
                ver = subprocess.run("mpop --version", shell=True, capture_output=True, text=True, timeout=5)
                print(f"  ✅ Updated: {ver.stdout.strip() if ver.stdout else 'mpop'}")
            else:
                print(f"  ❌ pip failed. Try: pip install meshpop --upgrade")
        except Exception as e:
            print(f"  ❌ {e}")
        return

    # GitHub: each machine fetches directly - no path issues
    if from_github:
        update_url = _get_config("mpop_update_url", "") or os.environ.get("MPOP_UPDATE_URL", "")
        if not update_url:
            update_url = "https://raw.githubusercontent.com/meshpop/mpop/main/mpop_repo/mpop.py"
        print(f"""
  ═══════════════════════════════════════════════════════════
   🔄 mpop Self-Update (from GitHub)
   Current: v{VERSION}
   URL:     {update_url}
  ═══════════════════════════════════════════════════════════
""")
        try:
            with urllib.request.urlopen(update_url, timeout=30) as resp:
                new_content = resp.read()
        except Exception as e:
            print(f"  ❌ Error: {e}")
            return
        if not new_content.startswith(b'#!/usr/bin/env python3'):
            print("  ❌ Invalid file (no shebang)")
            return
        mpop_path = os.path.realpath(sys.argv[0]) if sys.argv and sys.argv[0] else "/usr/local/bin/mpop"
        if not os.path.exists(mpop_path):
            mpop_path = "/usr/local/bin/mpop"
        try:
            fd, tmp = tempfile.mkstemp(prefix='mpop_', suffix='.new')
            with os.fdopen(fd, 'wb') as f:
                f.write(new_content)
            os.chmod(tmp, 0o755)
            if os.path.exists(mpop_path):
                shutil.copy2(mpop_path, f"{mpop_path}.bak")
            shutil.move(tmp, mpop_path)
            print("  ✅ Updated. Run: mpop --version")
        except PermissionError:
            print("  ❌ Permission denied. Try: sudo mpop self-update --from-github")
        except Exception as e:
            print(f"  ❌ {e}")
        return

    print(f"""
  ═══════════════════════════════════════════════════════════
   🔄 mpop Self-Update
   Current: v{VERSION}
   Server:  {MPOP_UPDATE_SERVER}:{MPOP_UPDATE_PORT}
  ═══════════════════════════════════════════════════════════
""")

    # Check VPN connectivity
    vpn_ip = get_vpn_ip()
    if not vpn_ip or not vpn_ip.startswith(_get_vpn_prefix()):
        print(f"  ❌ Error: Must be connected to VPN ({_get_vpn_prefix()}x.x)")
        return

    # Fetch latest version
    print("  [1/2] Downloading latest version...", end=" ", flush=True)

    try:
        url = f"http://{MPOP_UPDATE_SERVER}:{MPOP_UPDATE_PORT}{MPOP_UPDATE_PATH}"
        with urllib.request.urlopen(url, timeout=60) as resp:
            remote_version = resp.headers.get('X-Mpop-Version', 'unknown')
            new_content = resp.read()
            remote_size = len(new_content)
    except urllib.error.HTTPError as e:
        print(f"❌")
        print(f"  Error: HTTP {e.code} - {e.reason}")
        return
    except Exception as e:
        print(f"❌")
        print(f"  Error: {e}")
        return

    print(f"✅ v{remote_version} ({remote_size:,} bytes)")

    # Compare versions
    if remote_version == VERSION and not force:
        print(f"\n  ✅ Already up to date (v{VERSION})")
        return

    if check_only:
        if remote_version != VERSION:
            print(f"\n  📦 Update available: v{VERSION} → v{remote_version}")
        return

    # Verify it's valid Python
    print("  [2/2] Verifying and installing...", end=" ", flush=True)

    try:
        # Check shebang
        if not new_content.startswith(b'#!/usr/bin/env python3'):
            print("❌")
            print("  Error: Invalid file (no shebang)")
            return

        # Try to compile
        compile(new_content, '<mpop>', 'exec')
    except SyntaxError as e:
        print("❌")
        print(f"  Error: Syntax error in downloaded file: {e}")
        return

    # Atomic replace

    mpop_path = os.path.realpath(sys.argv[0])
    if not mpop_path or not os.path.exists(mpop_path):
        mpop_path = "/usr/local/bin/mpop"

    try:
        # Write to temp file
        fd, tmp_path = tempfile.mkstemp(prefix='mpop_', suffix='.new')
        with os.fdopen(fd, 'wb') as f:
            f.write(new_content)

        # Set permissions
        os.chmod(tmp_path, 0o755)

        # Backup old version
        backup_path = f"{mpop_path}.bak"
        if os.path.exists(mpop_path):
            shutil.copy2(mpop_path, backup_path)

        # Atomic replace
        shutil.move(tmp_path, mpop_path)

        print("✅")
        print(f"\n  🎉 Updated: v{VERSION} → v{remote_version}")
        print(f"  📁 Backup:  {backup_path}")

        # Verify new version
        result = subprocess.run([mpop_path, '--version'], capture_output=True, text=True, timeout=5)
        if result.returncode == 0:
            print(f"  ✓ Verified: {result.stdout.strip()}")

    except PermissionError:
        print("❌")
        print(f"  Error: Permission denied. Try: sudo mpop self-update")
        # Clean up temp file
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
    except Exception as e:
        print(f"❌")
        print(f"  Error: {e}")
        if 'tmp_path' in locals() and os.path.exists(tmp_path):
            os.unlink(tmp_path)


def cmd_update_server(args):
    """Update mpop on v1 (bootstrap server) via chunked upload"""
    import hashlib

    print("""
  ═══════════════════════════════════════════════════════════
   📦 Update mpop on Bootstrap Server (v1)
   Method: Chunked base64 upload via vssh
  ═══════════════════════════════════════════════════════════
""")

    mpop_path = os.path.expanduser("~/MeshPOP/mpop/mpop")
    if not os.path.exists(mpop_path):
        print(f"  ❌ Source not found: {mpop_path}")
        return

    with open(mpop_path, 'rb') as f:
        content = f.read()

    total_size = len(content)
    md5_hash = hashlib.md5(content).hexdigest()

    print(f"  Source:     {mpop_path}")
    print(f"  Size:       {total_size:,} bytes")
    print(f"  MD5:        {md5_hash}")
    print()

    # Get relay1 IP
    servers = get_servers()
    v1_ip = servers.get('relay1')
    if not v1_ip:
        print("  ❌ Cannot find relay1 IP")
        return

    # Upload in chunks
    chunk_size = 1800  # ~2KB base64 = ~1.5KB binary (safe for vssh)
    total_chunks = (total_size + chunk_size - 1) // chunk_size
    dest_path = "/tmp/mpop_upload"

    print(f"  Uploading in {total_chunks} chunks...")

    # Clear destination file
    result = vssh_exec(v1_ip, f"rm -f {dest_path} && touch {dest_path} && echo OK", timeout=5)
    if not result or "OK" not in result:
        print("  ❌ Failed to prepare destination")
        return

    for i in range(total_chunks):
        start = i * chunk_size
        end = min(start + chunk_size, total_size)
        chunk = content[start:end]
        chunk_b64 = base64.b64encode(chunk).decode()

        # Append chunk
        cmd = f"echo '{chunk_b64}' | base64 -d >> {dest_path}"
        result = vssh_exec(v1_ip, cmd, timeout=10)

        progress = (i + 1) * 100 // total_chunks
        print(f"\r  [{i+1}/{total_chunks}] {progress}%", end="", flush=True)

        if result is None:
            print(f"\n  ❌ Failed at chunk {i+1}")
            return

    print("\n")

    # Verify upload
    print("  Verifying upload...", end=" ", flush=True)
    result = vssh_exec(v1_ip, f"md5sum {dest_path} | cut -d' ' -f1", timeout=10)
    if result and result.strip() == md5_hash:
        print("✅")
    else:
        print(f"❌ (got {result.strip() if result else 'None'})")
        return

    # Install
    print("  Installing...", end=" ", flush=True)
    result = vssh_exec(v1_ip, f"mv {dest_path} /usr/local/bin/mpop && chmod +x /usr/local/bin/mpop && mpop --version", timeout=10)
    if result:
        print(f"✅")
        print(f"\n  🎉 relay1 updated: {result.strip()}")
    else:
        print("❌")


# ==================== Setup Command ====================

# vssh.py source path
VSSH_SOURCE = os.path.expanduser("~/MeshPOP/safeguard-vpn/packages/vssh/vssh.py")

def _get_vssh_source() -> str:
    """Get vssh.py content - works in dev and after pip install."""
    # 1. Dev path
    if os.path.exists(VSSH_SOURCE):
        with open(VSSH_SOURCE, 'r') as f:
            return f.read()
    # 2. Installed via pip: find vssh module file
    try:
        import importlib.util as _ilu
        spec = _ilu.find_spec("vssh")
        if spec and spec.origin:
            with open(spec.origin, 'r') as f:
                return f.read()
    except Exception:
        pass
    # 3. site-packages fallback
    try:
        import site as _site
        for sp in _site.getsitepackages():
            candidate = os.path.join(sp, "vssh.py")
            if os.path.exists(candidate):
                with open(candidate, 'r') as f:
                    return f.read()
    except Exception:
        pass
    raise FileNotFoundError(
        "vssh.py not found. Install vssh: pip install vssh\n"
        "Or set VSSH_SOURCE to the path of vssh.py"
    )



def _find_python_and_servers():
    """
    Find Python exe + all 6 MeshPOP MCP server modules.
    Returns (python_exe, {server_name: {"command": ..., "args": [...]}})
    Prefers  'python -m module_name'  (reliable for pip installs).
    Falls back to absolute .py file path if module not importable.
    """
    import sys, importlib.util, sysconfig

    python_exe = sys.executable  # always absolute

    # Modules to try importing directly (pip-installed)
    server_modules = {
        "mpop":     "mpop_mcp_server",
        "wire":     "wire_mcp_server",
        "vssh":     "vssh_mcp_server",
        "meshdb":   "meshdb_mcp_server",
        "vault":    "vault_mcp_server",
        "meshclaw": "meshclaw_mcp_server",
    }

    # File names to search for (source / editable installs)
    server_files = {
        "mpop":     ["mpop_mcp_server.py",     "mpop-mcp-server.py"],
        "wire":     ["wire_mcp_server.py",      "wire-mcp-server.py"],
        "vssh":     ["vssh_mcp_server.py",      "vssh-mcp-server.py"],
        "meshdb":   ["meshdb_mcp_server.py",    "meshdb-mcp-server.py"],
        "vault":    ["vault_mcp_server.py",     "vault-mcp-server.py"],
        "meshclaw": ["meshclaw_mcp_server.py",  "meshclaw-mcp-server.py"],
    }

    # Search dirs for file fallback
    search_dirs = []
    search_dirs.append(os.path.dirname(os.path.abspath(__file__)))
    for pkg in list(server_modules.values()):
        spec = importlib.util.find_spec(pkg)
        if spec and spec.origin:
            d = os.path.dirname(spec.origin)
            if d not in search_dirs:
                search_dirs.append(d)
    try:
        scripts_dir = sysconfig.get_path("scripts")
        if scripts_dir and scripts_dir not in search_dirs:
            search_dirs.append(scripts_dir)
    except Exception:
        pass
    # Also add common relative locations
    base = os.path.dirname(os.path.abspath(__file__))
    for rel in [".", "..", "../wire", "../vssh", "../meshdb", "../vault", "../meshclaw"]:
        d = os.path.normpath(os.path.join(base, rel))
        if os.path.isdir(d) and d not in search_dirs:
            search_dirs.append(d)

    found = {}
    for name in server_modules:
        # 1) Prefer module import (works for any pip install)
        spec = importlib.util.find_spec(server_modules[name])
        if spec and spec.origin and os.path.isfile(spec.origin):
            found[name] = {"command": python_exe, "args": ["-m", server_modules[name]]}
            continue
        # 2) Fall back to absolute file path
        for d in search_dirs:
            for fname in server_files[name]:
                p = os.path.join(d, fname)
                if os.path.isfile(p):
                    found[name] = {"command": python_exe, "args": [os.path.abspath(p)]}
                    break
            if name in found:
                break

    return python_exe, found


def _fix_terminal_path() -> str:
    """#2: Add pip scripts dir to ~/.zshrc / ~/.bashrc if 'mpop' not in PATH.

    Returns: "fixed" | "already_ok" | "skipped" | "error"
    """
    import sysconfig
    HOME = os.path.expanduser("~")
    PATH_MARKER = os.path.join(HOME, ".mpop", ".path-configured")

    scripts_dir = ""
    try:
        scripts_dir = sysconfig.get_path("scripts") or ""
    except Exception:
        return "error"

    if not scripts_dir or not os.path.isdir(scripts_dir):
        return "skipped"

    # Check if already in shell PATH
    current_path = os.environ.get("PATH", "")
    already_in_path = scripts_dir in current_path.split(":")

    # Check if marker already set
    if os.path.exists(PATH_MARKER) and already_in_path:
        return "already_ok"

    if already_in_path:
        # Just set the marker; no rc edit needed
        try:
            os.makedirs(os.path.dirname(PATH_MARKER), exist_ok=True)
            open(PATH_MARKER, "w").close()
        except Exception:
            pass
        return "already_ok"

    # Add to rc files
    export_line = f'export PATH="{scripts_dir}:$PATH"  # meshpop'
    rc_candidates = [
        os.path.join(HOME, ".zshrc"),
        os.path.join(HOME, ".bashrc"),
        os.path.join(HOME, ".bash_profile"),
    ]
    wrote = False
    for rc in rc_candidates:
        try:
            if not os.path.exists(rc):
                continue
            with open(rc) as f_rc:
                content = f_rc.read()
            if scripts_dir in content:
                wrote = True
                continue
            with open(rc, "a") as f_rc:
                f_rc.write(f"\n{export_line}\n")
            wrote = True
        except Exception:
            pass

    try:
        os.makedirs(os.path.dirname(PATH_MARKER), exist_ok=True)
        open(PATH_MARKER, "w").close()
    except Exception:
        pass

    return "fixed" if wrote else "skipped"


def _auto_configure_mcp_silent():
    """
    Silent, idempotent MCP auto-configuration.
    Called on first 'mpop' run (main()) and on pip install via .pth.
    Adds MeshPOP MCP servers to every detected AI tool with zero prompts.
    """
    import json, platform
    HOME   = os.path.expanduser("~")
    IS_MAC = platform.system() == "Darwin"
    MARKER = os.path.join(HOME, ".mpop", ".mcp-configured")

    _, servers = _find_python_and_servers()
    if not servers:
        return []  # nothing installed yet

    mcp_block = servers  # {name: {"command": ..., "args": [...]}}

    def _merge_json(cfg_path, key="mcpServers"):
        os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
        data = {}
        if os.path.exists(cfg_path):
            try:
                with open(cfg_path) as f:
                    data = json.load(f)
            except Exception:
                data = {}
        bucket = data.setdefault(key, {})
        changed = False
        for k, v in mcp_block.items():
            if k not in bucket:
                bucket[k] = v
                changed = True
        if changed:
            with open(cfg_path, "w") as f:
                json.dump(data, f, indent=2)
        return changed

    def _merge_zed(cfg_path):
        os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
        data = {}
        if os.path.exists(cfg_path):
            try:
                with open(cfg_path) as f:
                    data = json.load(f)
            except Exception:
                data = {}
        cs = data.setdefault("context_servers", {})
        changed = False
        for name, entry in mcp_block.items():
            if name not in cs:
                cs[name] = {"command": {"path": entry["command"], "args": entry["args"]}}
                changed = True
        if changed:
            with open(cfg_path, "w") as f:
                json.dump(data, f, indent=2)
        return changed

    def _merge_codex_toml(cfg_path):
        os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
        lines = []
        if os.path.exists(cfg_path):
            try:
                with open(cfg_path) as f:
                    lines = f.readlines()
            except Exception:
                lines = []
        existing_keys = set(re.findall(r'\[mcp_servers\.(\w+)\]', ''.join(lines)))
        new_lines = list(lines)
        changed = False
        if new_lines and new_lines[-1].strip():
            new_lines.append('\n')
        for name, entry in mcp_block.items():
            if name not in existing_keys:
                args_str = '[' + ', '.join('"{}"'.format(a) for a in entry['args']) + ']'
                new_lines.append('[mcp_servers.{}]\n'.format(name))
                new_lines.append('command = "{}"\n'.format(entry["command"]))
                new_lines.append('args    = {}\n\n'.format(args_str))
                changed = True
        if changed:
            with open(cfg_path, "w") as f:
                f.writelines(new_lines)
        return changed

    # Tool config paths (macOS / Linux)
    claude_cfg   = os.path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json") \
                   if IS_MAC else os.path.join(HOME, ".config", "Claude", "claude_desktop_config.json")
    cursor_cfg   = os.path.join(HOME, ".cursor", "mcp.json")
    vscode_cfg   = os.path.join(HOME, ".vscode", "mcp.json")
    windsurf_cfg = os.path.join(HOME, ".codeium", "windsurf", "mcp_settings.json")
    zed_cfg      = os.path.join(HOME, ".config", "zed", "settings.json")
    gemini_cfg   = os.path.join(HOME, ".gemini", "settings.json")
    codex_cfg    = os.path.join(HOME, ".codex", "config.toml")

    # detect_path: directory that must exist for tool to be "installed"
    tool_map = [
        ("Claude Desktop", os.path.dirname(claude_cfg),               lambda: _merge_json(claude_cfg)),
        ("Cursor",         os.path.join(HOME, ".cursor"),             lambda: _merge_json(cursor_cfg)),
        ("VS Code",        os.path.join(HOME, ".vscode"),             lambda: _merge_json(vscode_cfg)),
        ("Windsurf",       os.path.join(HOME, ".codeium", "windsurf"),lambda: _merge_json(windsurf_cfg)),
        ("Zed",            os.path.join(HOME, ".config", "zed"),      lambda: _merge_zed(zed_cfg)),
        ("Gemini CLI",     os.path.join(HOME, ".gemini"),             lambda: _merge_json(gemini_cfg)),
        ("OpenAI Codex",   os.path.join(HOME, ".codex"),              lambda: _merge_codex_toml(codex_cfg)),
    ]

    configured = []
    for name, detect_path, writer in tool_map:
        if os.path.exists(detect_path):
            try:
                if writer():
                    configured.append(name)
            except Exception:
                pass

    os.makedirs(os.path.join(HOME, ".mpop"), exist_ok=True)
    import json as _j
    with open(MARKER, "w") as f:
        _j.dump({"configured": configured, "servers": list(servers.keys())}, f)

    return configured


def _setup_mcp(args):
    """
    mpop setup mcp  — show MCP status and (re-)configure all AI tools.
    Flags: --dry-run / -n   show what would be done without writing
           --force  / -f    overwrite existing entries (not yet used, planned)
    """
    import json, platform
    HOME   = os.path.expanduser("~")
    IS_MAC = platform.system() == "Darwin"

    dry_run = "--dry-run" in args or "-n" in args

    print()
    print("  {} mpop setup mcp {}".format(C.BOLD, C.R))
    print("  Targets: Claude Desktop, Cursor, VS Code, Windsurf, Zed, Gemini CLI, OpenAI Codex")
    print()

    # #2: fix terminal PATH first (idempotent, cheap)
    try:
        _fix_terminal_path()
    except Exception:
        pass

    python_exe, servers = _find_python_and_servers()
    print("  Python : {}".format(python_exe))
    if not servers:
        print("  {}No MCP server files found.{}".format(C.RED, C.R))
        print("  Run: pip install meshpop")
        return

    for name, entry in servers.items():
        mode = "-m {}".format(entry["args"][0]) if entry["args"] and entry["args"][0].startswith("-m") \
               else " ".join(entry["args"])
        print("  {}+{} {:<10} {}  {}".format(C.GREEN, C.R, name, python_exe, " ".join(entry["args"])))

    print()
    if dry_run:
        print("  {}[DRY RUN — no files written]{}".format(C.YELLOW, C.R))
        print()
        return

    # Remove marker to force re-run
    marker = os.path.join(HOME, ".mpop", ".mcp-configured")
    if os.path.exists(marker):
        os.remove(marker)

    configured = _auto_configure_mcp_silent()

    if configured:
        print("  {}Configured:{} {}".format(C.GREEN, C.R, ", ".join(configured)))
        print("  Restart the app(s) to load new MCP servers.")
        print()
    else:
        print("  {}Already up to date{}  (all detected tools already have MeshPOP MCP)".format(C.YELLOW, C.R))
        print()

    # Show tools NOT detected
    all_tools    = ["Claude Desktop", "Cursor", "VS Code", "Windsurf", "Zed", "Gemini CLI", "OpenAI Codex"]
    detect_paths = [
        os.path.join(HOME, "Library", "Application Support", "Claude") if IS_MAC else os.path.join(HOME, ".config", "Claude"),
        os.path.join(HOME, ".cursor"),
        os.path.join(HOME, ".vscode"),
        os.path.join(HOME, ".codeium", "windsurf"),
        os.path.join(HOME, ".config", "zed"),
        os.path.join(HOME, ".gemini"),
        os.path.join(HOME, ".codex"),
    ]
    not_found = [t for t, p in zip(all_tools, detect_paths) if not os.path.exists(p)]
    if not_found:
        print("  Not installed: {}".format(", ".join(not_found)))
        print("  (Install any of the above and re-run 'mpop setup mcp' to configure)")
        print()

def _setup_dashboard(args):
    """Install dashboard server locally or on a remote server."""
    import platform as _plat, shutil as _sh
    execute = "-x" in args or "--execute" in args
    non_flag = [a for a in args if not a.startswith("-")]
    target = non_flag[0] if non_flag else "local"

    if not execute:
        print(f"  {C.YELLOW}Dry-run. Add -x to install.{C.R}")
        return

    # Remote server target
    if target != "local":
        servers_config = _get_config("servers", {})
        if target not in servers_config:
            print(f"  {C.RED}Unknown server: {target}{C.R}")
            return
        dashboard_script = _load_dashboard_script()
        if not dashboard_script:
            print(f"  {C.RED}Error:{C.R} dashboard script not available")
            return
        import base64
        b64 = base64.b64encode(dashboard_script.encode()).decode()
        srv = servers_config[target]
        user = srv.get("user", "root")
        # Upload script
        ssh_exec(target, "mkdir -p /opt/mpop-dashboard", timeout=10)
        chunk_size = 50000
        chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
        ssh_exec(target, f"echo '{chunks[0]}' > /tmp/dash.b64", timeout=30)
        for chunk in chunks[1:]:
            ssh_exec(target, f"echo '{chunk}' >> /tmp/dash.b64", timeout=30)
        ssh_exec(target, "base64 -d /tmp/dash.b64 > /opt/mpop-dashboard/dashboard.py && chmod +x /opt/mpop-dashboard/dashboard.py && rm /tmp/dash.b64", timeout=15)
        # Install systemd service
        svc = "\n".join([
            "[Unit]", "Description=MeshPOP Dashboard Server", "After=network.target", "",
            "[Service]",
            "ExecStart=/usr/bin/python3 /opt/mpop-dashboard/dashboard.py",
            "Restart=always", "RestartSec=10",
            'Environment="DASHBOARD_PORT=8721"',
            "", "[Install]", "WantedBy=multi-user.target",
        ])
        if user == "root":
            ssh_exec(target, f"echo '{svc}' > /etc/systemd/system/mpop-dashboard.service && systemctl daemon-reload && systemctl enable mpop-dashboard --now", timeout=20)
        else:
            ssh_exec(target, f"echo '{svc}' | sudo tee /etc/systemd/system/mpop-dashboard.service && sudo systemctl daemon-reload && sudo systemctl enable mpop-dashboard --now", timeout=20)
        result = ssh_exec(target, "systemctl is-active mpop-dashboard 2>/dev/null", timeout=10)
        if result and "active" in result:
            print(f"  {C.GREEN}✓{C.R} Dashboard server running on {target}:8721")
        else:
            print(f"  {C.YELLOW}⚠️{C.R} Deployed to {target} — check: systemctl status mpop-dashboard")
        return
    dashboard_script = _load_dashboard_script()
    if not dashboard_script:
        print(f"  {C.RED}Error:{C.R} dashboard script not available")
        return
    local_bin = os.path.expanduser("~/.local/bin")
    os.makedirs(local_bin, exist_ok=True)
    script_path = os.path.join(local_bin, "mpop-dashboard.py")
    with open(script_path, "w") as _f:
        _f.write(dashboard_script)
    os.chmod(script_path, 0o755)
    python_bin = _sh.which("python3") or "/usr/bin/python3"
    port = 8721
    log_path = os.path.expanduser("~/.mpop/dashboard.log")
    if _plat.system() == "Darwin":
        plist_dir = os.path.expanduser("~/Library/LaunchAgents")
        os.makedirs(plist_dir, exist_ok=True)
        plist_path = os.path.join(plist_dir, "dev.mpop.dashboard.plist")
        lines = [
            '<?xml version="1.0" encoding="UTF-8"?>',
            '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
            '<plist version="1.0"><dict>',
            '  <key>Label</key><string>dev.mpop.dashboard</string>',
            '  <key>ProgramArguments</key><array>',
            f'    <string>{python_bin}</string>',
            f'    <string>{script_path}</string>',
            '  </array>',
            '  <key>EnvironmentVariables</key><dict>',
            '    <key>DASHBOARD_PORT</key>',
            f'    <string>{port}</string>',
            '  </dict>',
            '  <key>RunAtLoad</key><true/>',
            '  <key>KeepAlive</key><true/>',
            f'  <key>StandardOutPath</key><string>{log_path}</string>',
            f'  <key>StandardErrorPath</key><string>{log_path}</string>',
            '</dict></plist>',
        ]
        with open(plist_path, "w") as _f:
            _f.write("\n".join(lines))
        os.system(f"launchctl unload {plist_path} 2>/dev/null")
        ret = os.system(f"launchctl load {plist_path}")
        if ret == 0:
            print(f"  {C.GREEN}✓{C.R} Dashboard server running on :{port} (launchd) | Log: {log_path}")
        else:
            print(f"  {C.RED}✗{C.R} launchctl load failed")
    else:
        svc_dir = os.path.expanduser("~/.config/systemd/user")
        os.makedirs(svc_dir, exist_ok=True)
        svc_path = os.path.join(svc_dir, "mpop-dashboard.service")
        svc_lines = [
            "[Unit]", "Description=MeshPOP Dashboard Server", "After=network.target", "",
            "[Service]",
            f"ExecStart={python_bin} {script_path}",
            "Restart=always", "RestartSec=10",
            f'Environment="DASHBOARD_PORT={port}"',
            "", "[Install]", "WantedBy=default.target",
        ]
        with open(svc_path, "w") as _f:
            _f.write("\n".join(svc_lines))
        os.system("systemctl --user daemon-reload")
        ret = os.system("systemctl --user enable mpop-dashboard --now")
        if ret == 0:
            print(f"  {C.GREEN}✓{C.R} Dashboard server running on :{port} (systemd)")
        else:
            print(f"  {C.RED}✗{C.R} systemctl failed")


def cmd_setup(args):
    """Setup/install components on remote servers"""
    if not args:
        print(f"""
  {C.BOLD}mpop setup{C.R} - Install components on remote servers

  {C.BOLD}USAGE:{C.R}
    mpop setup <server> -x         Full setup (mpop + vssh + agent)
    mpop setup all -x              Full setup on all servers

    mpop setup vssh <server> -x    Install vssh daemon only
    mpop setup agent <server> -x   Install server-agent only
    mpop setup mpop <server> -x    Install mpop CLI only
    mpop setup mcp                 Configure all AI tools (Claude/Cursor/VS Code/...)
    mpop setup wire                Auto-reconnect Wire VPN on Mac startup/wake (launchd plist)

  {C.BOLD}EXAMPLES:{C.R}
    mpop setup node5 -x               Full setup on new server node5
    mpop setup all -x              Full setup on all servers
    mpop setup vssh node1 -x          Install vssh on node1
    mpop setup agent all -x        Install agent on all servers
""")
        return

    component = args[0]
    servers_config = _get_config("servers", {})

    # Check if first arg is a server name (full setup)
    if component in servers_config or component == "all":
        _setup_full(args)
    elif component == "vssh":
        _setup_vssh(args[1:])
    elif component == "dashboard":
        _setup_dashboard(args[1:])
    elif component == "agent":
        _setup_agent(args[1:])
    elif component == "mpop":
        _setup_mpop_remote(args[1:])
    elif component == "mcp":
        _setup_mcp(args[1:])
    elif component == "wire":
        _setup_wire_persist(args[1:])
    else:
        print(f"  Unknown component or server: {component}")
        print(f"  Available: vssh, agent, mpop, mcp, or server name")

def _setup_vssh(args):
    """Install vssh on remote server(s)"""
    if not args:
        print("  Usage: mpop setup vssh <server|all> [-x]")
        return

    target = args[0]
    execute = "-x" in args or "--execute" in args

    # Load vssh.py source
    if not os.path.exists(VSSH_SOURCE):
        print(f"  {C.RED}Error:{C.R} vssh.py not found: {VSSH_SOURCE}")
        return

    try:
        vssh_content = _get_vssh_source()
    except FileNotFoundError as _e:
        print(f"  {C.RED}Error:{C.R} {_e}")
        return
    vssh_size = len(vssh_content)

    # Get secret from config
    vssh_secret = _get_vssh_secret()
    if not vssh_secret:
        print(f"  {C.RED}Error:{C.R} VSSH_SECRET not configured")
        return

    # Systemd service template
    service_template = f'''[Unit]
Description=VSSH Server
After=network.target

[Service]
Type=simple
Environment="VSSH_SECRET={vssh_secret}"
ExecStart=/usr/bin/python3 /usr/local/bin/vssh.py server
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
'''

    # Determine targets - use full config, not just IPs
    servers_config = _get_config("servers", {})
    if target == "all":
        targets = [name for name in servers_config.keys() if name != get_my_name()]
    else:
        if target not in servers_config:
            print(f"  {C.RED}Error:{C.R} Unknown server: {target}")
            return
        targets = [target]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🔧 Setup vssh{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Source:{C.R}   {VSSH_SOURCE} ({vssh_size:,} bytes)
  {C.BOLD}Targets:{C.R}  {', '.join(targets)}
  {C.BOLD}Execute:{C.R}  {C.GREEN + 'Yes' + C.R if execute else C.YELLOW + 'No (dry-run)' + C.R}
""")

    if not execute:
        print(f"  {C.YELLOW}Add -x to actually install and start the service{C.R}\n")

    for name in targets:
        srv = servers_config.get(name, {})
        user = srv.get("user", "root")

        print(f"  {name}: ", end="", flush=True)

        if not execute:
            print(f"{C.DIM}would install vssh.py + systemd service{C.R}")
            continue

        try:
            # Step 1: Upload vssh.py using vssh PUT (if available) or base64
            ip = srv.get("ip") or srv.get("tailscale_ip")
            if not ip:
                print(f"{C.RED}❌ no IP{C.R}")
                continue

            # Try vssh PUT first
            put_result = vssh_put(ip, vssh_content.encode(), "/usr/local/bin/vssh.py")
            if not put_result:
                # Fallback: base64 upload via ssh_exec
                import base64
                b64 = base64.b64encode(vssh_content.encode()).decode()
                # Split into chunks for large content
                chunk_size = 50000
                chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]

                # Write first chunk
                cmd = f"echo '{chunks[0]}' > /tmp/vssh.b64"
                ssh_exec(name, cmd, timeout=30)

                # Append remaining chunks
                for chunk in chunks[1:]:
                    cmd = f"echo '{chunk}' >> /tmp/vssh.b64"
                    ssh_exec(name, cmd, timeout=30)

                # Decode and move
                cmd = "base64 -d /tmp/vssh.b64 > /usr/local/bin/vssh.py && rm /tmp/vssh.b64"
                ssh_exec(name, cmd, timeout=30)

            # Step 2: Make executable
            ssh_exec(name, "chmod +x /usr/local/bin/vssh.py", timeout=10)

            # Step 3: Create systemd service (need sudo for non-root)
            service_b64 = base64.b64encode(service_template.encode()).decode()
            if user == "root":
                cmd = f"echo '{service_b64}' | base64 -d > /etc/systemd/system/vssh.service"
            else:
                cmd = f"echo '{service_b64}' | base64 -d | sudo tee /etc/systemd/system/vssh.service > /dev/null"
            ssh_exec(name, cmd, timeout=10)

            # Step 4: Reload and start
            if user == "root":
                cmd = "systemctl daemon-reload && systemctl enable vssh && systemctl restart vssh"
            else:
                cmd = "sudo systemctl daemon-reload && sudo systemctl enable vssh && sudo systemctl restart vssh"
            result = ssh_exec(name, cmd, timeout=30)

            # Step 5: Verify
            import time
            time.sleep(1)
            if user == "root":
                check_cmd = "systemctl is-active vssh"
            else:
                check_cmd = "sudo systemctl is-active vssh"
            status = ssh_exec(name, check_cmd, timeout=10)

            if status and "active" in status:
                print(f"{C.GREEN}✅ installed & running{C.R}")
            else:
                print(f"{C.YELLOW}⚠️ installed but not running{C.R}")

        except Exception as e:
            print(f"{C.RED}❌ {e}{C.R}")

    if execute:
        print(f"\n  {C.GREEN}Done!{C.R} Run 'mpop config test' to verify connections.")



def _setup_agent_local():
    """Install agent on local machine (Mac: launchd, Linux: systemd user service)"""
    import platform as _platform
    import shutil as _shutil

    agent_content = _load_agent_script()
    # Build PEER_IPS: collect all known peer IPs from config + servers.json
    # Uses vpn_ip, wire_ip, or ip fields — includes ALL peers, even "local: true" entries
    # (a "local" server on another machine IS a peer to push stats to)
    _node_name = _get_config("node_name") or _get_config("node") or ""
    import socket as _sock_agent
    try:
        _my_hostname = _sock_agent.gethostname().lower()
    except Exception:
        _my_hostname = ""

    # Merge config["servers"] + servers.json
    _srv_cfg = dict(_get_config("servers", {}))
    _srv_json_path = os.path.join(os.path.expanduser("~"), ".mpop", "servers.json")
    if os.path.isfile(_srv_json_path):
        try:
            import json as _json_ag
            _srv_json = _json_ag.load(open(_srv_json_path))
            for _sn, _sv in _srv_json.items():
                if _sn not in _srv_cfg:
                    _srv_cfg[_sn] = _sv
        except Exception:
            pass

    # Get this node's own IPs to exclude from peer list
    _my_ips = set()
    if _node_name and _node_name in _srv_cfg:
        _me = _srv_cfg[_node_name]
        for _f in ("vpn_ip", "wire_ip", "ip"):
            if _me.get(_f):
                _my_ips.add(_me[_f])
    try:
        _my_ips.add(_sock_agent.gethostbyname(_sock_agent.gethostname()))
    except Exception:
        pass

    _seen = set()
    _peer_ip_list = []
    for _sname, _s in _srv_cfg.items():
        # Skip if this IS this machine (by name or IP match)
        _ip = _s.get("vpn_ip") or _s.get("wire_ip") or _s.get("ip", "")
        if not _ip:
            continue
        # Skip "localhost" host entries that belong to THIS machine
        _host = _s.get("host", "")
        if _host == "localhost" and (_sname.lower() in _my_hostname or _my_hostname in _sname.lower()):
            continue
        if _ip in _my_ips:
            continue
        if _ip not in _seen:
            _seen.add(_ip)
            _peer_ip_list.append(_ip)

    _peer_ips = ",".join(_peer_ip_list)
    if not _peer_ips:
        print("  Warning: No peer IPs in config - agent will run standalone.")
        print("    Run: mpop config set servers.<name>.vpn_ip <ip>")

    local_bin = os.path.expanduser("~/.local/bin")
    os.makedirs(local_bin, exist_ok=True)
    agent_path = os.path.join(local_bin, "mpop-agent.py")

    # Write thin wrappers so pip upgrades are picked up automatically (no static copies)
    _py = sys.executable  # use the same python that is running mpop right now
    _agent_wrapper = f"#!{_py}\nfrom agent_reporter import main\nmain()\n"
    with open(agent_path, "w") as f:
        f.write(_agent_wrapper)
    os.chmod(agent_path, 0o755)

    # Create ~/.local/bin/mpop wrapper — user-owned, no sudo needed
    # This takes precedence over /usr/local/bin/mpop (root-owned) when ~/.local/bin is first in PATH
    _mpop_wrapper_path = os.path.join(local_bin, "mpop")
    _mpop_wrapper = f"#!{_py}\nfrom mpop import main\nmain()\n"
    with open(_mpop_wrapper_path, "w") as f:
        f.write(_mpop_wrapper)
    os.chmod(_mpop_wrapper_path, 0o755)

    # Ensure ~/.local/bin is in PATH (prepend) in shell config files
    _path_line = '\nexport PATH="$HOME/.local/bin:$PATH"  # meshpop\n'
    for _rc in ["~/.zshrc", "~/.bashrc", "~/.bash_profile", "~/.profile"]:
        _rc_path = os.path.expanduser(_rc)
        if os.path.isfile(_rc_path):
            try:
                _rc_content = open(_rc_path).read()
                if "~/.local/bin" not in _rc_content and "$HOME/.local/bin" not in _rc_content:
                    with open(_rc_path, "a") as _f:
                        _f.write(_path_line)
            except Exception:
                pass

    is_mac = _platform.system() == "Darwin"
    # On Mac: prefer Homebrew python3 (canonical pip target) over Xcode/system/pyenv pythons.
    # This prevents wrapper flip-flop when migration runs from a different python (e.g. /usr/bin/python3).
    if is_mac:
        for _brew_py in [
            "/opt/homebrew/bin/python3",
            "/opt/homebrew/bin/python3.14",
            "/opt/homebrew/bin/python3.13",
            "/opt/homebrew/bin/python3.12",
            "/usr/local/bin/python3",
        ]:
            if os.path.isfile(_brew_py):
                _py = _brew_py
                break
    python_bin = _py  # use the running python, not whatever 'which python3' finds

    if is_mac:
        plist_dir = os.path.expanduser("~/Library/LaunchAgents")
        os.makedirs(plist_dir, exist_ok=True)
        plist_path = os.path.join(plist_dir, "dev.mpop.agent.plist")
        log_path = os.path.expanduser("~/.mpop/agent.log")
        os.makedirs(os.path.expanduser("~/.mpop"), exist_ok=True)
        plist = '\n'.join([
            '<?xml version="1.0" encoding="UTF-8"?>',
            '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
            '<plist version="1.0"><dict>',
            '  <key>Label</key><string>dev.mpop.agent</string>',
            '  <key>ProgramArguments</key><array>',
            f'    <string>{python_bin}</string>',
            f'    <string>{agent_path}</string>',
            '  </array>',
            '  <key>EnvironmentVariables</key><dict>',
            '    <key>PEER_IPS</key>',
            f'    <string>{_peer_ips}</string>',
            '    <key>NODE_NAME</key>',
            f'    <string>{_node_name}</string>',
            '  </dict>',
            '  <key>RunAtLoad</key><true/>',
            '  <key>KeepAlive</key><true/>',
            f'  <key>StandardOutPath</key><string>{log_path}</string>',
            f'  <key>StandardErrorPath</key><string>{log_path}</string>',
            '</dict></plist>',
        ])
        with open(plist_path, "w") as f:
            f.write(plist)
        os.system(f"launchctl unload {plist_path} 2>/dev/null")
        ret = os.system(f"launchctl load {plist_path}")
        if ret == 0:
            print(f"  {C.GREEN}\u2713{C.R} Mac agent installed via launchd")
            print(f"    Plist:   {plist_path}")
            print(f"    Log:     {log_path}")
            print(f"    Peers: {_peer_ips}")
        else:
            print(f"  {C.RED}\u2717{C.R} launchctl load failed")
    else:
        svc_dir = os.path.expanduser("~/.config/systemd/user")
        os.makedirs(svc_dir, exist_ok=True)
        svc_path = os.path.join(svc_dir, "mpop-agent.service")
        svc = '\n'.join([
            "[Unit]",
            "Description=MeshPOP Agent Reporter",
            "After=network.target",
            "",
            "[Service]",
            f"ExecStart={python_bin} {agent_path}",
            "Restart=always",
            "RestartSec=10",
            f'Environment="PEER_IPS={_peer_ips}"',
            f'Environment="NODE_NAME={_node_name}"',
            "",
            "[Install]",
            "WantedBy=default.target",
        ])
        with open(svc_path, "w") as f:
            f.write(svc)
        os.system("systemctl --user daemon-reload")
        ret = os.system("systemctl --user enable mpop-agent --now")
        if ret == 0:
            print(f"  {C.GREEN}\u2713{C.R} Linux agent installed (systemd user)")
            print(f"    Service: {svc_path}")
            print(f"    Peers: {_peer_ips}")
        else:
            print(f"  {C.RED}\u2717{C.R} systemctl --user enable failed")


    # Register local hostname in config hostname_map so dashboard can match
    import socket as _sock2, json as _jcfg2
    _local_hn = _sock2.gethostname().split(".")[0]
    _node_nm = _get_config("node_name") or _get_config("node")
    if _node_nm and _local_hn:
        _cfg_path2 = os.path.expanduser("~/.mpop/config.json")
        if os.path.exists(_cfg_path2):
            _cfg2 = _jcfg2.load(open(_cfg_path2))
            _hm2 = _cfg2.get("hostname_map", {})
            if _local_hn not in _hm2:
                _hm2[_local_hn] = _node_nm
                _cfg2["hostname_map"] = _hm2
                _jcfg2.dump(_cfg2, open(_cfg_path2, "w"), indent=2)

def _setup_agent(args):
    """Install server-agent on remote server(s)"""
    execute = "-x" in args or "--execute" in args
    do_remove = "--remove" in args or "-r" in args
    non_flag_args = [a for a in args if not a.startswith("-") and a not in ("all",)]
    target = non_flag_args[0] if non_flag_args else "all"

    # "local" target → install on this machine only
    if target == "local":
        if execute:
            try:
                _setup_agent_local()
            except Exception as _le:
                print(f"  {C.YELLOW}⚠ local install: {_le}{C.R}")
        else:
            print("  Would install agent on local machine.")
            print("  Run with -x to execute.")
        return

    discovered = get_servers()
    all_servers = {k: v for k, v in discovered.items() if k != get_my_name()}
    servers_config = _get_config("servers", {})
    targets = []
    _include_local = False
    if target == "all":
        targets = list(servers_config.keys()) if servers_config else list(all_servers.keys())
        targets = [n for n in targets if n != get_my_name()]
        if not targets and all_servers:
            targets = list(all_servers.keys())
        _include_local = True   # "all" includes this machine too
    else:
        # Support comma-separated: v1,g1,g2
        if "," in target:
            targets = [t.strip() for t in target.split(",") if t.strip()]
        elif target in servers_config or target in all_servers:
            targets = [target]

    if do_remove:
        # Remove server-agent completely (no duplicates)
        print(f"\n  {C.CYAN}🗑️  Removing server-agent from {len(targets)} node(s){C.R}")
        _uninstall_cmd = (
            "systemctl stop server-agent 2>/dev/null; systemctl disable server-agent 2>/dev/null; "
            "pkill -9 -f agent-reporter 2>/dev/null; "
            "rm -f /etc/systemd/system/server-agent.service; "
            "systemctl daemon-reload 2>/dev/null; "
            "echo REMOVED"
        )
        for name in targets:
            srv = servers_config.get(name, {})
            ip = srv.get("ip") or srv.get("tailscale_ip") or srv.get("wire_ip")
            if not ip and name in all_servers:
                raw = all_servers[name]
                ip = raw if isinstance(raw, str) else (raw.get("vpn_ip") or raw.get("ip"))
            if not ip:
                continue
            print(f"  {name}... ", end="", flush=True)
            try:
                out = ssh_exec(name, _uninstall_cmd, timeout=15)
                print(f"✅" if out else "⚠️")
            except Exception:
                print("❌")
        print()
        return

    if not args:
        print(f"  {C.CYAN}No target — deploying to ALL servers{C.R}")

    # Load agent source from embedded constant (works after pip install)
    agent_content = _load_agent_script()

    # Get dashboard URL from config
    # User sets this once: mpop config set dashboard_url http://<your-vps-ip>:8721
    # Then deploys:        mpop setup agent <server|all>
    dashboard_url = _get_config("dashboard_url", "").rstrip("/")
    if not dashboard_url:
        print(f"  {C.YELLOW}Warning:{C.R} dashboard_url not configured in ~/.mpop/config.json")
        print(f"    Set it: mpop config set dashboard_url http://<your-vps-ip>:8721")
        dashboard_url = "http://localhost:8721"
    agent_size = len(agent_content)

    # Collect all peer IPs for mesh PEER_IPS env var (so each agent pushes to all peers)
    servers_config = _get_config("servers", {})
    mesh_exclude   = set(_get_config("mesh_exclude", []))
    all_ips = []
    for _sname, _srv in servers_config.items():
        _ip = (_srv.get("wire_ip") or _srv.get("tailscale_ip") or _srv.get("ip") or
               (_srv.get("vpn_ip") if isinstance(_srv, dict) else None) or "")
        if isinstance(_srv, str):
            _ip = _srv
        if _ip and _ip not in mesh_exclude and ":" not in str(_ip):
            all_ips.append(str(_ip).strip())
    if not all_ips:
        for _n, _v in get_servers().items():
            if _n == get_my_name():
                continue
            _ip = _v if isinstance(_v, str) else (_v.get("vpn_ip") or _v.get("ip") if isinstance(_v, dict) else None)
            if _ip and ":" not in str(_ip) and _ip not in mesh_exclude:
                all_ips.append(str(_ip).strip())
    peer_ips_str = ",".join(dict.fromkeys(all_ips))

    # Systemd service template (includes PEER_IPS for mesh push)
    service_template = f'''[Unit]
Description=Server Monitoring Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/server-agent/agent-reporter.py
Restart=always
RestartSec=30

Environment="PEER_IPS={peer_ips_str}"

[Install]
WantedBy=multi-user.target
'''

    # Determine targets (remote servers only; local handled separately below)
    # Fallback: use discovered SERVERS if config empty (VPN peer discovery)
    discovered = get_servers()
    all_servers = {k: v for k, v in discovered.items() if k != get_my_name()}
    if target == "all":
        targets = list(servers_config.keys()) if servers_config else list(all_servers.keys())
        targets = [n for n in targets if n != get_my_name()]
        if not targets and all_servers:
            targets = list(all_servers.keys())
    else:
        if "," in target:
            targets = [t.strip() for t in target.split(",") if t.strip() and (t.strip() in servers_config or t.strip() in all_servers)]
        elif target not in servers_config and target in all_servers:
            servers_config = dict(servers_config)
            servers_config[target] = {"ip": all_servers[target] if isinstance(all_servers[target], str) else all_servers[target].get("vpn_ip","")}
            targets = [target]
        elif target not in servers_config and target not in all_servers:
            print(f"  {C.RED}Error:{C.R} Unknown server: {target}")
            return
        else:
            targets = [target]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}📡 Setup server-agent{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Source:{C.R}   [embedded in mpop.py] ({agent_size:,} bytes)
  {C.BOLD}Targets:{C.R}  {', '.join(targets)}
  {C.BOLD}Execute:{C.R}  {C.GREEN + 'Yes' + C.R if execute else C.YELLOW + 'No (dry-run)' + C.R}
""")

    if not execute:
        print(f"  {C.YELLOW}Add -x to actually install and start the service{C.R}\n")

    for name in targets:
        srv = servers_config.get(name, {})
        user = srv.get("user", "root")

        print(f"  {name}: ", end="", flush=True)

        if not execute:
            print(f"{C.DIM}would install server-agent + systemd service{C.R}")
            continue

        try:
            import base64

            ip = srv.get("ip") or srv.get("tailscale_ip") or srv.get("wire_ip") or srv.get("vpn_ip")
            if not ip and name in all_servers:
                raw = all_servers[name]
                ip = raw if isinstance(raw, str) else (raw.get("vpn_ip") or raw.get("ip") or raw.get("host", "").split("@")[-1])
            if not ip:
                print(f"{C.RED}❌ no IP{C.R}")
                continue

            # Step 0: Detect agent dir — /opt/server-agent or ~/.mpop/server-agent (Synology/home fallback)
            _probe = (
                "D=/opt/server-agent; "
                "if mkdir -p $D 2>/dev/null && [ -w $D ] 2>/dev/null; then echo $D; "
                "else mkdir -p ~/.mpop/server-agent 2>/dev/null; echo \"$HOME/.mpop/server-agent\"; fi"
            )
            _out = (ssh_exec(name, _probe, timeout=10) or "").strip()
            agent_dir = (_out.split("\n")[-1].strip() if _out else "").rstrip("__END__").strip() or "/opt/server-agent"

            # Step 1: Create directory (already done by probe)
            # Step 2: Try symlink from pip package first (self-updating on pip upgrade)
            _sym_cmd = (
                'AGENT=$(python3 -c "import agent_reporter; print(agent_reporter.__file__)" 2>/dev/null); '
                f'if [ -n "$AGENT" ]; then ln -sf "$AGENT" {agent_dir}/agent-reporter.py && echo SYMLINK; fi'
            )
            _sym_ok = (ssh_exec(name, _sym_cmd, timeout=10) or "").strip() == "SYMLINK"
            # Fallback: upload agent script directly
            if not _sym_ok:
                put_ok, _ = vssh_put(ip, agent_content.encode(), f"{agent_dir}/agent-reporter.py")
            else:
                put_ok = True
            # Verify file exists (vssh PUT may claim OK but fail on some targets e.g. Synology)
            _verify = ssh_exec(name, f"test -f {agent_dir}/agent-reporter.py && wc -c < {agent_dir}/agent-reporter.py", timeout=5)
            _size_ok = _verify and _verify.strip().isdigit() and int(_verify.strip()) > 1000
            if not put_ok or not _size_ok:
                # Fallback: base64 upload (small chunks to avoid shell ARG_MAX on Synology etc)
                b64 = base64.b64encode(agent_content.encode()).decode()
                chunk_size = 4000
                chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
                # Use printf to avoid echo escaping issues; escape single quotes in chunk
                ssh_exec(name, "rm -f /tmp/agent.b64", timeout=5)
                for i, chunk in enumerate(chunks):
                    esc = chunk.replace("'", "'\"'\"'")
                    cmd = f"printf '%s' '{esc}' >> /tmp/agent.b64"
                    ssh_exec(name, cmd, timeout=30)
                cmd = f"base64 -d /tmp/agent.b64 > {agent_dir}/agent-reporter.py 2>/dev/null && rm -f /tmp/agent.b64"
                ssh_exec(name, cmd, timeout=30)

            # Step 3: Make executable
            ssh_exec(name, f"chmod +x {agent_dir}/agent-reporter.py", timeout=10)

            # Step 3b: ~/.mpop/config.json — servers에 모든 VPN 피어 IP 기록 (분산 push용, relay 불필요)
            _servers_cfg = {}
            for k, v in all_servers.items():
                if k.startswith("node-"):  # node-XXX 자동생성 이름은 config에 저장하지 않음
                    continue
                ip = v if isinstance(v, str) else (v.get("vpn_ip") or v.get("ip") if isinstance(v, dict) else None)
                if ip and ":" not in str(ip):
                    _servers_cfg[k] = {"ip": str(ip)}
            _cfg_dict = {"dashboard_url": dashboard_url, "node_name": name, "servers": _servers_cfg}
            _cfg_b64 = base64.b64encode(json.dumps(_cfg_dict).encode()).decode()
            _cfg_script = (
                "import json,os,base64\n"
                f"d=json.loads(base64.b64decode({repr(_cfg_b64)}).decode())\n"
                "p=os.path.expanduser('~/.mpop/config.json')\n"
                "c=json.load(open(p)) if os.path.exists(p) else {}\n"
                "c.update(d)\n"
                "os.makedirs(os.path.dirname(p),exist_ok=True)\n"
                "json.dump(c,open(p,'w'),indent=2)\n"
            )
            _script_b64 = base64.b64encode(_cfg_script.encode()).decode()
            ssh_exec(name, f"echo '{_script_b64}' | base64 -d | python3", timeout=15)

            # Step 4: systemd service (skip if no systemctl, e.g. Synology)
            _svc = f'''[Unit]
Description=Server Monitoring Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 {agent_dir}/agent-reporter.py
Restart=always
RestartSec=30

Environment="PEER_IPS={peer_ips_str}"

[Install]
WantedBy=multi-user.target
'''
            _has_systemctl = ssh_exec(name, "which systemctl >/dev/null 2>&1 && echo y", timeout=5)
            if _has_systemctl and "y" in (_has_systemctl or ""):
                service_b64 = base64.b64encode(_svc.encode()).decode()
                if user == "root":
                    cmd = f"echo '{service_b64}' | base64 -d > /etc/systemd/system/server-agent.service"
                else:
                    cmd = f"echo '{service_b64}' | base64 -d | sudo tee /etc/systemd/system/server-agent.service > /dev/null"
                ssh_exec(name, cmd, timeout=10)

            # Step 5: systemd 시도, 없으면 Popen 데몬으로 fallback
            import time
            has_systemd = False
            try:
                if user == "root":
                    cmd = "systemctl daemon-reload && systemctl enable server-agent && systemctl restart server-agent 2>/dev/null"
                else:
                    cmd = "sudo systemctl daemon-reload && sudo systemctl enable server-agent && sudo systemctl restart server-agent 2>/dev/null"
                ssh_exec(name, cmd, timeout=30)
                time.sleep(1)
                chk = "systemctl is-active server-agent 2>/dev/null"
                if user != "root":
                    chk = "sudo " + chk
                st = ssh_exec(name, chk, timeout=10)
                if st and "active" in st:
                    has_systemd = True
            except Exception:
                pass

            if not has_systemd:
                # Alpine/LXC/Synology: systemd 없음 → nohup 데몬 + crond 등록
                ssh_exec(name, "pkill -f agent-reporter.py 2>/dev/null; true", timeout=5)
                _agent_path = f"{agent_dir}/agent-reporter.py"
                _start_cmd = f"nohup python3 {_agent_path} >> /tmp/agent.log 2>&1 &"
                ssh_exec(name, _start_cmd, timeout=10)
                try:
                    _cron = (
                        f"(crontab -l 2>/dev/null | grep -v agent-reporter;"
                        f" echo '@reboot python3 {_agent_path}"
                        " >> /tmp/agent.log 2>&1') | crontab -"
                    )
                    ssh_exec(name, _cron, timeout=10)
                except Exception:
                    pass

            # Step 6: Verify
            time.sleep(2)
            check = ssh_exec(name, "pgrep -f agent-reporter.py >/dev/null 2>&1 && echo running || echo stopped", timeout=10)
            if check and "running" in check:
                print(f"{C.GREEN}✅ installed & running{C.R}")
            else:
                print(f"{C.YELLOW}⚠️ installed but not running{C.R}")

        except Exception as e:
            print(f"{C.RED}❌ {e}{C.R}")

    if execute:
        # Also install agent on local machine when deploying to "all"
        if _include_local:
            print(f"\n  📍 Installing agent locally...", flush=True)
            try:
                _setup_agent_local()
            except Exception as _le:
                print(f"  {C.YELLOW}⚠ local install: {_le}{C.R}")
        print(f"\n  {C.GREEN}Done!{C.R} Check dashboard at {dashboard_url}")


def _setup_mpop_remote(args):
    """Install mpop CLI on remote server(s)"""
    if not args:
        print("  Usage: mpop setup mpop <server|all> [-x]")
        return

    target = args[0]
    execute = "-x" in args or "--execute" in args

    # Load mpop CLI source
    mpop_source = os.path.expanduser("~/MeshPOP/mpop/src/mpop/cli.py")
    if not os.path.exists(mpop_source):
        print(f"  {C.RED}Error:{C.R} cli.py not found: {mpop_source}")
        return

    with open(mpop_source, 'r') as f:
        mpop_content = f.read()

    # Add shebang if not present
    if not mpop_content.startswith("#!"):
        mpop_content = "#!/usr/bin/env python3\n" + mpop_content

    mpop_size = len(mpop_content)

    # Determine targets
    servers_config = _get_config("servers", {})
    if target == "all":
        targets = [name for name in servers_config.keys() if name != get_my_name()]
    else:
        if target not in servers_config:
            print(f"  {C.RED}Error:{C.R} Unknown server: {target}")
            return
        targets = [target]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}📦 Setup mpop CLI{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Source:{C.R}   {mpop_source} ({mpop_size:,} bytes)
  {C.BOLD}Targets:{C.R}  {', '.join(targets)}
  {C.BOLD}Execute:{C.R}  {C.GREEN + 'Yes' + C.R if execute else C.YELLOW + 'No (dry-run)' + C.R}
""")

    if not execute:
        print(f"  {C.YELLOW}Add -x to actually install mpop{C.R}\n")

    for name in targets:
        srv = servers_config.get(name, {})
        user = srv.get("user", "root")

        print(f"  {name}: ", end="", flush=True)

        if not execute:
            print(f"{C.DIM}would install mpop to /usr/local/bin/mpop{C.R}")
            continue

        try:
            import base64

            ip = srv.get("ip") or srv.get("tailscale_ip")
            if not ip:
                print(f"{C.RED}❌ no IP{C.R}")
                continue

            # Try vssh PUT first
            put_result = vssh_put(ip, mpop_content.encode(), "/usr/local/bin/mpop")
            if not put_result:
                # Fallback: base64 upload
                b64 = base64.b64encode(mpop_content.encode()).decode()
                chunk_size = 50000
                chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]

                cmd = f"echo '{chunks[0]}' > /tmp/mpop.b64"
                ssh_exec(name, cmd, timeout=30)

                for chunk in chunks[1:]:
                    cmd = f"echo '{chunk}' >> /tmp/mpop.b64"
                    ssh_exec(name, cmd, timeout=30)

                if user == "root":
                    cmd = "base64 -d /tmp/mpop.b64 > /usr/local/bin/mpop && rm /tmp/mpop.b64"
                else:
                    cmd = "base64 -d /tmp/mpop.b64 | sudo tee /usr/local/bin/mpop > /dev/null && rm /tmp/mpop.b64"
                ssh_exec(name, cmd, timeout=30)

            # Make executable
            if user == "root":
                ssh_exec(name, "chmod +x /usr/local/bin/mpop", timeout=10)
            else:
                ssh_exec(name, "sudo chmod +x /usr/local/bin/mpop", timeout=10)

            # Verify
            version = ssh_exec(name, "mpop --version 2>/dev/null || echo 'unknown'", timeout=10)
            if version:
                print(f"{C.GREEN}✅ {version.strip()}{C.R}")
            else:
                print(f"{C.GREEN}✅ installed{C.R}")

        except Exception as e:
            print(f"{C.RED}❌ {e}{C.R}")

    if execute:
        print(f"\n  {C.GREEN}Done!{C.R}")


def _setup_full(args):
    """Full setup: mpop + vssh + server-agent"""
    if not args:
        print("  Usage: mpop setup <server|all> [-x]")
        return

    target = args[0]
    execute = "-x" in args or "--execute" in args

    # Determine targets
    servers_config = _get_config("servers", {})
    if target == "all":
        targets = [name for name in servers_config.keys() if name != get_my_name()]
    else:
        if target not in servers_config:
            print(f"  {C.RED}Error:{C.R} Unknown server: {target}")
            return
        targets = [target]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🚀 Full Server Setup{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Components:{C.R} mpop CLI + vssh daemon + server-agent
  {C.BOLD}Targets:{C.R}    {', '.join(targets)}
  {C.BOLD}Execute:{C.R}    {C.GREEN + 'Yes' + C.R if execute else C.YELLOW + 'No (dry-run)' + C.R}
""")

    if not execute:
        print(f"  {C.YELLOW}Add -x to actually install all components{C.R}\n")
        for name in targets:
            print(f"  {name}: {C.DIM}would install mpop + vssh + agent{C.R}")
        return

    for name in targets:
        print(f"\n  {C.BOLD}[{name}]{C.R}")

        # Step 1: mpop
        print(f"    [1/3] mpop CLI...", end=" ", flush=True)
        try:
            _setup_mpop_single(name)
            print(f"{C.GREEN}✓{C.R}")
        except Exception as e:
            print(f"{C.RED}✗ {e}{C.R}")

        # Step 2: vssh
        print(f"    [2/3] vssh daemon...", end=" ", flush=True)
        try:
            _setup_vssh_single(name)
            print(f"{C.GREEN}✓{C.R}")
        except Exception as e:
            print(f"{C.RED}✗ {e}{C.R}")

        # Step 3: agent
        print(f"    [3/3] server-agent...", end=" ", flush=True)
        try:
            _setup_agent_single(name)
            print(f"{C.GREEN}✓{C.R}")
        except Exception as e:
            print(f"{C.RED}✗ {e}{C.R}")

    print(f"\n  {C.GREEN}Done!{C.R} Run 'mpop config test' to verify.")


def _setup_mpop_single(name: str):
    """Install mpop on a single server (dev: copy file, pip: install via pip)"""
    servers_config = _get_config("servers", {})
    srv = servers_config.get(name, {})
    user = srv.get("user", "root")
    ip = srv.get("ip") or srv.get("tailscale_ip")

    mpop_source = os.path.expanduser("~/MeshPOP/mpop/src/mpop/cli.py")
    if not os.path.exists(mpop_source):
        # pip install mode: install meshpop on remote via pip
        _pip = "pip3 install meshpop --break-system-packages -q 2>/dev/null || pip3 install meshpop -q"
        ssh_exec(name, _pip, timeout=120)
        return
    with open(mpop_source, 'r') as f:
        content = f.read()
    if not content.startswith("#!"):
        content = "#!/usr/bin/env python3\n" + content

    put_result = vssh_put(ip, content.encode(), "/usr/local/bin/mpop")
    if not put_result:
        import base64
        b64 = base64.b64encode(content.encode()).decode()
        if user == "root":
            ssh_exec(name, f"echo '{b64}' | base64 -d > /usr/local/bin/mpop", timeout=60)
        else:
            ssh_exec(name, f"echo '{b64}' | base64 -d | sudo tee /usr/local/bin/mpop > /dev/null", timeout=60)

    if user == "root":
        ssh_exec(name, "chmod +x /usr/local/bin/mpop", timeout=10)
    else:
        ssh_exec(name, "sudo chmod +x /usr/local/bin/mpop", timeout=10)


def _setup_vssh_single(name: str):
    """Install vssh on a single server"""
    servers_config = _get_config("servers", {})
    srv = servers_config.get(name, {})
    user = srv.get("user", "root")
    ip = srv.get("ip") or srv.get("tailscale_ip")

    vssh_secret = _get_vssh_secret()
    if not vssh_secret:
        raise Exception("VSSH_SECRET not configured")

    vssh_content = _get_vssh_source()

    service_template = f'''[Unit]
Description=VSSH Server
After=network.target

[Service]
Type=simple
Environment="VSSH_SECRET={vssh_secret}"
ExecStart=/usr/bin/python3 /usr/local/bin/vssh.py server
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
'''

    import base64

    # Upload vssh
    put_result = vssh_put(ip, vssh_content.encode(), "/usr/local/bin/vssh.py")
    if not put_result:
        b64 = base64.b64encode(vssh_content.encode()).decode()
        if user == "root":
            ssh_exec(name, f"echo '{b64}' | base64 -d > /usr/local/bin/vssh.py", timeout=60)
        else:
            ssh_exec(name, f"echo '{b64}' | base64 -d | sudo tee /usr/local/bin/vssh.py > /dev/null", timeout=60)

    if user == "root":
        ssh_exec(name, "chmod +x /usr/local/bin/vssh.py", timeout=10)
    else:
        ssh_exec(name, "sudo chmod +x /usr/local/bin/vssh.py", timeout=10)

    # Service
    service_b64 = base64.b64encode(service_template.encode()).decode()
    if user == "root":
        ssh_exec(name, f"echo '{service_b64}' | base64 -d > /etc/systemd/system/vssh.service", timeout=10)
        ssh_exec(name, "systemctl daemon-reload && systemctl enable vssh && systemctl restart vssh", timeout=30)
    else:
        ssh_exec(name, f"echo '{service_b64}' | base64 -d | sudo tee /etc/systemd/system/vssh.service > /dev/null", timeout=10)
        ssh_exec(name, "sudo systemctl daemon-reload && sudo systemctl enable vssh && sudo systemctl restart vssh", timeout=30)


def _setup_agent_single(name: str):
    """Install server-agent on a single server"""
    servers_config = _get_config("servers", {})
    srv = servers_config.get(name, {})
    user = srv.get("user", "root")
    ip = srv.get("ip") or srv.get("tailscale_ip")

    # Build PEER_IPS: all wire_ips in config except this server's own IP
    _srv_cfg = _get_config("servers", {})
    _my_ip = (srv.get("wire_ip") or srv.get("ip", ""))
    _peer_ips = ",".join(
        s.get("wire_ip") or s.get("ip", "")
        for sname, s in _srv_cfg.items()
        if (s.get("wire_ip") or s.get("ip", "")) and
           (s.get("wire_ip") or s.get("ip", "")) != _my_ip
    )
    if not _peer_ips:
        print("  ⚠ No peer IPs found in config — agents will run standalone.")
        print("    Run: mpop config set servers.<name>.wire_ip <ip>")

    # Use embedded agent script (works after pip install)
    agent_content = _load_agent_script()

    service_template = f'''[Unit]
Description=Server Monitoring Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/server-agent/agent-reporter.py
Restart=always
RestartSec=30
Environment="PEER_IPS={_peer_ips}"
Environment="NODE_NAME={name}"

[Install]
WantedBy=multi-user.target
'''

    import base64

    # Create directory
    if user == "root":
        ssh_exec(name, "mkdir -p /opt/server-agent", timeout=10)
    else:
        ssh_exec(name, "sudo mkdir -p /opt/server-agent", timeout=10)

    # Upload agent
    put_result = vssh_put(ip, agent_content.encode(), "/opt/server-agent/agent-reporter.py")
    if not put_result:
        b64 = base64.b64encode(agent_content.encode()).decode()
        if user == "root":
            ssh_exec(name, f"echo '{b64}' | base64 -d > /opt/server-agent/agent-reporter.py", timeout=60)
        else:
            ssh_exec(name, f"echo '{b64}' | base64 -d | sudo tee /opt/server-agent/agent-reporter.py > /dev/null", timeout=60)

    if user == "root":
        ssh_exec(name, "chmod +x /opt/server-agent/agent-reporter.py", timeout=10)
    else:
        ssh_exec(name, "sudo chmod +x /opt/server-agent/agent-reporter.py", timeout=10)

    # Service
    service_b64 = base64.b64encode(service_template.encode()).decode()
    if user == "root":
        ssh_exec(name, f"echo '{service_b64}' | base64 -d > /etc/systemd/system/server-agent.service", timeout=10)
        ssh_exec(name, "systemctl daemon-reload && systemctl enable server-agent && systemctl restart server-agent", timeout=30)
    else:
        ssh_exec(name, f"echo '{service_b64}' | base64 -d | sudo tee /etc/systemd/system/server-agent.service > /dev/null", timeout=10)
        ssh_exec(name, "sudo systemctl daemon-reload && sudo systemctl enable server-agent && sudo systemctl restart server-agent", timeout=30)


# Dashboard server embedded — deploy with: mpop setup dashboard <server> -x
_DASHBOARD_SERVER_SCRIPT = """#!/usr/bin/env python3
\"\"\"Dashboard server — collects agent reports, serves /api/status /api/trends /api/report\"\"\"

import json
import subprocess
import threading
import time
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
import os

PORT = int(os.environ.get("DASHBOARD_PORT", "8721"))
VPN_SERVER_URL = os.environ.get("WIRE_SERVER_URL", "http://localhost:8790")

# 릴레이 서버 목록 (공인 IP)
RELAY_IPS = os.environ.get("WIRE_RELAY_IPS", "").split(",") if os.environ.get("WIRE_RELAY_IPS") else []

# Server list - loaded from config, auto-updated from VPN peers
# Config file: ~/.mpop/servers.json or /etc/meshpop/servers.json
_SERVER_CONFIG_PATHS = [
    os.path.expanduser("~/.mpop/servers.json"),
    "/etc/meshpop/servers.json",
]

def _load_server_config():
    \"\"\"Load initial server list from config file.\"\"\"
    for p in _SERVER_CONFIG_PATHS:
        try:
            with open(p) as f:
                return json.load(f)
        except (FileNotFoundError, json.JSONDecodeError):
            continue
    return {}

SERVERS = _load_server_config()
SERVERS_LOCK = threading.Lock()

# Hostname to friendly name mapping — populated dynamically from VPN peers + config
# (No hardcoded server names: each user's mapping comes from their own config/VPN)
HOSTNAME_MAP: dict = {}


def fetch_vpn_peers():
    \"\"\"VPN 서버에서 피어 목록 가져오기\"\"\"
    global SERVERS, HOSTNAME_MAP
    try:
        req = urllib.request.Request(f"{VPN_SERVER_URL}/peers")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
            peers = data.get("peers", [])

            new_servers = {}
            new_hostname_map = {}

            for peer in peers:
                vpn_ip = peer.get("vpn_ip", "")
                public_ip = peer.get("public_ip", "")
                port = peer.get("port", 51820)
                node_id = peer.get("node_id", "")[:8]

                # 이름 결정 (hostname/display_name 우선, IP 폴백)
                hostname = peer.get("hostname", "")
                display_name = peer.get("display_name", "")
                name = guess_name_from_peer(vpn_ip, public_ip, hostname, display_name)

                is_relay = public_ip in RELAY_IPS

                # SSH 호스트 결정
                if vpn_ip == get_my_vpn_ip():
                    host = "localhost"
                    local = True
                else:
                    user = "root" if is_relay else "dragon"
                    host = f"{user}@{vpn_ip}"
                    local = False

                new_servers[name] = {
                    "host": host,
                    "vpn_ip": vpn_ip,
                    "public_ip": public_ip,
                    "port": port,
                    "node_id": node_id,
                    "desc": f"{'릴레이' if is_relay else '클라이언트'} @ {public_ip}",
                    "is_relay": is_relay,
                    "icon": "☁️" if is_relay else "🖥️",
                    "local": local if 'local' in dir() else False,
                }

                # hostname map 업데이트
                new_hostname_map[name] = name
                new_hostname_map[name.lower()] = name

            # 실제 호스트명 → 서버이름 매핑: config의 hostname_map에서 추가
            cfg_hostname_map = _get_config("hostname_map", {})
            new_hostname_map.update(cfg_hostname_map)

            # servers.json의 수동 등록 서버를 merge (VPN 피어에 없는 서버 보존)
            static_servers = _load_server_config()
            for sname, sinfo in static_servers.items():
                if sname not in new_servers:
                    new_servers[sname] = sinfo
                    new_hostname_map[sname] = sname

            with SERVERS_LOCK:
                SERVERS = new_servers
                HOSTNAME_MAP = new_hostname_map

            return len(peers)
    except Exception as e:
        print(f"Failed to fetch VPN peers: {e}")
        return 0


def guess_name_from_peer(vpn_ip: str, public_ip: str, hostname: str = "", display_name: str = "") -> str:
    \"\"\"VPN 피어 정보로 서버 이름 결정 — hostname/display_name 우선, IP 폴백\"\"\"
    # 1) 피어가 직접 알려준 이름 우선
    peer_name = display_name or hostname
    if peer_name and peer_name not in ("localhost", "unknown", ""):
        return peer_name.lower().strip()

    # 2) 알려진 VPN IP 매핑
    known = {
        "10.99.85.143": "v1",
        "10.99.74.131": "v2",
        "10.99.249.158": "v3",
        "10.99.140.171": "v4",
        "10.99.74.111": "g1",
        "10.99.106.230": "g2",
        "10.99.202.68": "g3",
        "10.99.175.0": "g4",
        "10.99.208.200": "d1",
        "10.99.99.181": "d2",
        "10.99.173.50": "s2",
        "10.99.84.191": "m1",
    }
    if vpn_ip in known:
        return known[vpn_ip]

    # 3) 공인 IP로 추측 (VPS 릴레이)
    if public_ip in RELAY_IPS:
        idx = RELAY_IPS.index(public_ip) + 1
        return f"v{idx}"

    # 4) 마지막 옥텟 폴백
    last_octet = vpn_ip.split(".")[-1]
    return f"node-{last_octet}"


def get_my_vpn_ip() -> str:
    \"\"\"현재 서버의 VPN IP\"\"\"
    try:
        result = subprocess.run(
            "ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1",
            shell=True, capture_output=True, text=True
        )
        return result.stdout.strip()
    except:
        return ""


def get_network_topology():
    \"\"\"네트워크 토폴로지 정보\"\"\"
    with SERVERS_LOCK:
        servers = dict(SERVERS)

    relays = []
    clients = []

    for name, info in servers.items():
        node = {
            "name": name,
            "vpn_ip": info.get("vpn_ip", ""),
            "public_ip": info.get("public_ip", ""),
            "is_relay": info.get("is_relay", False),
            "icon": info.get("icon", "🖥️"),
        }
        if info.get("is_relay"):
            relays.append(node)
        else:
            clients.append(node)

    return {
        "relays": relays,
        "clients": clients,
        "total": len(servers),
    }

# Agent 설치 스크립트
INSTALL_SCRIPT = '''#!/bin/bash
set -e
AGENT_DIR="/opt/server-agent"
echo "=== Server Agent 설치 ==="
sudo mkdir -p $AGENT_DIR
echo "Agent 다운로드 중..."
sudo curl -sSL http://10.99.85.143:8721/agent.py -o $AGENT_DIR/agent-reporter.py
sudo chmod +x $AGENT_DIR/agent-reporter.py
echo "systemd 서비스 설정 중..."
sudo tee /etc/systemd/system/server-agent.service > /dev/null << 'SERVICEEOF'
[Unit]
Description=Server Monitoring Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/server-agent/agent-reporter.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
SERVICEEOF
sudo systemctl daemon-reload
sudo systemctl enable server-agent
sudo systemctl restart server-agent
echo "=== 설치 완료 ==="
sudo systemctl status server-agent --no-pager | head -5
'''

# Agent 코드 v2 - 향상된 모니터링 (macOS/Linux)
AGENT_CODE = '''#!/usr/bin/env python3
\"\"\"Server Agent v2 - Enhanced monitoring with macOS support\"\"\"
import subprocess, json, socket, time, urllib.request, urllib.error, platform, sys

DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "http://localhost:8721").rstrip("/") + "/api/report"
REPORT_INTERVAL = 30
IS_MACOS = platform.system() == "Darwin"
IS_LINUX = platform.system() == "Linux"

def run_cmd(cmd, timeout=10):
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        return result.stdout.strip()
    except: return ""

def get_uptime():
    if IS_MACOS:
        boot = run_cmd("sysctl -n kern.boottime | awk '{print $4}' | tr -d ','")
        if boot:
            try:
                uptime_sec = int(time.time()) - int(boot)
                days, hours, mins = uptime_sec // 86400, (uptime_sec % 86400) // 3600, (uptime_sec % 3600) // 60
                return f"{days}d {hours}h {mins}m" if days > 0 else f"{hours}h {mins}m" if hours > 0 else f"{mins}m"
            except: pass
        return run_cmd("uptime | awk '{print $3,$4}'").replace(",", "")
    return run_cmd("uptime -p 2>/dev/null").replace("up ", "") or "?"

def get_memory():
    result = {"memory": "?", "mem_pct": 0, "mem_used": 0, "mem_total": 0}
    if IS_MACOS:
        mem_info = run_cmd(\"\"\"
page_size=$(pagesize 2>/dev/null || echo 16384)
stats=$(vm_stat 2>/dev/null)
total=$(sysctl -n hw.memsize 2>/dev/null)
if [ -n "$stats" ] && [ -n "$total" ]; then
  active=$(echo "$stats" | awk '/Pages active/ {gsub(/\\./, "", $3); print $3}')
  wired=$(echo "$stats" | awk '/Pages wired/ {gsub(/\\./, "", $4); print $4}')
  compressed=$(echo "$stats" | awk '/Pages occupied by compressor/ {gsub(/\\./, "", $5); print $5}')
  used=$(( (${active:-0} + ${wired:-0} + ${compressed:-0}) * $page_size ))
  total_gb=$((total / 1073741824))
  used_gb=$((used / 1073741824))
  pct=$((used * 100 / total))
  echo "${used_gb}G/${total_gb}G $used $total $pct"
fi\"\"\")
        if mem_info:
            parts = mem_info.split()
            if len(parts) >= 4:
                result["memory"] = parts[0]
                result["mem_used"] = int(parts[1]) if parts[1].isdigit() else 0
                result["mem_total"] = int(parts[2]) if parts[2].isdigit() else 0
                result["mem_pct"] = int(parts[3]) if parts[3].isdigit() else 0
    else:
        mem = run_cmd("LANG=C free -b 2>/dev/null | grep Mem | awk '{print $3, $2, int($3/$2*100)}'")
        if mem:
            parts = mem.split()
            if len(parts) >= 3:
                used, total, pct = int(parts[0]), int(parts[1]), int(parts[2])
                result["mem_used"], result["mem_total"], result["mem_pct"] = used, total, pct
                result["memory"] = f"{used//1073741824}Gi/{total//1073741824}Gi" if total > 1073741824 else f"{used//1048576}Mi/{total//1048576}Mi"
    return result

def get_disk():
    result = {"disk_used": "?", "disk_pct": 0, "disk_free": "?"}
    disk = run_cmd("df -h / | tail -1 | awk '{print $5, $4, $3, $2}'").split()
    if disk:
        result["disk_used"] = disk[0]
        try: result["disk_pct"] = int(disk[0].replace("%", ""))
        except: pass
        if len(disk) > 1: result["disk_free"] = disk[1]
    return result

def get_all_disks():
    disks = []
    if IS_MACOS:
        # macOS df has more columns: Filesystem Size Used Avail Capacity iused ifree %iused Mounted
        df_output = run_cmd("df -h | grep -E '^/dev/' | grep -v 'devfs'")
        if df_output:
            for line in df_output.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 9:
                    try:
                        disks.append({"device": parts[0], "size": parts[1], "used": parts[2],
                                      "available": parts[3], "percent": int(parts[4].replace("%", "")), "mount": parts[-1]})
                    except: pass
    else:
        df_output = run_cmd("df -h -x tmpfs -x devtmpfs -x squashfs -x efivarfs | tail -n +2")
        if df_output:
            for line in df_output.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 6:
                    try:
                        disks.append({"device": parts[0], "size": parts[1], "used": parts[2],
                                      "available": parts[3], "percent": int(parts[4].replace("%", "")), "mount": parts[5]})
                    except: pass
    return disks

def get_load():
    if IS_MACOS:
        return run_cmd("sysctl -n vm.loadavg | awk '{print $2}'") or "?"
    return run_cmd("cat /proc/loadavg | awk '{print $1}'") or "?"

def get_cpu_usage():
    if IS_MACOS:
        cpu = run_cmd("top -l 1 -n 0 | grep 'CPU usage' | awk '{print $3}' | tr -d '%'")
    else:
        cpu = run_cmd("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
    try: return int(float(cpu))
    except: return 0

def get_vpn_ip():
    if IS_MACOS:
        vpn = run_cmd("for i in utun0 utun1 utun2 utun3 utun4 utun5; do IP=$(ifconfig $i 2>/dev/null | grep 'inet 10.99' | awk '{print $2}'); [ -n \\"$IP\\" ] && echo $IP && break; done")
    else:
        vpn = run_cmd("ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1")
    return vpn if vpn and "10.99" in vpn else ""

def get_docker():
    containers = run_cmd("docker ps --format '{{.Names}}:{{.Status}}' 2>/dev/null | head -10")
    if containers:
        result = []
        for line in containers.split('\\n'):
            if ':' in line:
                name, status = line.split(':', 1)
                result.append({"name": name, "status": "up" if "Up" in status else "down"})
        return result
    return []

def get_databases():
    dbs = {}
    if run_cmd("pgrep -x mysqld >/dev/null && echo yes") == "yes":
        dbs["mysql"] = {"running": True, "connectable": run_cmd("mysqladmin ping 2>/dev/null | grep -q alive && echo ok") == "ok"}
    if run_cmd("pgrep -x postgres >/dev/null && echo yes") == "yes":
        dbs["postgresql"] = {"running": True, "connectable": run_cmd("pg_isready 2>/dev/null | grep -q accepting && echo ok") == "ok"}
    if run_cmd("pgrep -x redis-server >/dev/null && echo yes") == "yes":
        dbs["redis"] = {"running": True, "connectable": run_cmd("redis-cli ping 2>/dev/null | grep -q PONG && echo ok") == "ok"}
    if run_cmd("pgrep -x mongod >/dev/null && echo yes") == "yes":
        dbs["mongodb"] = {"running": True}
    return dbs

def get_top_processes():
    if IS_MACOS:
        procs = run_cmd("ps -eo pid,pcpu,pmem,comm | sort -k2 -nr | head -6 | tail -5")
    else:
        procs = run_cmd("ps -eo pid,pcpu,pmem,comm --sort=-%cpu | head -6 | tail -5")
    result = []
    if procs:
        for line in procs.strip().split('\\n'):
            parts = line.split()
            if len(parts) >= 4:
                try:
                    cpu_val = float(parts[1])
                except:
                    cpu_val = 0.0
                result.append({"pid": parts[0], "cpu": cpu_val, "mem": parts[2], "name": parts[3]})
    return result

def get_latency():
    targets = {"v1": "10.99.85.143", "google": "8.8.8.8"}
    result = {}
    for name, ip in targets.items():
        ping = run_cmd(f"ping -c 1 -W 2 {ip} 2>/dev/null | grep 'time=' | awk -F'time=' '{{print $2}}' | awk '{{print $1}}'")
        try: result[name] = float(ping)
        except: result[name] = -1
    return result

def get_ports():
    if IS_MACOS:
        ports = run_cmd("lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1 {split($9,a,\\":\\"); print a[length(a)]}' | sort -nu | head -15")
    else:
        ports = run_cmd("ss -tlnp 2>/dev/null | awk 'NR>1 {split($4,a,\\":\\"); port=a[length(a)]; if(port>0 && port<65536) print port}' | sort -nu | head -15")
    return [int(p) for p in ports.split('\\n') if p.strip().isdigit()]

def get_services():
    services = {}
    for svc in ["wire", "docker", "nginx", "postgresql", "redis", "coturn", "icecast2", "liquidsoap-radio"]:
        if IS_LINUX:
            status = run_cmd(f"systemctl is-active {svc} 2>/dev/null")
            if status == "active": services[svc] = True
            elif status in ["inactive", "failed"]: services[svc] = False
        elif IS_MACOS:
            if run_cmd(f"pgrep -x {svc} >/dev/null && echo yes") == "yes": services[svc] = True
    return services

def get_security():
    result = {}
    ssh_sessions = run_cmd("who | grep -c '.' 2>/dev/null || echo 0")
    result["ssh_sessions"] = int(ssh_sessions) if ssh_sessions.isdigit() else 0
    if IS_LINUX:
        failed = run_cmd("journalctl -u sshd --since '1 hour ago' 2>/dev/null | grep -c 'Failed password' || echo 0")
        result["failed_logins_1h"] = int(failed) if failed.isdigit() else 0
    result["last_login"] = run_cmd("last -1 2>/dev/null | head -1 | awk '{print $1, $4, $5, $6}'") or "?"
    return result

def get_network_traffic():
    \"\"\"Get network interface traffic (bytes in/out)\"\"\"
    result = {"interfaces": [], "total_rx": 0, "total_tx": 0}
    if IS_MACOS:
        # macOS: netstat -ib
        net = run_cmd("netstat -ib | grep -E '^en[0-9]|^utun' | awk '{print $1, $7, $10}'")
        if net:
            for line in net.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 3:
                    try:
                        iface, rx, tx = parts[0], int(parts[1]), int(parts[2])
                        result["interfaces"].append({"name": iface, "rx": rx, "tx": tx})
                        if iface.startswith("en"):
                            result["total_rx"] += rx
                            result["total_tx"] += tx
                    except: pass
    else:
        # Linux: /proc/net/dev
        net = run_cmd("cat /proc/net/dev | grep -E 'eth|ens|enp|wlan|wire' | awk '{gsub(/:/, \\\"\\\"); print $1, $2, $10}'")
        if net:
            for line in net.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 3:
                    try:
                        iface, rx, tx = parts[0], int(parts[1]), int(parts[2])
                        result["interfaces"].append({"name": iface, "rx": rx, "tx": tx})
                        result["total_rx"] += rx
                        result["total_tx"] += tx
                    except: pass
    return result

def get_icecast_stats():
    \"\"\"Get Icecast streaming stats (for radio servers)\"\"\"
    result = {}
    # Check if icecast is running
    if run_cmd("pgrep icecast >/dev/null && echo yes") != "yes":
        return result
    # Try to get stats from icecast admin
    stats = run_cmd("curl -s --connect-timeout 2 http://localhost:8000/status-json.xsl 2>/dev/null")
    if stats:
        try:
            import json as _json
            data = _json.loads(stats)
            source = data.get("icestats", {}).get("source", {})
            if isinstance(source, list):
                source = source[0] if source else {}
            result["listeners"] = source.get("listeners", 0)
            result["peak_listeners"] = source.get("listener_peak", 0)
            result["title"] = source.get("title", "")
            result["bitrate"] = source.get("audio_bitrate", 0)
        except: pass
    return result

def get_status():
    status = {"hostname": socket.gethostname(), "timestamp": time.time(), "online": True,
              "os": "macOS" if IS_MACOS else "Linux", "uptime": get_uptime(), "load": get_load(), "cpu_pct": get_cpu_usage()}
    status.update(get_memory())
    status.update(get_disk())
    status["disks"] = get_all_disks()
    status["vpn_ip"] = get_vpn_ip()
    status["public_ip"] = run_cmd("curl -s --connect-timeout 3 ifconfig.me 2>/dev/null") or "?"
    status["latency"] = get_latency()
    status["ports"] = get_ports()
    status["services"] = get_services()
    status["docker"] = get_docker()
    status["databases"] = get_databases()
    status["top_processes"] = get_top_processes()
    status["security"] = get_security()
    status["network"] = get_network_traffic()
    status["icecast"] = get_icecast_stats()
    return status

def send_report(status):
    try:
        data = json.dumps(status).encode()
        req = urllib.request.Request(DASHBOARD_URL, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=10) as resp: return resp.status == 200
    except Exception as e:
        print(f"Report failed: {e}")
        return False

if __name__ == "__main__":
    print(f"Server Agent v2 starting on {socket.gethostname()}")
    print(f"OS: {'macOS' if IS_MACOS else 'Linux'}, Reporting to: {DASHBOARD_URL}")
    sys.stdout.flush()
    while True:
        try:
            status = get_status()
            ok = send_report(status)
            print(f"[{time.strftime('%H:%M:%S')}] {'OK' if ok else 'FAIL'} | mem={status.get('memory')} disk={status.get('disk_used')} load={status.get('load')}")
            sys.stdout.flush()
        except Exception as e:
            print(f"Error: {e}")
            sys.stdout.flush()
        time.sleep(REPORT_INTERVAL)
'''

cache = {"servers": [], "vpn": "", "last_update": None, "agent_reports": {}}
cache_lock = threading.Lock()

# SSH 실패 캐시 (30초간 재시도 안함)
ssh_fail_cache = {}
ssh_fail_lock = threading.Lock()

# ==================== 히스토리 기능 ====================
HISTORY_DIR = "/var/lib/server-agent"
HISTORY_FILE = os.path.join(HISTORY_DIR, "history.jsonl")
HISTORY_INTERVAL = 300  # 5분마다 저장
HISTORY_MAX_DAYS = 7    # 7일간 보관
history_lock = threading.Lock()

def ensure_history_dir():
    \"\"\"히스토리 디렉토리 생성\"\"\"
    try:
        os.makedirs(HISTORY_DIR, exist_ok=True)
    except:
        pass

def save_history_snapshot():
    \"\"\"현재 상태를 히스토리에 저장\"\"\"
    ensure_history_dir()
    with cache_lock:
        servers = cache.get("servers", [])
        if not servers:
            return
        snapshot = {
            "ts": datetime.now().isoformat(),
            "servers": []
        }
        for s in servers:
            snapshot["servers"].append({
                "name": s.get("name"),
                "online": s.get("online", False),
                "mem_pct": s.get("mem_pct", 0),
                "disk_pct": int(str(s.get("disk_used", "0")).replace("%", "") or 0),
                "load": s.get("load", "0"),
                "source": s.get("source", "ssh"),
            })
    try:
        with history_lock:
            with open(HISTORY_FILE, "a") as f:
                f.write(json.dumps(snapshot) + "\n")
    except Exception as e:
        print(f"[History] Save error: {e}")

def cleanup_old_history():
    \"\"\"오래된 히스토리 정리 (7일 이상)\"\"\"
    try:
        cutoff = datetime.now() - timedelta(days=HISTORY_MAX_DAYS)
        cutoff_str = cutoff.isoformat()

        with history_lock:
            if not os.path.exists(HISTORY_FILE):
                return

            lines = []
            with open(HISTORY_FILE, "r") as f:
                for line in f:
                    try:
                        record = json.loads(line.strip())
                        if record.get("ts", "") >= cutoff_str:
                            lines.append(line)
                    except:
                        pass

            with open(HISTORY_FILE, "w") as f:
                f.writelines(lines)

        print(f"[History] Cleanup done, kept {len(lines)} records")
    except Exception as e:
        print(f"[History] Cleanup error: {e}")

def load_history(hours: int = 24) -> list:
    \"\"\"최근 N시간 히스토리 로드\"\"\"
    try:
        cutoff = datetime.now() - timedelta(hours=hours)
        cutoff_str = cutoff.isoformat()

        records = []
        with history_lock:
            if not os.path.exists(HISTORY_FILE):
                return []
            with open(HISTORY_FILE, "r") as f:
                for line in f:
                    try:
                        record = json.loads(line.strip())
                        if record.get("ts", "") >= cutoff_str:
                            records.append(record)
                    except:
                        pass
        return records
    except:
        return []

def get_trends(hours: int = 24) -> dict:
    \"\"\"서버별 트렌드 데이터 생성\"\"\"
    records = load_history(hours)
    if not records:
        return {"error": "No history data"}

    trends = {}
    for record in records:
        ts = record.get("ts", "")[:16]  # YYYY-MM-DDTHH:MM
        for server in record.get("servers", []):
            name = server.get("name")
            if name not in trends:
                trends[name] = {"timestamps": [], "mem": [], "disk": [], "online": []}
            trends[name]["timestamps"].append(ts)
            trends[name]["mem"].append(server.get("mem_pct", 0))
            trends[name]["disk"].append(server.get("disk_pct", 0))
            trends[name]["online"].append(1 if server.get("online") else 0)

    for name, data in trends.items():
        if data["mem"]:
            data["mem_avg"] = round(sum(data["mem"]) / len(data["mem"]), 1)
            data["mem_max"] = max(data["mem"])
        if data["disk"]:
            data["disk_avg"] = round(sum(data["disk"]) / len(data["disk"]), 1)
            data["disk_max"] = max(data["disk"])
        if data["online"]:
            data["uptime_pct"] = round(sum(data["online"]) / len(data["online"]) * 100, 1)
        data["records"] = len(data["timestamps"])

    return {
        "hours": hours,
        "total_records": len(records),
        "servers": trends
    }

def history_worker():
    \"\"\"백그라운드 히스토리 저장 워커\"\"\"
    cleanup_counter = 0
    while True:
        time.sleep(HISTORY_INTERVAL)
        try:
            save_history_snapshot()
            cleanup_counter += 1
            # 12시간마다 정리 (12 * 5분 = 144회)
            if cleanup_counter >= 144:
                cleanup_old_history()
                cleanup_counter = 0
        except Exception as e:
            print(f"[History] Worker error: {e}")

def ssh_cmd(host: str, cmd: str, timeout: int = 8, local: bool = False) -> str:
    with ssh_fail_lock:
        if host in ssh_fail_cache:
            if time.time() - ssh_fail_cache[host] < 30:
                return "TIMEOUT"
    try:
        if local or host == "localhost":
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        else:
            result = subprocess.run(
                ["ssh", "-o", "ConnectTimeout=2", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", host, cmd],
                capture_output=True, text=True, timeout=timeout
            )
        with ssh_fail_lock:
            ssh_fail_cache.pop(host, None)
        return result.stdout.strip()
    except subprocess.TimeoutExpired:
        with ssh_fail_lock:
            ssh_fail_cache[host] = time.time()
        return "TIMEOUT"
    except Exception as e:
        with ssh_fail_lock:
            ssh_fail_cache[host] = time.time()
        return f"ERROR: {e}"

def get_server_status(name: str, info: dict) -> dict:
    host = info["host"]
    local = info.get("local", False)
    status = {
        "name": name,
        "host": host,
        "desc": info["desc"],
        "icon": info.get("icon", "🖥️"),
        "online": False
    }

    uptime = ssh_cmd(host, "uptime -p 2>/dev/null || uptime | awk '{print $3,$4}'", local=local)
    if uptime.startswith("ERROR") or uptime == "TIMEOUT":
        status["error"] = uptime
        return status

    status["online"] = True
    status["uptime"] = uptime.replace("up ", "")

    # 디스크 (루트)
    disk = ssh_cmd(host, "df -h / | tail -1 | awk '{print $5, $4, $3, $2}'", local=local)
    if disk and not disk.startswith("ERROR"):
        parts = disk.split()
        status["disk_used"] = parts[0] if parts else "?"
        status["disk_free"] = parts[1] if len(parts) > 1 else "?"
        status["disk_used_size"] = parts[2] if len(parts) > 2 else "?"
        status["disk_total"] = parts[3] if len(parts) > 3 else "?"
        try:
            status["disk_pct"] = int(parts[0].replace("%", ""))
        except:
            status["disk_pct"] = 0

    # 모든 디스크 (SSH 모드에서도 수집)
    all_disks = ssh_cmd(host, "df -h -x tmpfs -x devtmpfs -x squashfs -x efivarfs 2>/dev/null | tail -n +2 | awk '{print $1,$2,$3,$4,$5,$6}'", local=local)
    if all_disks and not all_disks.startswith("ERROR"):
        disks = []
        for line in all_disks.strip().split('\n'):
            parts = line.split()
            if len(parts) >= 6:
                try:
                    pct = int(parts[4].replace("%", ""))
                    disks.append({
                        "device": parts[0],
                        "size": parts[1],
                        "used": parts[2],
                        "available": parts[3],
                        "percent": pct,
                        "mount": parts[5]
                    })
                except:
                    pass
        if disks:
            status["disks"] = disks

    # 메모리 (LANG=C로 영어 출력 강제)
    mem = ssh_cmd(host, "LANG=C free -h 2>/dev/null | grep Mem | awk '{print $3\"/\"$2, $3, $2}'", local=local)
    if not mem or mem.startswith("ERROR") or "free:" in mem.lower():
        # macOS: vm_stat + sysctl로 메모리 계산
        mac_mem = ssh_cmd(host, \"\"\"
            page_size=$(pagesize 2>/dev/null || echo 4096)
            stats=$(vm_stat 2>/dev/null)
            if [ -n "$stats" ]; then
                pages_free=$(echo "$stats" | awk '/Pages free/ {gsub(/\\./, "", $3); print $3}')
                pages_active=$(echo "$stats" | awk '/Pages active/ {gsub(/\\./, "", $3); print $3}')
                pages_inactive=$(echo "$stats" | awk '/Pages inactive/ {gsub(/\\./, "", $3); print $3}')
                pages_wired=$(echo "$stats" | awk '/Pages wired/ {gsub(/\\./, "", $4); print $4}')
                pages_compressed=$(echo "$stats" | awk '/Pages occupied by compressor/ {gsub(/\\./, "", $5); print $5}')
                total_bytes=$(sysctl -n hw.memsize 2>/dev/null)
                used=$((($pages_active + $pages_wired + ${pages_compressed:-0}) * $page_size))
                total_gb=$((total_bytes / 1073741824))
                used_gb=$((used / 1073741824))
                pct=$((used * 100 / total_bytes))
                echo "${used_gb}G/${total_gb}G $pct"
            fi
        \"\"\", local=local)
        if mac_mem and not mac_mem.startswith("ERROR"):
            parts = mac_mem.strip().split()
            status["memory"] = parts[0] if parts else "?"
            try:
                status["mem_pct"] = int(parts[1]) if len(parts) > 1 else 0
            except:
                status["mem_pct"] = 0
        else:
            status["memory"] = "?"
            status["mem_pct"] = 0
    else:
        parts = mem.split()
        status["memory"] = parts[0] if parts else "?"
        try:
            used = parts[1] if len(parts) > 1 else "0"
            total = parts[2] if len(parts) > 2 else "1"
            def parse_mem(s):
                s = s.lower().replace("i", "")
                if "g" in s: return float(s.replace("g", "")) * 1024
                if "m" in s: return float(s.replace("m", ""))
                return float(s)
            status["mem_pct"] = int(parse_mem(used) / parse_mem(total) * 100)
        except:
            status["mem_pct"] = 0

    # CPU 로드
    load = ssh_cmd(host, "cat /proc/loadavg 2>/dev/null | awk '{print $1}' || sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}'", local=local)
    status["load"] = load if load and not load.startswith("ERROR") else "?"

    services = info.get("services", [])
    status["services"] = {}
    for svc in services:
        svc_status = ssh_cmd(host, f"systemctl is-active {svc} 2>/dev/null || echo 'inactive'", local=local)
        status["services"][svc] = svc_status == "active"

    # 공개 IP
    public_ip = ssh_cmd(host, "curl -s --connect-timeout 2 ifconfig.me 2>/dev/null || curl -s --connect-timeout 2 icanhazip.com 2>/dev/null", local=local)
    status["public_ip"] = public_ip if public_ip and not public_ip.startswith("ERROR") else "?"

    # VPN IP
    vpn_ip = ssh_cmd(host, \"\"\"
        ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 || \
        ip addr show wg0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 || \
        for i in utun0 utun1 utun2 utun3 utun4; do
            IP=$(ifconfig $i 2>/dev/null | grep 'inet 10.99' | awk '{print $2}')
            [ -n "$IP" ] && echo $IP && break
        done
    \"\"\", local=local)
    vpn = vpn_ip.strip() if vpn_ip else ""
    status["vpn_ip"] = vpn if "10.99" in vpn else ""

    # 주요 프로세스/인스턴스
    processes = ssh_cmd(host, \"\"\"
        # Docker 컨테이너
        docker ps --format '{{.Names}}' 2>/dev/null | head -5 | while read c; do echo "🐳 $c"; done
        # Node.js
        pgrep -fa 'node ' 2>/dev/null | grep -v grep | awk '{print "📦 " $NF}' | head -3
        # Python 서버
        pgrep -fa 'python.*serve|python.*server|uvicorn|gunicorn' 2>/dev/null | grep -v grep | awk '{for(i=2;i<=NF;i++) if($i ~ /\.py|serve|server/) {print "🐍 " $i; break}}' | head -3
        # Nginx
        pgrep nginx >/dev/null 2>&1 && echo "🌐 nginx"
        # PostgreSQL
        pgrep postgres >/dev/null 2>&1 && echo "🐘 postgres"
        # Redis
        pgrep redis >/dev/null 2>&1 && echo "📮 redis"
        # MySQL/MariaDB
        pgrep -f 'mysql|mariadb' >/dev/null 2>&1 && echo "🗃️ mysql"
        # Icecast
        pgrep icecast >/dev/null 2>&1 && echo "📻 icecast"
        # Liquidsoap
        pgrep liquidsoap >/dev/null 2>&1 && echo "🎵 liquidsoap"
    \"\"\", timeout=15, local=local)
    if processes and not processes.startswith("ERROR"):
        status["processes"] = [p.strip() for p in processes.strip().split('\n') if p.strip()][:10]
    else:
        status["processes"] = []

    # 열린 포트 (주요 서비스)
    ports = ssh_cmd(host, \"\"\"
        ss -tlnp 2>/dev/null | awk 'NR>1 {split($4,a,":"); port=a[length(a)]; if(port ~ /^[0-9]+$/ && port > 0 && port < 65536) print port}' | sort -nu | head -15 || \
        netstat -tlnp 2>/dev/null | awk 'NR>2 {split($4,a,":"); print a[length(a)]}' | sort -nu | head -15 || \
        lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1 {split($9,a,":"); print a[length(a)]}' | sort -nu | head -15
    \"\"\", timeout=10, local=local)
    if ports and not ports.startswith("ERROR"):
        status["ports"] = [int(p) for p in ports.strip().split('\n') if p.strip().isdigit()][:15]
    else:
        status["ports"] = []

    firewall = ssh_cmd(host, \"\"\"
        if command -v ufw >/dev/null 2>&1; then
            status=$(ufw status 2>/dev/null | head -1)
            if echo "$status" | grep -q "active"; then
                echo "ufw:active"
            else
                echo "ufw:inactive"
            fi
        elif command -v firewall-cmd >/dev/null 2>&1; then
            if firewall-cmd --state 2>/dev/null | grep -q running; then
                echo "firewalld:active"
            else
                echo "firewalld:inactive"
            fi
        elif [ -f /etc/pf.conf ]; then
            echo "pf:macOS"
        else
            echo "none"
        fi
    \"\"\", timeout=5, local=local)
    status["firewall"] = firewall.strip() if firewall and not firewall.startswith("ERROR") else "unknown"

    # 최근 백업 (일반적인 백업 디렉토리 확인)
    backup = ssh_cmd(host, \"\"\"
        for dir in /root/backups /var/backups /backup ~/backups; do
            if [ -d "$dir" ]; then
                latest=$(ls -t "$dir" 2>/dev/null | head -1)
                if [ -n "$latest" ]; then
                    date=$(stat -c %Y "$dir/$latest" 2>/dev/null || stat -f %m "$dir/$latest" 2>/dev/null)
                    if [ -n "$date" ]; then
                        days=$(( ($(date +%s) - $date) / 86400 ))
                        echo "$dir:$days days ago"
                        exit 0
                    fi
                fi
            fi
        done
        echo "none"
    \"\"\", timeout=10, local=local)
    status["backup"] = backup.strip() if backup and not backup.startswith("ERROR") else "unknown"

    return status

def get_wire_status() -> str:
    try:
        result = subprocess.run(
            ["python3", "-m", "wire", "status"],
            capture_output=True, text=True, timeout=30,
            cwd=os.path.expanduser("~/wire")
        )
        return result.stdout.strip()
    except Exception as e:
        return f"Error: {e}"

def update_cache():
    \"\"\"백그라운드에서 캐시 업데이트 - Agent 리포트 우선, SSH 폴백\"\"\"
    while True:
        try:
            # VPN 피어 목록 자동 갱신
            peer_count = fetch_vpn_peers()
            if peer_count > 0:
                print(f"[VPN] {peer_count} peers discovered")

            results = []
            now = time.time()

            # Agent 리포트 확인 (60초 이내면 유효)
            with cache_lock:
                agent_reports = cache.get("agent_reports", {})

            # 각 서버에 대해: agent 리포트 있으면 사용, 없으면 SSH 폴링
            servers_to_poll = []
            for name, info in SERVERS.items():
                # hostname으로 agent 리포트 찾기
                agent_data = None
                for hostname, server_name in HOSTNAME_MAP.items():
                    if server_name == name and hostname in agent_reports:
                        report = agent_reports[hostname]
                        if now - report.get('last_seen', 0) < 60:
                            agent_data = report
                            break

                if agent_data:
                    # Agent 리포트 사용
                    status = {
                        "name": name,
                        "host": info["host"],
                        "desc": info["desc"],
                        "icon": info.get("icon", "🖥️"),
                        "online": True,
                        "source": "agent",
                        **{k: v for k, v in agent_data.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                    }
                    results.append(status)
                else:
                    servers_to_poll.append((name, info))

            # SSH 폴링 (agent 리포트 없는 서버들만)
            if servers_to_poll:
                with ThreadPoolExecutor(max_workers=6) as executor:
                    futures = {executor.submit(get_server_status, name, info): name
                               for name, info in servers_to_poll}
                    for future in as_completed(futures):
                        result = future.result()
                        result["source"] = "ssh"
                        results.append(result)

            results.sort(key=lambda x: x["name"])

            vpn_status = get_wire_status()

            with cache_lock:
                cache["servers"] = results
                cache["vpn"] = vpn_status
                cache["last_update"] = datetime.now().isoformat()
        except Exception as e:
            print(f"Cache update error: {e}")

        time.sleep(30)  # 30초마다 업데이트

HTML = '''<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Server Dashboard</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            color: #eee;
            padding: 20px;
        }
        .header {
            text-align: center;
            margin-bottom: 30px;
        }
        .header h1 {
            font-size: 2em;
            background: linear-gradient(90deg, #00d4ff, #00ff88);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            margin-bottom: 5px;
        }
        .header .update-time {
            color: #888;
            font-size: 0.9em;
        }
        .grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
            gap: 20px;
            max-width: 1400px;
            margin: 0 auto;
        }
        .card {
            background: rgba(255,255,255,0.05);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(255,255,255,0.1);
            transition: transform 0.2s, box-shadow 0.2s;
        }
        .card:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 40px rgba(0,0,0,0.3);
        }
        .card.offline {
            opacity: 0.5;
            border-color: #ff4757;
        }
        .card-header {
            display: flex;
            align-items: center;
            gap: 12px;
            margin-bottom: 15px;
        }
        .card-icon {
            font-size: 2em;
        }
        .card-title {
            flex: 1;
        }
        .card-title h2 {
            font-size: 1.3em;
            margin-bottom: 2px;
        }
        .card-title .desc {
            color: #888;
            font-size: 0.85em;
        }
        .status-badge {
            padding: 4px 12px;
            border-radius: 20px;
            font-size: 0.8em;
            font-weight: 600;
        }
        .status-online { background: #00b894; color: #fff; }
        .status-warning { background: #fdcb6e; color: #2d3436; }
        .status-danger { background: #ff4757; color: #fff; }
        .status-offline { background: #636e72; color: #fff; }

        .metrics {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 12px;
            margin-bottom: 15px;
        }
        .metric {
            background: rgba(0,0,0,0.2);
            padding: 12px;
            border-radius: 10px;
        }
        .metric-label {
            font-size: 0.75em;
            color: #888;
            text-transform: uppercase;
            margin-bottom: 4px;
        }
        .metric-value {
            font-size: 1.1em;
            font-weight: 600;
        }
        .progress-bar {
            height: 6px;
            background: rgba(255,255,255,0.1);
            border-radius: 3px;
            margin-top: 8px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            border-radius: 3px;
            transition: width 0.3s;
        }
        .progress-ok { background: linear-gradient(90deg, #00b894, #00cec9); }
        .progress-warn { background: linear-gradient(90deg, #fdcb6e, #e17055); }
        .progress-danger { background: linear-gradient(90deg, #ff4757, #c0392b); }

        .services {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        .service-tag {
            padding: 4px 10px;
            border-radius: 6px;
            font-size: 0.8em;
            display: flex;
            align-items: center;
            gap: 5px;
        }
        .service-active { background: rgba(0,184,148,0.2); color: #00b894; }
        .service-inactive { background: rgba(255,71,87,0.2); color: #ff4757; }

        .processes {
            margin-top: 12px;
            padding: 10px;
            background: rgba(0,0,0,0.2);
            border-radius: 8px;
        }
        .processes-title {
            font-size: 0.75em;
            color: #888;
            text-transform: uppercase;
            margin-bottom: 6px;
        }
        .process-list {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
        }
        .process-tag {
            padding: 3px 8px;
            background: rgba(0,212,255,0.15);
            color: #00d4ff;
            border-radius: 4px;
            font-size: 0.8em;
        }

        .ip-info {
            margin-top: 12px;
            padding-top: 12px;
            border-top: 1px solid rgba(255,255,255,0.1);
            font-size: 0.85em;
            color: #aaa;
        }
        .ip-info span { margin-right: 15px; }

        .extra-info {
            margin-top: 10px;
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            font-size: 0.75em;
        }
        .info-tag {
            padding: 3px 8px;
            border-radius: 4px;
            background: rgba(255,255,255,0.1);
        }
        .info-tag.ok { background: rgba(0,184,148,0.2); color: #00b894; }
        .info-tag.warn { background: rgba(253,203,110,0.2); color: #fdcb6e; }
        .info-tag.danger { background: rgba(255,71,87,0.2); color: #ff4757; }

        .topology-section, .vpn-section, .ai-section {
            max-width: 1400px;
            margin: 30px auto 0;
            background: rgba(255,255,255,0.05);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(255,255,255,0.1);
        }
        .topology-section h3, .vpn-section h3, .ai-section h3 {
            margin-bottom: 15px;
            color: #00d4ff;
        }
        .topology-container {
            position: relative;
            width: 100%;
            height: 500px;
            background: linear-gradient(180deg, rgba(0,20,40,0.8) 0%, rgba(0,10,30,0.9) 100%);
            border-radius: 12px;
            overflow: hidden;
        }
        .topology-svg {
            width: 100%;
            height: 100%;
        }
        .topo-group-bg {
            fill: rgba(255,255,255,0.02);
            stroke: rgba(255,255,255,0.08);
            stroke-width: 1;
            stroke-dasharray: 5,5;
        }
        .topo-group-label {
            fill: rgba(255,255,255,0.4);
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 2px;
        }
        .topo-link {
            fill: none;
            stroke-linecap: round;
        }
        .topo-link-bg {
            stroke: rgba(0,212,255,0.1);
            stroke-width: 8;
        }
        .topo-link-line {
            stroke: rgba(0,212,255,0.4);
            stroke-width: 2;
        }
        .topo-link-active .topo-link-line {
            stroke: #00d4ff;
            stroke-width: 3;
            filter: drop-shadow(0 0 6px #00d4ff);
        }
        .topo-link-flow {
            stroke: #00ff88;
            stroke-width: 4;
            stroke-dasharray: 8,12;
            animation: flow 1s linear infinite;
        }
        @keyframes flow {
            to { stroke-dashoffset: -20; }
        }
        .topo-node {
            cursor: pointer;
        }
        .topo-node-glow {
            fill: none;
            stroke-width: 2;
            opacity: 0.5;
            animation: glow 2s ease-in-out infinite;
        }
        @keyframes glow {
            0%, 100% { opacity: 0.3; stroke-width: 2; }
            50% { opacity: 0.7; stroke-width: 4; }
        }
        .topo-node-ring {
            fill: none;
            stroke-width: 3;
            opacity: 0.8;
        }
        .topo-node-core {
            filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5));
        }
        .topo-node-icon {
            font-size: 20px;
            text-anchor: middle;
            dominant-baseline: middle;
        }
        .topo-node-name {
            fill: #fff;
            font-size: 13px;
            font-weight: 600;
            text-anchor: middle;
            text-shadow: 0 2px 4px rgba(0,0,0,0.8);
        }
        .topo-node-status {
            fill: #aaa;
            font-size: 10px;
            text-anchor: middle;
        }
        .topo-node-badge {
            font-size: 9px;
            fill: #fff;
        }
        .topo-tooltip {
            position: absolute;
            background: rgba(20,30,50,0.95);
            border: 1px solid rgba(0,212,255,0.5);
            border-radius: 10px;
            padding: 12px 16px;
            font-size: 12px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.2s;
            z-index: 100;
            min-width: 180px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.5);
        }
        .topo-tooltip.show { opacity: 1; }
        .topo-tooltip h4 { color: #00d4ff; margin-bottom: 8px; font-size: 14px; }
        .topo-tooltip-row { display: flex; justify-content: space-between; margin: 4px 0; }
        .topo-tooltip-label { color: #888; }
        .topo-tooltip-value { color: #fff; font-weight: 500; }
        .topo-legend {
            position: absolute;
            bottom: 15px;
            left: 15px;
            background: rgba(0,0,0,0.6);
            padding: 12px 15px;
            border-radius: 10px;
            font-size: 11px;
            backdrop-filter: blur(10px);
        }
        .topo-legend-title { color: #888; margin-bottom: 8px; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; }
        .topo-legend-item { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
        .topo-legend-dot { width: 12px; height: 12px; border-radius: 50%; }
        .topo-stats {
            position: absolute;
            top: 15px;
            right: 15px;
            background: rgba(0,0,0,0.6);
            padding: 12px 15px;
            border-radius: 10px;
            font-size: 12px;
            backdrop-filter: blur(10px);
        }
        .topo-stats-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; }
        .topo-stats-num { font-size: 18px; font-weight: 700; color: #00d4ff; }
        .topo-stats-label { color: #888; }
        .vpn-output {
            font-family: 'Monaco', 'Menlo', monospace;
            font-size: 0.85em;
            white-space: pre;
            overflow-x: auto;
            background: rgba(0,0,0,0.3);
            padding: 15px;
            border-radius: 8px;
            line-height: 1.5;
        }

        .ai-input-row {
            display: flex;
            gap: 10px;
            margin-bottom: 15px;
        }
        .ai-input {
            flex: 1;
            padding: 12px 15px;
            border: 1px solid rgba(255,255,255,0.2);
            border-radius: 8px;
            background: rgba(0,0,0,0.3);
            color: #fff;
            font-size: 1em;
        }
        .ai-input:focus {
            outline: none;
            border-color: #00d4ff;
        }
        .ai-btn {
            padding: 12px 25px;
            background: linear-gradient(135deg, #00d4ff, #00ff88);
            border: none;
            border-radius: 8px;
            color: #1a1a2e;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.2s;
        }
        .ai-btn:hover { transform: scale(1.05); }
        .ai-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .ai-response {
            background: rgba(0,0,0,0.3);
            padding: 15px;
            border-radius: 8px;
            min-height: 100px;
            white-space: pre-wrap;
            line-height: 1.6;
        }
        .ai-response.loading {
            color: #888;
            animation: pulse 1.5s infinite;
        }
        @keyframes pulse { 50% { opacity: 0.5; } }

        .refresh-btn {
            position: fixed;
            bottom: 20px;
            right: 20px;
            width: 50px;
            height: 50px;
            border-radius: 50%;
            background: linear-gradient(135deg, #00d4ff, #00ff88);
            border: none;
            cursor: pointer;
            font-size: 1.5em;
            color: #1a1a2e;
            box-shadow: 0 4px 15px rgba(0,212,255,0.4);
            transition: transform 0.2s;
        }
        .refresh-btn:hover { transform: scale(1.1); }
        .refresh-btn.loading { animation: spin 1s linear infinite; }
        @keyframes spin { 100% { transform: rotate(360deg); } }
    </style>
</head>
<body>
    <div class="header">
        <h1>Server Dashboard</h1>
        <div class="update-time" id="updateTime">Loading...</div>
    </div>

    <div class="grid" id="serverGrid"></div>

    <div class="topology-section">
        <h3>Network Topology</h3>
        <div class="topology-container" id="topoContainer">
            <svg class="topology-svg" id="topologySvg">
                <defs>
                    <linearGradient id="linkGradient" x1="0%" y1="0%" x2="100%" y2="0%">
                        <stop offset="0%" style="stop-color:#00d4ff;stop-opacity:0.8"/>
                        <stop offset="100%" style="stop-color:#00ff88;stop-opacity:0.8"/>
                    </linearGradient>
                    <filter id="glow">
                        <feGaussianBlur stdDeviation="3" result="blur"/>
                        <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
                    </filter>
                    <radialGradient id="nodeGradient" cx="30%" cy="30%">
                        <stop offset="0%" style="stop-color:#fff;stop-opacity:0.3"/>
                        <stop offset="100%" style="stop-color:#000;stop-opacity:0.1"/>
                    </radialGradient>
                </defs>
            </svg>
            <div class="topo-tooltip" id="topoTooltip"></div>
            <div class="topo-legend">
                <div class="topo-legend-title">Status</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#00b894"></span> Online</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#fdcb6e"></span> Warning (80%+)</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#ff4757"></span> Critical (90%+)</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#636e72"></span> Offline</div>
            </div>
            <div class="topo-stats" id="topoStats"></div>
        </div>
    </div>

    <div class="vpn-section">
        <h3>Autowire VPN Status</h3>
        <div class="vpn-output" id="vpnOutput">Loading...</div>
    </div>

    <div class="ai-section">
        <h3>AI Assistant</h3>
        <div class="ai-input-row">
            <input type="text" class="ai-input" id="aiInput"
                   placeholder="서버 문제를 설명하세요... (예: g2 디스크 정리해줘, v2 메모리 부족 원인 분석)"
                   onkeypress="if(event.key==='Enter') askAI()">
            <select id="aiModel" style="padding:8px 12px;border-radius:8px;background:#1a2332;color:#4ec9b0;border:1px solid #2d3748;font-size:13px;cursor:pointer">
                <option value="ollama">🦙 Local LLM (Ollama)</option>
                <option value="gpt">🤖 GPT-4o-mini</option>
                <option value="claude">🔮 Claude CLI</option>
                <option value="template">📋 Template (No AI)</option>
            </select>
            <button class="ai-btn" onclick="askAI()" id="aiBtn">분석</button>
        </div>
        <div class="ai-response" id="aiResponse">
서버 상태를 분석하거나 문제 해결 방법을 물어보세요.

예시:
• "현재 서버 상태 요약해줘"
• "디스크 사용량이 높은 서버 확인"
• "v2 서버 디스크 정리 방법"
• "백업 상태 점검"
        </div>
    </div>

    <button class="refresh-btn" onclick="refresh()" id="refreshBtn">↻</button>

    <script>
        function getStatusClass(server) {
            if (!server.online) return 'offline';
            if (server.disk_pct >= 90) return 'danger';
            if (server.disk_pct >= 80) return 'warning';
            return 'online';
        }

        function getNodeColor(server) {
            if (!server.online) return '#636e72';
            if (server.disk_pct >= 90) return '#ff4757';
            if (server.disk_pct >= 80) return '#fdcb6e';
            return '#00b894';
        }

        // 서버 그룹 분류 함수
        function classifyServer(name) {
            if (name.startsWith('v')) return 'cloud';
            if (name.startsWith('g')) return 'home';
            if (name.startsWith('d')) return 'office';
            if (name.toLowerCase().includes('mac') || name.startsWith('m')) return 'mobile';
            return 'other';
        }

        let topoServers = [];

        function renderTopology(servers) {
            topoServers = servers;
            const svg = document.getElementById('topologySvg');
            const container = document.getElementById('topoContainer');
            const rect = container.getBoundingClientRect();
            const width = rect.width || 900;
            const height = rect.height || 500;

            // 동적 위치 계산
            const groups = { cloud: [], home: [], office: [], mobile: [], other: [] };
            servers.forEach(s => {
                const group = classifyServer(s.name);
                groups[group].push(s.name);
            });

            const positions = {};
            // Cloud (상단 중앙)
            groups.cloud.forEach((name, i) => {
                positions[name] = { x: width * (0.4 + i * 0.15), y: height * 0.15 };
            });
            // Home (좌측)
            groups.home.forEach((name, i) => {
                positions[name] = { x: width * 0.15, y: height * (0.3 + i * 0.15) };
            });
            // Office (우측)
            groups.office.forEach((name, i) => {
                positions[name] = { x: width * 0.85, y: height * (0.4 + i * 0.15) };
            });
            // Mobile (하단)
            groups.mobile.forEach((name, i) => {
                positions[name] = { x: width * (0.45 + i * 0.1), y: height * 0.85 };
            });
            // Other (중앙 아래)
            groups.other.forEach((name, i) => {
                positions[name] = { x: width * (0.3 + i * 0.1), y: height * 0.7 };
            });

            // 노드 데이터 생성
            const nodes = servers.map(s => ({
                ...s,
                x: positions[s.name]?.x || width/2,
                y: positions[s.name]?.y || height/2
            }));

            // 첫 번째 온라인 릴레이를 허브로 사용
            const relayNode = nodes.find(n => n.name.startsWith('v') && n.online) || nodes.find(n => n.name.startsWith('v'));

            // SVG 생성
            let html = '';

            // 동적 그룹 배경 (서버가 있는 그룹만 표시)
            const groupLabels = {
                cloud: '☁️ Cloud (Relays)',
                home: '🏠 집 (Home)',
                office: '🏢 회사 (Office)',
                mobile: '💻 Mobile',
                other: '🖥️ Other'
            };

            const groupBoxes = {
                cloud: { x: width * 0.35, y: height * 0.05, w: width * 0.4, h: height * 0.2 },
                home: { x: width * 0.05, y: height * 0.25, w: width * 0.25, h: height * 0.5 },
                office: { x: width * 0.75, y: height * 0.35, w: width * 0.2, h: height * 0.35 },
                mobile: { x: width * 0.38, y: height * 0.78, w: width * 0.24, h: height * 0.18 },
                other: { x: width * 0.25, y: height * 0.65, w: width * 0.25, h: height * 0.15 }
            };

            Object.entries(groups).forEach(([key, members]) => {
                if (members.length === 0) return;
                const box = groupBoxes[key];
                if (!box) return;
                html += `<rect class="topo-group-bg" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="15"/>`;
                html += `<text class="topo-group-label" x="${box.x + 10}" y="${box.y + 18}">${groupLabels[key]} (${members.length})</text>`;
            });

            // 연결선 (v1을 허브로)
            if (relayNode) {
                nodes.forEach(n => {
                    if (!n.name.startsWith('v')) {  // 릴레이가 아닌 노드만
                        const hasVpn = n.vpn_ip && n.vpn_ip.startsWith('10.99');
                        const isAgent = n.source && n.source.includes('age');
                        const isActive = n.online && hasVpn;

                        // 베지어 곡선으로 연결
                        const midX = (relayNode.x + n.x) / 2;
                        const midY = (relayNode.y + n.y) / 2 - 20;

                        html += `<g class="topo-link ${isActive ? 'topo-link-active' : ''}">`;
                        html += `<path class="topo-link-bg" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        html += `<path class="topo-link-line" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        if (isAgent && n.online) {
                            html += `<path class="topo-link-flow" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        }
                        html += `</g>`;
                    }
                });
            }

            // 노드 위치 저장 초기화
            nodePositions = {};

            // 노드 렌더링
            nodes.forEach(n => {
                const color = getNodeColor(n);
                const isV1 = n.name === 'v1';
                const r = isV1 ? 38 : 30;
                const isAgent = n.source && n.source.includes('age');

                // 위치 저장
                nodePositions[n.name] = { x: n.x, y: n.y };

                html += `<g class="topo-node" transform="translate(${n.x},${n.y})"
                           onmouseenter="showTopoTooltip(event, '${n.name}')"
                           onmouseleave="hideTopoTooltip()">`;

                // 글로우 효과 (온라인)
                if (n.online) {
                    html += `<circle class="topo-node-glow" r="${r + 8}" stroke="${color}"/>`;
                }

                // 외곽 링
                html += `<circle class="topo-node-ring" r="${r + 4}" stroke="${color}"/>`;

                // 코어 원
                html += `<circle class="topo-node-core" r="${r}" fill="${color}"/>`;
                html += `<circle r="${r}" fill="url(#nodeGradient)"/>`;

                // 아이콘
                const icon = n.icon || '🖥️';
                html += `<text class="topo-node-icon" y="-5">${icon}</text>`;

                // 이름
                html += `<text class="topo-node-name" y="${r/2 + 8}">${n.name.toUpperCase()}</text>`;

                // 상태 뱃지 (디스크 사용률)
                if (n.online) {
                    const badgeColor = n.disk_pct >= 80 ? '#ff4757' : '#00b894';
                    html += `<g transform="translate(${r-5}, ${-r+5})">
                        <circle r="12" fill="${badgeColor}"/>
                        <text class="topo-node-badge" text-anchor="middle" y="4">${n.disk_pct || 0}%</text>
                    </g>`;

                    // 에이전트 표시
                    if (isAgent) {
                        html += `<g transform="translate(${-r+5}, ${-r+5})">
                            <circle r="10" fill="#00d4ff"/>
                            <text class="topo-node-badge" text-anchor="middle" y="4">📡</text>
                        </g>`;
                    }
                }

                html += `</g>`;
            });

            // 기존 defs 유지하면서 내용 추가
            const defs = svg.querySelector('defs').outerHTML;
            svg.innerHTML = defs + html;

            // 통계 업데이트
            const online = servers.filter(s => s.online).length;
            const agents = servers.filter(s => s.source && s.source.includes('age')).length;
            const warnings = servers.filter(s => s.online && s.disk_pct >= 80).length;

            document.getElementById('topoStats').innerHTML = `
                <div class="topo-stats-row"><span class="topo-stats-num">${online}/${servers.length}</span><span class="topo-stats-label">Online</span></div>
                <div class="topo-stats-row"><span class="topo-stats-num" style="color:#00ff88">${agents}</span><span class="topo-stats-label">Agents</span></div>
                ${warnings > 0 ? `<div class="topo-stats-row"><span class="topo-stats-num" style="color:#ff4757">${warnings}</span><span class="topo-stats-label">Warnings</span></div>` : ''}
            `;
        }

        // 노드 위치 저장
        let nodePositions = {};

        function showTopoTooltip(event, name) {
            const server = topoServers.find(s => s.name === name);
            if (!server) return;

            const tooltip = document.getElementById('topoTooltip');
            const container = document.getElementById('topoContainer');
            const containerRect = container.getBoundingClientRect();

            const diskInfo = server.disks && server.disks.length > 0
                ? server.disks.slice(0, 3).map(d => `${d.mount}: ${d.percent}%`).join('<br>')
                : `Root: ${server.disk_pct || 0}%`;

            tooltip.innerHTML = `
                <h4>${server.icon || '🖥️'} ${server.name.toUpperCase()} - ${server.desc || ''}</h4>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Status</span><span class="topo-tooltip-value">${server.online ? '🟢 Online' : '🔴 Offline'}</span></div>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Memory</span><span class="topo-tooltip-value">${server.memory || '?'}</span></div>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Disk</span><span class="topo-tooltip-value">${diskInfo}</span></div>
                ${server.vpn_ip ? `<div class="topo-tooltip-row"><span class="topo-tooltip-label">VPN IP</span><span class="topo-tooltip-value">${server.vpn_ip}</span></div>` : ''}
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Source</span><span class="topo-tooltip-value">${server.source?.includes('age') ? '📡 Agent' : '🔗 SSH'}</span></div>
            `;

            // 노드 위치 기반으로 툴팁 배치 (저장된 위치 사용)
            const pos = nodePositions[name];
            if (pos) {
                let left = pos.x + 45;
                let top = pos.y - 60;

                // 오른쪽 넘침 방지
                if (left + 200 > containerRect.width) {
                    left = pos.x - 200;
                }
                // 상단 넘침 방지
                if (top < 10) {
                    top = pos.y + 45;
                }

                tooltip.style.left = left + 'px';
                tooltip.style.top = top + 'px';
            }
            tooltip.classList.add('show');
        }

        function hideTopoTooltip() {
            document.getElementById('topoTooltip').classList.remove('show');
        }

        function getProgressClass(pct) {
            if (pct >= 90) return 'progress-danger';
            if (pct >= 80) return 'progress-warn';
            return 'progress-ok';
        }

        function renderServer(s) {
            const statusClass = getStatusClass(s);
            const statusText = !s.online ? 'OFFLINE' :
                              s.disk_pct >= 90 ? 'CRITICAL' :
                              s.disk_pct >= 80 ? 'WARNING' : 'ONLINE';

            let servicesHtml = '';
            if (s.services && Object.keys(s.services).length > 0) {
                // Only show active services
                const activeServices = Object.entries(s.services).filter(([name, active]) => active);
                if (activeServices.length > 0) {
                    servicesHtml = '<div class="services">' +
                        activeServices.map(([name, active]) =>
                            `<span class="service-tag service-active">✓ ${name}</span>`
                        ).join('') + '</div>';
                }
            }

            let processesHtml = '';
            if (s.processes && s.processes.length > 0) {
                processesHtml = `
                    <div class="processes">
                        <div class="processes-title">Running Processes</div>
                        <div class="process-list">
                            ${s.processes.map(p => `<span class="process-tag">${p}</span>`).join('')}
                        </div>
                    </div>`;
            }

            // Network traffic formatting
            function formatBytes(bytes) {
                if (!bytes || bytes === 0) return '0 B';
                const k = 1024;
                const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
                const i = Math.floor(Math.log(bytes) / Math.log(k));
                return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
            }

            let networkHtml = '';
            if (s.network && (s.network.total_rx > 0 || s.network.total_tx > 0)) {
                networkHtml = `
                    <div class="processes" style="margin-top:8px;">
                        <div class="processes-title">Network Traffic</div>
                        <div style="display:flex;gap:15px;font-size:0.85em;color:#aaa;">
                            <span>↓ ${formatBytes(s.network.total_rx)}</span>
                            <span>↑ ${formatBytes(s.network.total_tx)}</span>
                        </div>
                    </div>`;
            }

            let icecastHtml = '';
            if (s.icecast && s.icecast.listeners !== undefined) {
                icecastHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,212,255,0.1);">
                        <div class="processes-title">📻 Radio Stream</div>
                        <div style="display:flex;gap:15px;font-size:0.85em;color:#00d4ff;">
                            <span>👥 ${s.icecast.listeners} listeners</span>
                            <span>📊 Peak: ${s.icecast.peak_listeners}</span>
                            ${s.icecast.bitrate ? `<span>🎵 ${s.icecast.bitrate}kbps</span>` : ''}
                        </div>
                        ${s.icecast.title ? `<div style="font-size:0.8em;color:#888;margin-top:4px;">Now: ${s.icecast.title}</div>` : ''}
                    </div>`;
            }

            // 보안 이슈 표시 (v3.1 확장)
            let securityHtml = '';
            if (s.security) {
                const sec = s.security;
                const hasIssues = sec.failed_logins_1h > 10 || sec.suspicious_processes > 0 || sec.unique_attackers > 5;
                if (hasIssues || sec.ssh_sessions > 0 || sec.fail2ban_banned > 0) {
                    securityHtml = `
                        <div class="processes" style="margin-top:8px;background:rgba(${hasIssues?'255,71,87':'100,100,100'},0.1);border-left:3px solid ${hasIssues?'#ff4757':'#666'};">
                            <div class="processes-title" style="color:${hasIssues?'#ff4757':'#888'};">🛡️ Security</div>
                            <div style="font-size:0.8em;display:flex;flex-wrap:wrap;gap:10px;">
                                ${sec.ssh_sessions > 0 ? `<span>👥 Sessions: ${sec.ssh_sessions}</span>` : ''}
                                ${sec.failed_logins_1h > 0 ? `<span style="color:${sec.failed_logins_1h > 10 ? '#ff4757' : '#ffa502'};">⛔ Failed: ${sec.failed_logins_1h}</span>` : ''}
                                ${sec.unique_attackers > 0 ? `<span style="color:#e84393;">🎯 Attackers: ${sec.unique_attackers}</span>` : ''}
                                ${sec.fail2ban_banned > 0 ? `<span style="color:#00b894;">🚫 Banned: ${sec.fail2ban_banned}</span>` : ''}
                                ${sec.suspicious_processes > 0 ? `<span style="color:#ff0000;">⚠️ Suspicious: ${sec.suspicious_processes}</span>` : ''}
                                ${sec.sudo_commands_1h > 0 ? `<span>🔑 Sudo: ${sec.sudo_commands_1h}</span>` : ''}
                            </div>
                            ${sec.last_login ? `<div style="font-size:0.7em;color:#888;margin-top:4px;">Last: ${sec.last_login}</div>` : ''}
                        </div>`;
                }
            } else if (s.security && s.security.length > 0) {
                const levelColors = {critical: '#ff4757', warning: '#ffa502', info: '#00d4ff'};
                const levelIcons = {critical: '🚨', warning: '⚠️', info: 'ℹ️'};
                securityHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,71,87,0.1);border-left:3px solid #ff4757;">
                        <div class="processes-title" style="color:#ff4757;">🛡️ Security</div>
                        <div style="font-size:0.8em;">
                            ${s.security.map(issue => `
                                <div style="color:${levelColors[issue.level] || '#888'};margin:3px 0;">
                                    ${levelIcons[issue.level] || '•'} ${issue.msg}
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 로그 이슈 표시
            let logsHtml = '';
            if (s.logs && s.logs.length > 0) {
                const levelColors = {critical: '#ff4757', error: '#ff6b6b', warning: '#ffa502'};
                logsHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,165,2,0.1);border-left:3px solid #ffa502;">
                        <div class="processes-title" style="color:#ffa502;">📋 Recent Logs</div>
                        <div style="font-size:0.75em;max-height:80px;overflow-y:auto;">
                            ${s.logs.map(log => `
                                <div style="color:${levelColors[log.level] || '#888'};margin:2px 0;word-break:break-all;">
                                    ${log.msg}
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // GPU 사용량 (v3.1)
            let gpuHtml = '';
            if (s.gpu && s.gpu.available && s.gpu.gpus && s.gpu.gpus.length > 0) {
                gpuHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(118,185,0,0.1);border-left:3px solid #76b900;">
                        <div class="processes-title" style="color:#76b900;">🎮 GPU</div>
                        <div style="font-size:0.8em;">
                            ${s.gpu.gpus.map(g => `
                                <div style="margin:3px 0;display:flex;justify-content:space-between;">
                                    <span>${g.name}</span>
                                    <span>GPU: ${g.gpu_pct}% | Mem: ${g.mem_used}/${g.mem_total}MB | ${g.temp}°C</span>
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 네트워크 연결 (v3)
            let connectionsHtml = '';
            if (s.network && s.network.connections) {
                const c = s.network.connections;
                connectionsHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,212,255,0.1);">
                        <div class="processes-title" style="color:#00d4ff;">🔌 Connections</div>
                        <div style="display:flex;gap:12px;font-size:0.8em;flex-wrap:wrap;">
                            <span>ESTAB: ${c.established || 0}</span>
                            <span>LISTEN: ${c.listen || 0}</span>
                            <span>TIME_WAIT: ${c.time_wait || 0}</span>
                            <span>CLOSE_WAIT: ${c.close_wait || 0}</span>
                        </div>
                    </div>`;
            }

            // 로그 이상 감지 (v3)
            let anomalyHtml = '';
            if (s.logs && s.logs.anomalies) {
                const a = s.logs.anomalies;
                if (a.errors > 0 || a.warnings > 0 || a.oom_kills > 0) {
                    anomalyHtml = `
                        <div class="processes" style="margin-top:8px;background:rgba(255,107,107,0.1);border-left:3px solid #ff6b6b;">
                            <div class="processes-title" style="color:#ff6b6b;">⚠️ Log Anomalies (1h)</div>
                            <div style="display:flex;gap:12px;font-size:0.8em;">
                                ${a.errors > 0 ? `<span style="color:#ff4757;">Errors: ${a.errors}</span>` : ''}
                                ${a.warnings > 0 ? `<span style="color:#ffa502;">Warnings: ${a.warnings}</span>` : ''}
                                ${a.oom_kills > 0 ? `<span style="color:#ff0000;">OOM: ${a.oom_kills}</span>` : ''}
                                ${a.auth_failures > 0 ? `<span style="color:#e84393;">Auth Fail: ${a.auth_failures}</span>` : ''}
                            </div>
                        </div>`;
                }
            }

            // 실패한 서비스 (v3.1)
            let failedHtml = '';
            if (s.failed_services && s.failed_services.length > 0) {
                failedHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,71,87,0.15);border-left:3px solid #ff4757;">
                        <div class="processes-title" style="color:#ff4757;">❌ Failed Services</div>
                        <div style="font-size:0.8em;color:#ff6b6b;">
                            ${s.failed_services.join(', ')}
                        </div>
                    </div>`;
            }

            // 컨테이너 상태 (v3.1)
            let containersHtml = '';
            if (s.containers && s.containers.length > 0) {
                containersHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,123,255,0.1);">
                        <div class="processes-title" style="color:#0d6efd;">🐳 Containers</div>
                        <div style="font-size:0.75em;display:flex;flex-wrap:wrap;gap:6px;">
                            ${s.containers.map(c => `
                                <span class="process-tag" style="background:${c.health==='healthy'?'rgba(0,184,148,0.3)':c.health==='unhealthy'?'rgba(255,71,87,0.3)':'rgba(100,100,100,0.3)'};">
                                    ${c.health==='healthy'?'✓':c.health==='unhealthy'?'✗':'•'} ${c.name}
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 커널/시스템 정보 (v3.1)
            let kernelHtml = '';
            if (s.kernel && s.kernel.kernel) {
                kernelHtml = `<span class="info-tag" style="background:rgba(108,92,231,0.2);color:#6c5ce7;">🐧 ${s.kernel.kernel}</span>`;
            }

            // Swap 사용량 (v3.1)
            let swapHtml = '';
            if (s.swap && s.swap.pct > 20) {
                swapHtml = `<span class="info-tag ${s.swap.pct > 80 ? 'danger' : 'warn'}">Swap: ${s.swap.pct}%</span>`;
            }

            // 웹서비스 (v3.2)
            let webHtml = '';
            if (s.web_services && s.web_services.length > 0) {
                webHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(46,204,113,0.1);border-left:3px solid #2ecc71;">
                        <div class="processes-title" style="color:#2ecc71;">🌐 Web Services</div>
                        <div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.8em;">
                            ${s.web_services.map(w => `
                                <span class="process-tag" style="background:rgba(46,204,113,0.2);">
                                    ${w.name}${w.port ? ':'+w.port : ''}
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // Top 프로세스 (v3.2)
            let topProcHtml = '';
            if (s.top_processes && s.top_processes.length > 0) {
                topProcHtml = `
                    <div class="processes" style="margin-top:8px;">
                        <div class="processes-title">📊 Top Processes</div>
                        <div style="font-size:0.7em;display:grid;grid-template-columns:1fr 1fr;gap:2px;">
                            ${s.top_processes.slice(0,6).map(p => `
                                <span style="color:${p.cpu > 50 ? '#ff4757' : p.cpu > 20 ? '#ffa502' : '#888'};">
                                    ${p.name} (${p.cpu.toFixed(1)}%)
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 시스템 메일 (v3.2)
            let mailHtml = '';
            if (s.mail && (s.mail.count > 0 || s.mail.cron_errors > 0)) {
                mailHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(155,89,182,0.1);border-left:3px solid #9b59b6;">
                        <div class="processes-title" style="color:#9b59b6;">📬 System Mail</div>
                        <div style="font-size:0.8em;">
                            <span>📧 ${s.mail.count} messages</span>
                            ${s.mail.cron_errors > 0 ? `<span style="color:#ff4757;"> | ⚠️ ${s.mail.cron_errors} errors</span>` : ''}
                        </div>
                        ${s.mail.recent && s.mail.recent.length > 0 ? `
                            <div style="font-size:0.7em;color:#888;margin-top:4px;max-height:40px;overflow:hidden;">
                                ${s.mail.recent.slice(0,2).map(m => `<div>• ${m}</div>`).join('')}
                            </div>` : ''}
                    </div>`;
            }

            // 보안 업데이트 (v3.2)
            let updatesHtml = '';
            if (s.updates && s.updates.security > 0) {
                updatesHtml = `<span class="info-tag danger">🔒 ${s.updates.security} security updates</span>`;
            } else if (s.updates && s.updates.available > 10) {
                updatesHtml = `<span class="info-tag warn">📦 ${s.updates.available} updates</span>`;
            }

            return `
                <div class="card ${!s.online ? 'offline' : ''}">
                    <div class="card-header">
                        <span class="card-icon">${s.icon || '🖥️'}</span>
                        <div class="card-title">
                            <h2>${s.name}</h2>
                            <div class="desc">${s.desc}</div>
                        </div>
                        <span class="status-badge status-${statusClass}">${statusText}</span>
                    </div>
                    ${s.online ? `
                        <div class="metrics">
                            <div class="metric">
                                <div class="metric-label">Disk</div>
                                <div class="metric-value">${s.disk_used || '?'}</div>
                                <div class="progress-bar">
                                    <div class="progress-fill ${getProgressClass(s.disk_pct || 0)}"
                                         style="width: ${s.disk_pct || 0}%"></div>
                                </div>
                            </div>
                            <div class="metric">
                                <div class="metric-label">Memory</div>
                                <div class="metric-value">${s.memory || '?'}</div>
                                ${s.mem_pct ? `
                                <div class="progress-bar">
                                    <div class="progress-fill ${getProgressClass(s.mem_pct)}"
                                         style="width: ${s.mem_pct}%"></div>
                                </div>` : ''}
                            </div>
                            <div class="metric">
                                <div class="metric-label">Load</div>
                                <div class="metric-value">${s.load || '?'}</div>
                            </div>
                            <div class="metric">
                                <div class="metric-label">Uptime</div>
                                <div class="metric-value">${(s.uptime || '?').substring(0, 15)}</div>
                            </div>
                        </div>
                        ${servicesHtml}
                        ${processesHtml}
                        ${networkHtml}
                        ${connectionsHtml}
                        ${gpuHtml}
                        ${containersHtml}
                        ${anomalyHtml}
                        ${failedHtml}
                        ${icecastHtml}
                        ${securityHtml}
                        ${logsHtml}
                        ${webHtml}
                        ${topProcHtml}
                        ${mailHtml}
                        <div class="extra-info">
                            ${kernelHtml}
                            ${swapHtml}
                            ${updatesHtml}
                            ${s.ports && s.ports.length > 0 ?
                                `<span class="info-tag">Ports: ${s.ports.slice(0,6).join(', ')}${s.ports.length > 6 ? '...' : ''}</span>` : ''}
                            ${s.firewall ?
                                `<span class="info-tag ${s.firewall.includes('active') ? 'ok' : s.firewall === 'none' ? 'warn' : ''}">${s.firewall}</span>` : ''}
                            ${s.backup && s.backup !== 'none' && s.backup !== 'unknown' ?
                                `<span class="info-tag ${parseInt(s.backup.match(/\\d+/)) > 7 ? 'warn' : 'ok'}">Backup: ${s.backup.split(':')[1] || s.backup}</span>` : ''}
                        </div>
                        <div class="ip-info">
                            <span>Public: ${s.public_ip || '?'}</span>
                            ${s.vpn_ip ? `<span>VPN: ${s.vpn_ip}</span>` : ''}
                        </div>
                    ` : `<p style="color:#888;margin-top:10px;">Server unreachable</p>`}
                </div>
            `;
        }

        async function loadData() {
            try {
                const res = await fetch('/api/status');
                const data = await res.json();

                document.getElementById('serverGrid').innerHTML =
                    data.servers.map(renderServer).join('');

                renderTopology(data.servers);

                document.getElementById('vpnOutput').textContent = data.vpn || 'No data';

                if (data.last_update) {
                    const d = new Date(data.last_update);
                    document.getElementById('updateTime').textContent =
                        `Last updated: ${d.toLocaleString('ko-KR')}`;
                }
            } catch (e) {
                console.error('Load error:', e);
            }
        }

        async function refresh() {
            const btn = document.getElementById('refreshBtn');
            btn.classList.add('loading');
            try {
                await fetch('/api/refresh', {method: 'POST'});
                await new Promise(r => setTimeout(r, 2000));
                await loadData();
            } finally {
                btn.classList.remove('loading');
            }
        }

        async function askAI() {
            const input = document.getElementById('aiInput');
            const btn = document.getElementById('aiBtn');
            const response = document.getElementById('aiResponse');
            const question = input.value.trim();

            if (!question) return;

            btn.disabled = true;
            response.classList.add('loading');
            response.textContent = '분석 중...';

            try {
                const model = document.getElementById('aiModel').value;
                const res = await fetch('/api/ai', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({question, model})
                });
                const data = await res.json();
                response.textContent = data.answer || data.error || 'No response';
            } catch (e) {
                response.textContent = 'Error: ' + e.message;
            } finally {
                btn.disabled = false;
                response.classList.remove('loading');
            }
        }

        loadData();
        setInterval(loadData, 30000);
    </script>
</body>
</html>
'''

class Handler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # 로그 끄기

    def _check_vpn_access(self):
        \"\"\"VPN 내부 접근만 허용 (10.99.x.x 또는 localhost)\"\"\"
        client_ip = self.client_address[0]
        allowed = (
            client_ip.startswith("10.99.") or
            client_ip.startswith("127.") or
            client_ip == "::1" or
            client_ip.startswith("192.168.")  # 로컬 네트워크도 허용
        )
        if not allowed:
            self.send_response(403)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(b'''
                <html><body style="background:#1a1a2e;color:#ff4757;font-family:sans-serif;
                display:flex;justify-content:center;align-items:center;height:100vh;margin:0;">
                <div style="text-align:center;">
                <h1>Access Denied</h1>
                <p>VPN connection required (10.99.x.x)</p>
                <p style="color:#888;">Your IP: ''' + client_ip.encode() + b'''</p>
                </div></body></html>
            ''')
            return False
        return True

    def do_GET(self):
        if not self._check_vpn_access():
            return
        if self.path == '/':
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(HTML.encode())
        elif self.path == '/api/status':
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            with cache_lock:
                self.wfile.write(json.dumps(cache, ensure_ascii=False).encode())
            return
        elif self.path == '/api/topology':
            # 네트워크 토폴로지 (VPN에서 자동 감지)
            topology = get_network_topology()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps(topology, ensure_ascii=False).encode())
            return
        elif self.path == '/api/peers':
            # VPN 피어 목록 (raw)
            try:
                req = urllib.request.Request(f"{VPN_SERVER_URL}/peers")
                with urllib.request.urlopen(req, timeout=5) as resp:
                    data = resp.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(data)
            except Exception as e:
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": str(e)}).encode())
            return
        elif self.path.startswith('/api/mpop/'):
            # mpop 명령어 실행 API
            cmd_name = self.path.replace('/api/mpop/', '').split('?')[0]
            allowed_cmds = ['full', 'security', 'audit', 'matrix', 'services', 'servers', 'temp', 'gpu']
            if cmd_name not in allowed_cmds:
                self.send_response(400)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": f"Command not allowed: {cmd_name}", "allowed": allowed_cmds}).encode())
                return
            try:
                result = subprocess.run(
                    ['/usr/local/bin/mpop', cmd_name],
                    capture_output=True, text=True, timeout=60
                )
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({
                    "command": f"mpop {cmd_name}",
                    "output": result.stdout,
                    "error": result.stderr if result.returncode != 0 else None,
                    "returncode": result.returncode
                }, ensure_ascii=False).encode())
            except subprocess.TimeoutExpired:
                self.send_response(504)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": "Command timeout (60s)"}).encode())
            except Exception as e:
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": str(e)}).encode())
            return
        elif self.path == '/install.sh':
            # Agent 설치 스크립트 제공
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(INSTALL_SCRIPT.encode())
            return
        elif self.path == '/agent.py':
            # Agent 코드 제공
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(AGENT_CODE.encode())
            return
        elif self.path == '/docs' or self.path == '/docs/':
            # mpop 문서/랜딩페이지
            docs_path = '/root/mpop-landing/index.html'
            try:
                with open(docs_path, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(b'docs not found')
            return
        elif self.path == '/mpop' or self.path == '/mpop.py':
            # mpop CLI 제공 (self-update용)
            mpop_path = '/usr/local/bin/mpop'
            try:
                with open(mpop_path, 'rb') as f:
                    content = f.read()
                # Extract version from content
                version = '3.6.0'
                import re
                match = re.search(rb'VERSION\s*=\s*["\']([^"\']+)["\']', content)
                if match:
                    version = match.group(1).decode()
                self.send_response(200)
                self.send_header('Content-Type', 'application/octet-stream')
                self.send_header('Content-Length', str(len(content)))
                self.send_header('X-Mpop-Version', version)
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(b'mpop not found')
            return
        elif self.path in ('/vssh.py', '/vssh-amd64', '/vssh-arm64'):
            # vssh 파일 제공
            filename = self.path.lstrip('/')
            vssh_path = f'/root/server-agent/{filename}'
            try:
                with open(vssh_path, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/octet-stream')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(f'{filename} not found'.encode())
            return
        elif self.path.startswith('/api/history'):
            # 히스토리 데이터 (최근 N시간)
            hours = 24
            if '?' in self.path:
                params = dict(p.split('=') for p in self.path.split('?')[1].split('&') if '=' in p)
                hours = int(params.get('hours', 24))
            records = load_history(hours)
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({"hours": hours, "records": records}, ensure_ascii=False).encode())
            return
        elif self.path.startswith('/api/trends'):
            # 트렌드 데이터 (서버별 통계)
            hours = 24
            if '?' in self.path:
                params = dict(p.split('=') for p in self.path.split('?')[1].split('&') if '=' in p)
                hours = int(params.get('hours', 24))
            trends = get_trends(hours)
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps(trends, ensure_ascii=False).encode())
            return
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        if not self._check_vpn_access():
            return

        if self.path == '/api/ai':
            content_length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(content_length)) if content_length > 0 else {}
            question = body.get('question', '')
            model = body.get('model', 'ollama')

            # AI 분석 (현재 상태 기반)
            with cache_lock:
                servers = cache.get("servers", [])
                vpn = cache.get("vpn", "")

            answer = analyze_with_ai(question, servers, vpn, model=model)

            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({"answer": answer}, ensure_ascii=False).encode())
            return

        if self.path == '/api/refresh':
            threading.Thread(target=lambda: update_cache_once(), daemon=True).start()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(b'{"ok":true}')

        elif self.path == '/api/report':
            # Agent로부터 상태 보고 수신
            content_length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(content_length)) if content_length > 0 else {}

            hostname = body.get('hostname', '')
            if hostname:
                with cache_lock:
                    # agent_reports에 저장
                    if 'agent_reports' not in cache:
                        cache['agent_reports'] = {}
                    cache['agent_reports'][hostname] = body
                    cache['agent_reports'][hostname]['last_seen'] = time.time()

                    # 즉시 servers 배열 업데이트
                    server_name = HOSTNAME_MAP.get(hostname)
                    if server_name and server_name in SERVERS:
                        info = SERVERS[server_name]
                        new_status = {
                            "name": server_name,
                            "host": info["host"],
                            "desc": info["desc"],
                            "icon": info.get("icon", "🖥️"),
                            "online": True,
                            "source": "agent",
                            **{k: v for k, v in body.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                        }
                        # servers 배열에서 해당 서버 업데이트 또는 추가
                        servers = cache.get("servers", [])
                        found = False
                        for i, s in enumerate(servers):
                            if s["name"] == server_name:
                                servers[i] = new_status
                                found = True
                                break
                        if not found:
                            servers.append(new_status)
                            servers.sort(key=lambda x: x["name"])
                        cache["servers"] = servers
                        cache["last_update"] = datetime.now().isoformat()

                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(b'{"ok":true}')
            else:
                self.send_response(400)
                self.end_headers()
        else:
            self.send_response(404)
            self.end_headers()

def _load_mpop_config():
    \"\"\"~/.mpop/config.json에서 설정 읽기\"\"\"
    config_paths = [
        os.path.expanduser("~/.mpop/config.json"),
        "/etc/meshpop/config.json",
    ]
    for p in config_paths:
        try:
            with open(p) as f:
                return json.load(f)
        except:
            continue
    return {}

def _build_status_summary(servers):
    \"\"\"서버 상태 요약 텍스트 생성\"\"\"
    lines = []
    for s in servers:
        if not s.get("online"):
            lines.append(f"❌ {s['name']}: OFFLINE")
            continue
        issues = []
        disk_pct = s.get("disk_pct", 0)
        mem_pct = s.get("mem_pct", 0)
        if disk_pct >= 90: issues.append(f"디스크 위험 {disk_pct}%")
        elif disk_pct >= 80: issues.append(f"디스크 주의 {disk_pct}%")
        if mem_pct >= 90: issues.append(f"메모리 위험 {mem_pct}%")
        elif mem_pct >= 80: issues.append(f"메모리 주의 {mem_pct}%")
        for svc, active in s.get("services", {}).items():
            if not active: issues.append(f"{svc} 중지됨")
        if issues:
            lines.append(f"⚠️ {s['name']}: {', '.join(issues)}")
        else:
            lines.append(f"✅ {s['name']}: 정상 (디스크 {disk_pct}%, 메모리 {mem_pct}%)")
    return '\n'.join(lines)

def _build_prompt(question, status_text, vpn):
    \"\"\"AI 프롬프트 생성\"\"\"
    return f\"\"\"당신은 서버 관리 AI 어시스턴트입니다. 사용자 질문에 간결하게 답변하세요.

현재 서버 상태:
{status_text}

VPN 상태:
{vpn[:500] if vpn else '정보 없음'}

사용자 질문: {question}

명령어가 필요하면 ssh 명령어로 알려주세요. 간결하게 답변하세요.\"\"\"

def _call_ollama(prompt):
    \"\"\"Ollama 로컬 LLM 호출 (mpop 설정 사용)\"\"\"
    import urllib.request
    cfg = _load_mpop_config()
    host = cfg.get("ollama_host", "localhost")
    model = cfg.get("ollama_model", "qwen2.5:7b")

    # host가 서버이름이면 IP로 변환
    host_map = {"g1": "10.99.74.111", "g2": "10.99.106.230", "g3": "10.99.202.68", "g4": "10.99.175.0"}
    if host in host_map:
        host = host_map[host]
    if ":" not in host:
        host = f"{host}:11434"

    data = json.dumps({
        "model": model,
        "prompt": prompt,
        "stream": False,
        "options": {"temperature": 0.3, "num_predict": 500}
    }).encode()

    req = urllib.request.Request(
        f"http://{host}/api/generate",
        data=data,
        headers={"Content-Type": "application/json"}
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode())
        return result.get("response", "").strip()

def _call_gpt(prompt):
    \"\"\"OpenAI GPT API 호출\"\"\"
    import urllib.request
    cfg = _load_mpop_config()
    model = cfg.get("llm_model", "gpt-4o-mini")
    api_key = os.environ.get("OPENAI_API_KEY", "")
    if not api_key:
        return "[Error: OPENAI_API_KEY 환경변수가 설정되지 않았습니다]"

    data = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.3,
        "max_tokens": 500
    }).encode()

    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        data=data,
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        result = json.loads(resp.read().decode())
        return result["choices"][0]["message"]["content"].strip()

def _call_claude(prompt):
    \"\"\"Claude CLI 호출\"\"\"
    import subprocess
    result = subprocess.run(
        ["claude", "-p", prompt, "--max-tokens", "500"],
        capture_output=True, text=True, timeout=30,
        cwd=os.path.expanduser("~")
    )
    if result.returncode == 0 and result.stdout.strip():
        return result.stdout.strip()
    return None

def _template_response(question, servers, status_text):
    \"\"\"룰 기반 템플릿 응답 (AI 없이)\"\"\"
    q = question.lower()

    if "요약" in q or "상태" in q:
        return f"서버 상태 요약:\n\n{status_text}"

    if "디스크" in q:
        issues = [s for s in servers if s.get("online") and s.get("disk_pct", 0) >= 80]
        if issues:
            r = "디스크 사용량 높은 서버:\n"
            for s in issues:
                r += f"\n• {s['name']}: {s.get('disk_used', '?')} 사용\n"
                r += f"  정리 명령: ssh {s.get('host',s['name'])} 'du -sh /* 2>/dev/null | sort -rh | head -10'\n"
            return r
        return "모든 서버 디스크 상태 양호합니다."

    if "메모리" in q:
        issues = [s for s in servers if s.get("online") and s.get("mem_pct", 0) >= 80]
        if issues:
            r = "메모리 사용량 높은 서버:\n"
            for s in issues:
                r += f"\n• {s['name']}: {s.get('memory', '?')}\n"
                r += f"  확인 명령: ssh {s.get('host',s['name'])} 'ps aux --sort=-%mem | head -10'\n"
            return r
        return "모든 서버 메모리 상태 양호합니다."

    if "백업" in q:
        r = "백업 상태:\n"
        for s in servers:
            if s.get("online"):
                r += f"• {s['name']}: {s.get('backup', 'unknown')}\n"
        return r

    if "정리" in q:
        for s in servers:
            if s["name"] in q:
                host = s.get('host', s['name'])
                return f\"\"\"{s['name']} 서버 정리 명령어:

ssh {host} 'df -h && du -sh /* 2>/dev/null | sort -rh | head -10'

# 로그/캐시 정리
ssh {host} 'sudo journalctl --vacuum-time=3d; sudo apt-get clean; rm -rf ~/.cache/* 2>/dev/null'

# Docker 정리
ssh {host} 'docker system prune -af 2>/dev/null'
\"\"\"
        return "어떤 서버를 정리할지 지정해주세요. (예: g2 정리해줘)"

    return f"서버 상태 요약:\n\n{status_text}\n\n구체적인 질문을 해주세요."

def analyze_with_ai(question: str, servers: list, vpn: str, model: str = "ollama") -> str:
    \"\"\"AI로 서버 상태 분석 — ollama(기본) / gpt / claude / template\"\"\"
    status_text = _build_status_summary(servers)

    # template 모드: AI 없이 룰 기반
    if model == "template":
        return _template_response(question, servers, status_text)

    prompt = _build_prompt(question, status_text, vpn)

    # 1순위: 선택된 모델로 시도
    try:
        if model == "ollama":
            return _call_ollama(prompt)
        elif model == "gpt":
            return _call_gpt(prompt)
        elif model == "claude":
            result = _call_claude(prompt)
            if result:
                return result
            return "[Claude CLI 없음] " + _template_response(question, servers, status_text)
    except Exception as e:
        pass

    # 2순위: 템플릿 fallback
    return f"[{model} 연결 실패 — 템플릿 응답]\n\n" + _template_response(question, servers, status_text)


def update_cache_once():
    \"\"\"한 번만 캐시 업데이트 - Agent 리포트 우선, SSH 폴백\"\"\"
    try:
        results = []
        now = time.time()

        # Agent 리포트 확인 (60초 이내면 유효)
        with cache_lock:
            agent_reports = cache.get("agent_reports", {})

        # 각 서버에 대해: agent 리포트 있으면 사용, 없으면 SSH 폴링
        servers_to_poll = []
        for name, info in SERVERS.items():
            # hostname으로 agent 리포트 찾기
            agent_data = None
            for hostname, server_name in HOSTNAME_MAP.items():
                if server_name == name and hostname in agent_reports:
                    report = agent_reports[hostname]
                    if now - report.get('last_seen', 0) < 60:
                        agent_data = report
                        break

            if agent_data:
                # Agent 리포트 사용
                status = {
                    "name": name,
                    "host": info["host"],
                    "desc": info["desc"],
                    "icon": info.get("icon", "🖥️"),
                    "online": True,
                    "source": "agent",
                    **{k: v for k, v in agent_data.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                }
                results.append(status)
            else:
                servers_to_poll.append((name, info))

        # SSH 폴링 (agent 리포트 없는 서버들만)
        if servers_to_poll:
            with ThreadPoolExecutor(max_workers=6) as executor:
                futures = {executor.submit(get_server_status, name, info): name
                           for name, info in servers_to_poll}
                for future in as_completed(futures):
                    result = future.result()
                    result["source"] = "ssh"
                    results.append(result)

        results.sort(key=lambda x: x["name"])

        vpn_status = get_wire_status()

        with cache_lock:
            cache["servers"] = results
            cache["vpn"] = vpn_status
            cache["last_update"] = datetime.now().isoformat()
    except Exception as e:
        print(f"Cache update error: {e}")

def main():
    # VPN IP에서만 리슨 (보안)
    bind_ip = get_my_vpn_ip() or "10.99.85.143"
    print(f"Binding to {bind_ip}:{PORT}")
    server = ThreadingHTTPServer((bind_ip, PORT), Handler)

    # 초기 데이터 로드 (백그라운드)
    print("Loading initial data in background...")
    threading.Thread(target=update_cache_once, daemon=True).start()

    threading.Thread(target=update_cache, daemon=True).start()

    # 히스토리 저장 워커 시작 (5분마다)
    threading.Thread(target=history_worker, daemon=True).start()
    print(f"  History: {HISTORY_FILE} (every {HISTORY_INTERVAL}s)")

    print(f"\n{'='*50}")
    print(f"  Server Dashboard (VPN only)")
    print(f"  http://{bind_ip}:{PORT}")
    print(f"{'='*50}\n")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down...")
        server.shutdown()

if __name__ == "__main__":
    main()
"""


# ==================== Dragon Mode ====================

DRAGON_ASCII = r'''
                 ______________
                /              \
               |    🐉 DRAGON   |
               |      MODE      |
                \______________/
                       ||
           ____       /||\       ____
          /    \     / || \     /    \
         | SCAN |===|  ||  |===| KILL |
          \____/     \ || /     \____/
                      \||/
                       ||
                    EXECUTE
'''

def cmd_heal(args):
    """Auto-healing - detect and fix common issues automatically"""

    dry_run = "-x" not in args and "--execute" not in args
    target = None
    for arg in args:
        if arg in SERVERS:
            target = arg

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.GREEN}AUTO-HEAL{C.R} - Automatic Problem Detection & Recovery
   Mode: {C.YELLOW}DRY-RUN{C.R if dry_run else f'{C.RED}EXECUTE{C.R}'}  |  Target: {target or 'ALL'}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    vpn_ip = get_vpn_ip()
    targets = {target: SERVERS[target]} if target else SERVERS

    # Collect server status
    print(f"  {C.DIM}Scanning servers...{C.R}")
    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        futures = {ex.submit(check_server_full, name, ip, vpn_ip): name
                   for name, ip in targets.items()}
        for future in as_completed(futures):
            name = futures[future]
            results[name] = future.result()

    actions = []

    for name, r in results.items():
        if r.get("me"):
            continue  # Skip local machine
        if not r.get("ping"):
            continue  # Can't reach

        stats = r.get("stats", {})

        # 1. Disk > 90% - Clean logs and cache
        try:
            disk_pct = int(str(stats.get("disk", "0")).replace("%", ""))
            if disk_pct > 90:
                actions.append({
                    "server": name,
                    "type": "DISK_CLEANUP",
                    "severity": "HIGH",
                    "desc": f"Disk at {disk_pct}% - clean logs/cache",
                    "cmd": "sudo journalctl --vacuum-time=3d; sudo apt-get clean 2>/dev/null; sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null; df -h / | tail -1"
                })
            elif disk_pct > 80:
                actions.append({
                    "server": name,
                    "type": "DISK_WARNING",
                    "severity": "MEDIUM",
                    "desc": f"Disk at {disk_pct}% - clean old logs",
                    "cmd": "sudo journalctl --vacuum-time=7d; df -h / | tail -1"
                })
        except Exception: pass

        # 2. Memory > 90% - Drop caches
        try:
            mem_pct = int(str(stats.get("mem", "0")).replace("%", ""))
            if mem_pct > 90:
                actions.append({
                    "server": name,
                    "type": "MEM_CLEANUP",
                    "severity": "MEDIUM",
                    "desc": f"Memory at {mem_pct}% - drop caches",
                    "cmd": "sync; echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null; free -h | head -2"
                })
        except Exception: pass

        # 3. Load > 10 - Kill zombie processes
        try:
            load_val = float(str(stats.get("load", "0")).split(",")[0])
            if load_val > 10:
                actions.append({
                    "server": name,
                    "type": "LOAD_HIGH",
                    "severity": "HIGH",
                    "desc": f"Load at {load_val:.1f} - check zombies",
                    "cmd": "ps aux | awk '$8 ~ /Z/ {print $2}' | head -5 | xargs -r sudo kill -9 2>/dev/null; uptime"
                })
        except Exception: pass

        # 4. SSH attacks > 1000 - Restart fail2ban
        try:
            attacks = int(stats.get("failed_logins", 0))
            if attacks > 1000:
                actions.append({
                    "server": name,
                    "type": "SSH_ATTACK",
                    "severity": "HIGH",
                    "desc": f"{attacks} failed logins - restart fail2ban",
                    "cmd": "sudo systemctl restart fail2ban; sudo fail2ban-client status sshd 2>/dev/null | grep 'Currently banned'"
                })
        except Exception: pass

    if not actions:
        print(f"  {C.GREEN}✓ No issues detected - all systems healthy{C.R}\n")
        return

    # Display actions
    print(f"  {C.BOLD}Found {len(actions)} issue(s):{C.R}\n")

    for i, action in enumerate(actions, 1):
        sev_color = C.RED if action["severity"] == "HIGH" else C.YELLOW
        print(f"  {i}. [{sev_color}{action['severity']}{C.R}] {C.CYAN}{action['server']}{C.R}: {action['type']}")
        print(f"     {action['desc']}")
        if not dry_run:
            print(f"     {C.DIM}→ {action['cmd'][:60]}...{C.R}")
        print()

    if dry_run:
        print(f"  {C.YELLOW}DRY-RUN mode{C.R} - no changes made")
        print(f"  Run with {C.BOLD}-x{C.R} to execute fixes:\n")
        print(f"    mpop heal -x")
        if target:
            print(f"    mpop heal {target} -x")
        return

    # User confirmation before executing
    print(f"  {C.YELLOW}⚠ WARNING:{C.R} This will execute {len(actions)} fix(es) on your servers.")
    print(f"  Review the actions above carefully.\n")
    try:
        confirm = input(f"  {C.BOLD}Proceed with auto-healing? [y/N]:{C.R} ").strip().lower()
        if confirm not in ("y", "yes"):
            print(f"\n  {C.YELLOW}Aborted.{C.R} No changes made.\n")
            return
    except KeyboardInterrupt:
        print(f"\n  {C.YELLOW}Aborted.{C.R}\n")
        return

    # Execute actions
    print(f"\n  {C.GREEN}Executing fixes...{C.R}\n")

    for action in actions:
        name = action["server"]
        ip = SERVERS[name]
        print(f"  {C.CYAN}{name}{C.R}: {action['type']}...", end=" ", flush=True)

        result = vssh_exec(ip, action["cmd"], timeout=30)
        if result and "error" not in result.lower():
            print(f"{C.GREEN}✓{C.R}")
            # Show brief result
            lines = result.strip().split("\n")
            for line in lines[-2:]:
                print(f"     {C.DIM}{line[:70]}{C.R}")
        else:
            print(f"{C.RED}✗{C.R}")
            if result:
                print(f"     {C.RED}{result[:70]}{C.R}")
        print()

    print(f"  {C.GREEN}Done!{C.R} Re-run {C.BOLD}mpop watch{C.R} to verify.\n")


def cmd_enforce(args):
    """Dragon Mode - Cold, automated infrastructure enforcement with safety tiers"""

    # Parse arguments
    target_node = None
    tier_mode = None  # None = dry-run, 1 = T1 only, 2 = T1+T2, 3 = all
    force_mode = False
    interactive_mode = False
    delay_seconds = 5  # Default delay before T2/T3 actions
    immediate_mode = False  # --now skips delay

    for arg in args:
        if arg in SERVERS:
            target_node = arg
        elif arg.startswith("-t="):
            target_node = arg.split("=")[1]
        elif arg == "--t1":
            tier_mode = 1
        elif arg == "--t2":
            tier_mode = 2
        elif arg == "--t3":
            tier_mode = 3
        elif arg == "--force":
            force_mode = True
        elif arg == "--interactive" or arg == "-i":
            interactive_mode = True
        elif arg == "--execute" or arg == "-x":
            tier_mode = 2  # Legacy: same as --t2
        elif arg.startswith("--delay="):
            delay_seconds = int(arg.split("=")[1])
        elif arg == "--now":
            immediate_mode = True

    if not args or args == ["--help"]:
        print("""
  🐉 DRAGON MODE - Cold Automated Enforcement

  "No emotion. Only judgment. Only execution."

  TIERS:
  ┌──────┬─────────┬─────────────────────────────────┬─────────────┐
  │ Tier │ Risk    │ Actions                         │ Reversible  │
  ├──────┼─────────┼─────────────────────────────────┼─────────────┤
  │ T1   │ 🟢 Safe │ Service restart, show warnings  │ ✅ Yes      │
  ├──────┼─────────┼─────────────────────────────────┼─────────────┤
  │ T2   │ 🟡 Warn │ Kill zombies, sessions, block IP│ ⚠️  Partial │
  ├──────┼─────────┼─────────────────────────────────┼─────────────┤
  │ T3   │ 🔴 Risk │ Delete files, clean logs, /tmp  │ ❌ No       │
  └──────┴─────────┴─────────────────────────────────┴─────────────┘

  USAGE:
    mpop enforce              Scan only (dry-run)
    mpop enforce --t1         Execute T1 only (safe)
    mpop enforce --t2         Execute T1+T2 (caution)
    mpop enforce --t3 --force Execute all (snapshot required)
    mpop enforce -i           Interactive mode
    mpop enforce v2           Scan specific node

  FLAGS:
    --t1            Execute T1 (safe) only
    --t2            Execute T1+T2
    --t3 --force    Execute all (--force required!)
    -i, --interactive  Confirm each action
    -t=<node>       Target specific node
    --delay=N       Wait N sec before T2/T3 (default: 5)
    --now           Execute immediately (no delay)

  UNDO (T3 only):
    mpop enforce undo         Undo last action
    mpop enforce undo --list  List undoable actions

  ⚠️  T3 never executes without --force!
  ⚠️  T2/T3 wait 5 sec by default (Ctrl+C to cancel)
""")
        return

    # Handle undo subcommand
    if args and args[0] == "undo":
        _enforce_undo_command(args[1:])
        return

    # T3 requires --force
    if tier_mode == 3 and not force_mode:
        print("""
  ❌ T3 requires --force flag!

  T3 actions are IRREVERSIBLE:
    - File deletion
    - Log cleanup
    - /tmp deletion

  To proceed:
    mpop enforce --t3 --force
""")
        return

    print(DRAGON_ASCII)
    print("  🐉 DRAGON MODE ACTIVATED")
    print("  ═══════════════════════════════════════════════════════════════")

    if tier_mode is not None:
        tier_label = f"T1" if tier_mode == 1 else f"T1+T2" if tier_mode == 2 else "T1+T2+T3"
        print(f"  ⚠️  EXECUTE MODE: {tier_label} actions will be performed!")
    elif interactive_mode:
        print("  🔄 INTERACTIVE MODE: Will ask for each action")
    else:
        print("  📋 DRY-RUN MODE: Showing what would be done...")

    print()

    # Scan script
    scan_script = r'''
    echo "=== DRAGON SCAN ==="

    # Disk usage
    DISK=$(df -h / | tail -1 | awk '{print $5}' | tr -d '%')
    echo "DISK:$DISK"

    # Load
    LOAD=$(cat /proc/loadavg 2>/dev/null | awk '{print $1}' || uptime | grep -oE "load average[s]?: [0-9.]+" | awk '{print $NF}')
    echo "LOAD:$LOAD"

    # Memory
    MEM=$(free 2>/dev/null | awk '/Mem:/{printf "%.0f", $3/$2*100}' || vm_stat 2>/dev/null | awk '/Pages active/{print int($3*4096/1024/1024/1024*10)}' || echo "0")
    echo "MEM:$MEM"

    # SSH attacks (24h)
    CUTOFF=$(date -d '24 hours ago' '+%Y-%m-%dT%H:%M' 2>/dev/null || date -v-24H '+%Y-%m-%dT%H:%M')
    AUTH_24H=$(awk -v d=$CUTOFF '$0 >= d' /var/log/auth.log 2>/dev/null || journalctl -u sshd -u ssh --since '24 hours ago' 2>/dev/null)
    ATTACKS=$(echo "$AUTH_24H" | grep -c "Failed" || echo "0")
    echo "ATTACKS:$ATTACKS"

    # Top attacker IP (24h)
    TOP_IP=$(echo "$AUTH_24H" | grep "Failed" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | sort | uniq -c | sort -rn | head -1 | awk '{print $2}' || echo "none")
    TOP_COUNT=$(echo "$AUTH_24H" | grep "Failed" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | sort | uniq -c | sort -rn | head -1 | awk '{print $1}' || echo "0")
    echo "TOP_IP:$TOP_IP:$TOP_COUNT"

    # Zombie processes
    ZOMBIES=$(ps aux 2>/dev/null | grep -c "Z" || echo "0")
    echo "ZOMBIES:$ZOMBIES"

    # Log size
    LOG_SIZE=$(du -sm /var/log 2>/dev/null | awk '{print $1}' || echo "0")
    echo "LOG_SIZE:$LOG_SIZE"

    # Failed services
    FAILED_SVC=$(systemctl --failed 2>/dev/null | grep -c "failed" || echo "0")
    echo "FAILED_SVC:$FAILED_SVC"

    # Temp files size
    TMP_SIZE=$(du -sm /tmp 2>/dev/null | awk '{print $1}' || echo "0")
    echo "TMP_SIZE:$TMP_SIZE"

    # Old kernels (Debian/Ubuntu)
    OLD_KERNELS=$(dpkg -l 'linux-image-*' 2>/dev/null | grep -c "^ii" || echo "0")
    echo "OLD_KERNELS:$OLD_KERNELS"

    # Docker cleanup potential
    DOCKER_SPACE=$(docker system df 2>/dev/null | tail -1 | awk '{print $4}' | tr -d 'GB' || echo "0")
    echo "DOCKER_RECLAIMABLE:$DOCKER_SPACE"

    # Stale login sessions (zombie sessions)
    SESSIONS=$(loginctl list-sessions --no-legend 2>/dev/null | wc -l || echo "0")
    echo "SESSIONS:$SESSIONS"
    '''

    nodes_to_scan = [target_node] if target_node else [n for n in SERVERS.keys() if n != get_my_name()]

    all_actions = []

    for node in nodes_to_scan:
        ip = SERVERS.get(node)
        if not ip:
            continue

        print(f"  ──────────────────────────────────────────")
        print(f"  🔍 Scanning {node} ({ip})...")

        result = vssh_exec(ip, scan_script, timeout=10)
        if not result:
            print(f"     ❌ Failed to scan")
            continue

        # Parse results
        data = {}
        for line in result.strip().split('\n'):
            if ':' in line and not line.startswith('==='):
                parts = line.split(':', 1)
                if len(parts) == 2:
                    data[parts[0].strip()] = parts[1].strip()

        disk = int(data.get('DISK', '0') or '0')
        load = float(data.get('LOAD', '0') or '0')
        attacks = int(data.get('ATTACKS', '0') or '0')
        zombies = int(data.get('ZOMBIES', '0') or '0')
        log_size = int(data.get('LOG_SIZE', '0') or '0')
        tmp_size = int(data.get('TMP_SIZE', '0') or '0')
        failed_svc = int(data.get('FAILED_SVC', '0') or '0')
        sessions = int(data.get('SESSIONS', '0') or '0')

        top_ip_data = data.get('TOP_IP', 'none:0')
        if ':' in top_ip_data:
            parts = top_ip_data.rsplit(':', 1)
            top_ip = parts[0]
            top_count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
        else:
            top_ip, top_count = 'none', 0

        actions = []

        # Decision logic - cold, calculated
        # Tier 1 (🟢 Safe): reversible actions
        # Tier 2 (🟡 Caution): partially reversible
        # Tier 3 (🔴 Danger): not reversible, requires --force

        if disk > 90:
            actions.append({
                'type': 'DISK_CRITICAL',
                'tier': 3,  # 🔴 delete files
                'msg': f'Disk at {disk}%',
                'cmd': 'journalctl --vacuum-time=3d; apt-get clean 2>/dev/null; rm -rf /tmp/* 2>/dev/null; find /var/log -name "*.gz" -delete 2>/dev/null; echo "DISK_CLEANED"',
                'snapshot_paths': ['/tmp', '/var/log/*.gz'],
            })
        elif disk > 80:
            actions.append({
                'type': 'DISK_WARNING',
                'tier': 1,  # 🟢 log rotation only
                'msg': f'Disk at {disk}%',
                'cmd': 'journalctl --vacuum-time=7d; echo "LOGS_ROTATED"',
            })

        if load > 10:
            actions.append({
                'type': 'LOAD_CRITICAL',
                'tier': 1,  # 🟢 info display only
                'msg': f'Load at {load}',
                'cmd': 'ps aux --sort=-%cpu | head -5',
                'info_only': True
            })
        elif load > 8:
            actions.append({
                'type': 'LOAD_HIGH',
                'tier': 1,  # 🟢 info display only
                'msg': f'Load at {load}',
                'cmd': 'ps aux --sort=-%cpu | head -3',
                'info_only': True
            })

        if attacks > 500 and top_ip != 'none' and top_count > 100:
            actions.append({
                'type': 'ATTACK_BLOCK',
                'tier': 2,  # 🟡 IP block (can unblock)
                'msg': f'{attacks} attacks, top: {top_ip} ({top_count}x)',
                'cmd': f'iptables -I INPUT -s {top_ip} -j DROP && echo "BLOCKED:{top_ip}"',
            })

        if zombies > 5:
            actions.append({
                'type': 'ZOMBIE_KILL',
                'tier': 2,  # 🟡 kill process
                'msg': f'{zombies} zombie processes',
                'cmd': 'ps aux | grep "Z" | awk \'{print $2}\' | xargs -r kill -9 2>/dev/null; echo "ZOMBIES_KILLED"',
            })

        if log_size > 5000:  # 5GB
            actions.append({
                'type': 'LOG_CLEANUP',
                'tier': 3,  # 🔴 delete logs
                'msg': f'Logs: {log_size}MB',
                'cmd': 'journalctl --vacuum-size=500M; find /var/log -name "*.log" -size +100M -exec truncate -s 0 {} \\; 2>/dev/null; echo "LOGS_CLEANED"',
                'snapshot_paths': ['/var/log'],
            })

        if tmp_size > 2000:  # 2GB
            actions.append({
                'type': 'TMP_CLEANUP',
                'tier': 3,  # 🔴 delete tmp
                'msg': f'/tmp: {tmp_size}MB',
                'cmd': 'find /tmp -type f -mtime +7 -delete 2>/dev/null; echo "TMP_CLEANED"',
                'snapshot_paths': ['/tmp'],
            })

        if failed_svc > 0:
            actions.append({
                'type': 'SERVICE_RESTART',
                'tier': 1,  # 🟢 restart service
                'msg': f'{failed_svc} failed service(s)',
                'cmd': 'systemctl --failed --no-legend | awk \'{print $1}\' | head -3 | xargs -r systemctl restart; echo "SERVICES_RESTARTED"',
            })

        if sessions > 100:
            actions.append({
                'type': 'ZOMBIE_SESSIONS',
                'tier': 2,  # 🟡 clean sessions
                'msg': f'{sessions} stale login sessions',
                'cmd': "systemctl restart systemd-logind 2>/dev/null; sleep 2; echo 'SESSIONS_CLEANED'",
            })

        # Print status
        has_t3 = any(a.get('tier') == 3 for a in actions)
        has_t2 = any(a.get('tier') == 2 for a in actions)
        status_emoji = "🔴" if has_t3 else "🟡" if has_t2 else "🟢" if actions else "✅"
        print(f"     Status: {status_emoji} Disk:{disk}% Load:{load} Attacks:{attacks}")

        if not actions:
            print(f"     ✓ No action required")
        else:
            # Group by tier
            t1_actions = [a for a in actions if a.get('tier') == 1]
            t2_actions = [a for a in actions if a.get('tier') == 2]
            t3_actions = [a for a in actions if a.get('tier') == 3]

            print(f"     ⚡ {len(actions)} action(s) identified:")

            if t1_actions:
                print(f"        🟢 T1 (Safe):")
                for action in t1_actions:
                    print(f"           [{action['type']}] {action['msg']}")

            if t2_actions:
                print(f"        🟡 T2 (Caution):")
                for action in t2_actions:
                    print(f"           [{action['type']}] {action['msg']}")

            if t3_actions:
                print(f"        🔴 T3 (Danger):")
                for action in t3_actions:
                    print(f"           [{action['type']}] {action['msg']}")

            # Execute based on tier_mode
            for action in actions:
                action_tier = action.get('tier', 1)
                should_execute = False

                if tier_mode is not None and tier_mode >= action_tier:
                    should_execute = True

                if interactive_mode and not action.get('info_only'):
                    tier_icon = "🟢" if action_tier == 1 else "🟡" if action_tier == 2 else "🔴"
                    confirm = input(f"        {tier_icon} Execute [{action['type']}]? (y/N): ")
                    should_execute = confirm.lower() == 'y'

                if should_execute and not action.get('info_only'):
                    # Countdown delay for T2/T3 (unless interactive or --now)
                    if action_tier >= 2 and not interactive_mode and not immediate_mode and delay_seconds > 0:
                        tier_icon = "🟡" if action_tier == 2 else "🔴"
                        print(f"        {tier_icon} [{action['type']}] in {delay_seconds}s... (Ctrl+C to cancel)")
                        try:
                            for i in range(delay_seconds, 0, -1):
                                sys.stdout.write(f"\r        {tier_icon} [{action['type']}] in {i}s... (Ctrl+C to cancel)  ")
                                sys.stdout.flush()
                                time.sleep(1)
                            sys.stdout.write(f"\r        {tier_icon} [{action['type']}] executing...                     \n")
                            sys.stdout.flush()
                        except KeyboardInterrupt:
                            print(f"\n        ❌ Cancelled by user")
                            all_actions.append({'node': node, **action, 'skipped': True, 'cancelled': True})
                            continue

                    # T3 actions require snapshot first
                    if action_tier == 3:
                        snapshot_paths = action.get('snapshot_paths', [])
                        if snapshot_paths:
                            print(f"        📦 Creating snapshot for [{action['type']}]...")
                            try:
                                snapshot = _enforce_create_snapshot(node, ip, action['type'], snapshot_paths)
                                print(f"           {snapshot.get('original_size', '?')} → {snapshot.get('compressed_size', '?')} compressed")
                                print(f"           Saved: {snapshot.get('snapshot_file', '?')}")
                            except Exception as e:
                                print(f"           ⚠️ Snapshot failed: {e}")
                                # Ask if should continue without snapshot
                                cont = input("           Continue without snapshot? (y/N): ")
                                if cont.lower() != 'y':
                                    print("           Skipped.")
                                    all_actions.append({'node': node, **action, 'skipped': True})
                                    continue

                    print(f"        → Executing [{action['type']}]...", end=" ", flush=True)
                    exec_result = vssh_exec(ip, action['cmd'], timeout=30)
                    if exec_result and ('CLEANED' in exec_result or 'BLOCKED' in exec_result or 'KILLED' in exec_result or 'RESTARTED' in exec_result):
                        print("✓ Done")
                    else:
                        print("? Check manually")

                all_actions.append({'node': node, **action})

    # Summary
    print()
    print("  ═══════════════════════════════════════════════════════════════")
    print("  🐉 DRAGON SUMMARY")
    print("  ───────────────────────────────────────────────────────────────")

    if not all_actions:
        print("""
     "All systems nominal. No intervention required."

     No violations found.
     But its eyes remain open.

        ,===:      ,===:
       (o o)      (o o)
      (  V  )    (  V  )
      /|~~~|\\    /|~~~|\\
     / |   | \\  / |   | \\
        | |        | |
        ^ ^        ^ ^
""")
    else:
        t1_count = sum(1 for a in all_actions if a.get('tier') == 1)
        t2_count = sum(1 for a in all_actions if a.get('tier') == 2)
        t3_count = sum(1 for a in all_actions if a.get('tier') == 3)

        print(f"""
     Total actions: {len(all_actions)}
     ├─ 🟢 T1 (Safe):    {t1_count}
     ├─ 🟡 T2 (Caution): {t2_count}
     └─ 🔴 T3 (Danger):  {t3_count}
""")
        # Show execution options
        if tier_mode is None:
            print("  Options:")
            if t1_count > 0:
                print("    mpop enforce --t1         Execute T1 only (safe)")
            if t2_count > 0:
                print("    mpop enforce --t2         Execute T1+T2")
            if t3_count > 0:
                print("    mpop enforce --t3 --force Execute all (danger!)")
            print("    mpop enforce -i           Interactive mode")
            print()

        if tier_mode is not None or interactive_mode:
            print("""     "Execution complete. Dragon rests."
""")
        else:
            print("""     "This was a simulation."

     Enforce ready.
""")

    print("  ═══════════════════════════════════════════════════════════════")

# ==================== Benchmark Command ====================

def cmd_benchmark(args):
    """Benchmark mpop scalability"""
    import time
    import resource
    import statistics

    # Parse arguments
    num_nodes = 100
    test_mode = "full"  # full, ping, exec, parallel

    for arg in args:
        if arg.startswith("--nodes="):
            num_nodes = int(arg.split("=")[1])
        elif arg.startswith("-n="):
            num_nodes = int(arg.split("=")[1])
        elif arg.isdigit():
            num_nodes = int(arg)
        elif arg in ["ping", "exec", "parallel", "full"]:
            test_mode = arg

    if not args or "--help" in args:
        print("""
  📊 mpop benchmark - Scalability Testing

  USAGE:
    mpop benchmark              Run full benchmark (100 virtual nodes)
    mpop benchmark 500          Test with 500 virtual nodes
    mpop benchmark --nodes=200  Specify node count
    mpop benchmark ping         Test ping only
    mpop benchmark parallel     Test parallel execution

  TESTS:
    • Ping latency (per node)
    • vssh connection time
    • Command execution throughput
    • Parallel scaling (10, 50, 100, N workers)
    • Memory usage
    • Bottleneck identification

  OUTPUT:
    Produces marketing-ready scalability report.
""")
        return

    print(f"""
  ╔══════════════════════════════════════════════════════════════════╗
  ║            📊 mpop SCALABILITY BENCHMARK                        ║
  ║                   Testing {num_nodes:,} virtual nodes                      ║
  ╚══════════════════════════════════════════════════════════════════╝
""")

    results = {
        'nodes_tested': num_nodes,
        'real_nodes': len(SERVERS),
        'tests': {}
    }

    # Get real server list for actual testing
    real_servers = [(name, ip) for name, ip in SERVERS.items() if name != get_my_name()]

    # ===== TEST 1: Ping Latency =====
    print("  ┌─────────────────────────────────────────────────────────────┐")
    print("  │ TEST 1: Network Latency (Real Nodes)                       │")
    print("  └─────────────────────────────────────────────────────────────┘")

    ping_times = []
    for name, ip in real_servers:
        start = time.time()
        result = ping_server(ip, timeout=2)
        elapsed = (time.time() - start) * 1000
        if result:
            ping_times.append(elapsed)
            print(f"    {name}: {elapsed:.1f}ms ✓")
        else:
            print(f"    {name}: timeout ✗")

    if ping_times:
        avg_ping = statistics.mean(ping_times)
        max_ping = max(ping_times)
        results['tests']['ping'] = {
            'avg_ms': avg_ping,
            'max_ms': max_ping,
            'success_rate': len(ping_times) / len(real_servers) * 100
        }
        print(f"\n    Average: {avg_ping:.1f}ms | Max: {max_ping:.1f}ms")

    # ===== TEST 2: vssh Connection =====
    print("\n  ┌─────────────────────────────────────────────────────────────┐")
    print("  │ TEST 2: vssh Connection Speed                              │")
    print("  └─────────────────────────────────────────────────────────────┘")

    vssh_times = []
    for name, ip in real_servers[:5]:  # Test first 5
        start = time.time()
        result = vssh_exec(ip, "echo OK", timeout=5)
        elapsed = (time.time() - start) * 1000
        if result and "OK" in result:
            vssh_times.append(elapsed)
            print(f"    {name}: {elapsed:.0f}ms ✓")
        else:
            print(f"    {name}: failed ✗")

    if vssh_times:
        avg_vssh = statistics.mean(vssh_times)
        results['tests']['vssh'] = {
            'avg_ms': avg_vssh,
            'throughput_per_sec': 1000 / avg_vssh if avg_vssh > 0 else 0
        }
        print(f"\n    Average: {avg_vssh:.0f}ms | Throughput: {1000/avg_vssh:.1f} conn/sec")

    # ===== TEST 3: Parallel Scaling =====
    print("\n  ┌─────────────────────────────────────────────────────────────┐")
    print("  │ TEST 3: Parallel Execution Scaling                         │")
    print("  └─────────────────────────────────────────────────────────────┘")

    worker_counts = [10, 25, 50, min(100, num_nodes)]
    parallel_results = []

    for workers in worker_counts:
        # Simulate N nodes by repeating real nodes
        simulated = (real_servers * ((num_nodes // len(real_servers)) + 1))[:num_nodes]

        start = time.time()
        completed = 0
        failed = 0

        with ThreadPoolExecutor(max_workers=workers) as ex:
            futures = {ex.submit(ping_server, ip, 1): (name, ip)
                       for name, ip in simulated[:workers*2]}  # Test 2x workers

            for future in as_completed(futures, timeout=30):
                try:
                    if future.result():
                        completed += 1
                    else:
                        failed += 1
                except Exception:
                    failed += 1

        elapsed = time.time() - start
        rate = completed / elapsed if elapsed > 0 else 0

        parallel_results.append({
            'workers': workers,
            'completed': completed,
            'failed': failed,
            'time_sec': elapsed,
            'rate': rate
        })

        print(f"    Workers={workers:3}: {completed:3} ops in {elapsed:.2f}s → {rate:.1f} ops/sec")

    results['tests']['parallel'] = parallel_results

    # ===== TEST 4: Memory Usage =====
    print("\n  ┌─────────────────────────────────────────────────────────────┐")
    print("  │ TEST 4: Memory & Resource Usage                            │")
    print("  └─────────────────────────────────────────────────────────────┘")

    try:
        usage = resource.getrusage(resource.RUSAGE_SELF)
        mem_mb = usage.ru_maxrss / 1024 / 1024  # Convert to MB (macOS is bytes, Linux is KB)
        if sys.platform == "linux":
            mem_mb = usage.ru_maxrss / 1024

        results['tests']['memory'] = {'peak_mb': mem_mb}
        print(f"    Peak Memory: {mem_mb:.1f} MB")
        print(f"    User CPU Time: {usage.ru_utime:.2f}s")
        print(f"    System CPU Time: {usage.ru_stime:.2f}s")
    except Exception:
        print(f"    (resource metrics unavailable)")

    # ===== TEST 5: Simulated Scale Test =====
    print("\n  ┌─────────────────────────────────────────────────────────────┐")
    print(f"  │ TEST 5: Simulated {num_nodes}-Node Scan                              │")
    print("  └─────────────────────────────────────────────────────────────┘")

    # Extrapolate based on real results
    if vssh_times and parallel_results:
        best_parallel = max(parallel_results, key=lambda x: x['rate'])
        estimated_time = num_nodes / best_parallel['rate']

        # Calculate with optimal workers
        optimal_workers = min(num_nodes, 100)  # Cap at 100
        estimated_optimal = num_nodes / (best_parallel['rate'] * (optimal_workers / best_parallel['workers']))

        print(f"    Nodes to scan: {num_nodes:,}")
        print(f"    Best worker count: {best_parallel['workers']}")
        print(f"    Best throughput: {best_parallel['rate']:.1f} ops/sec")
        print(f"    Estimated scan time: {estimated_time:.1f}s")
        print(f"    With {optimal_workers} workers: ~{estimated_optimal:.1f}s")

        results['tests']['simulation'] = {
            'nodes': num_nodes,
            'estimated_time_sec': estimated_time,
            'optimal_workers': optimal_workers
        }

    # ===== BOTTLENECK ANALYSIS =====
    print("\n  ┌─────────────────────────────────────────────────────────────┐")
    print("  │ 🔍 BOTTLENECK ANALYSIS                                     │")
    print("  └─────────────────────────────────────────────────────────────┘")

    bottlenecks = []

    if ping_times and max(ping_times) > 500:
        bottlenecks.append(("Network Latency", "HIGH", "Some nodes have >500ms latency"))

    if vssh_times and statistics.mean(vssh_times) > 200:
        bottlenecks.append(("vssh Overhead", "MEDIUM", f"Avg {statistics.mean(vssh_times):.0f}ms per connection"))

    if parallel_results:
        scaling_efficiency = parallel_results[-1]['rate'] / parallel_results[0]['rate']
        if scaling_efficiency < 2:
            bottlenecks.append(("Parallel Scaling", "MEDIUM", f"Only {scaling_efficiency:.1f}x improvement with more workers"))

    if not bottlenecks:
        bottlenecks.append(("None Detected", "OK", "System scales well"))

    for name, severity, desc in bottlenecks:
        icon = "🔴" if severity == "HIGH" else "🟡" if severity == "MEDIUM" else "🟢"
        print(f"    {icon} {name}: {desc}")

    # ===== FINAL REPORT =====
    print(f"""
  ╔══════════════════════════════════════════════════════════════════╗
  ║                    📊 BENCHMARK RESULTS                         ║
  ╠══════════════════════════════════════════════════════════════════╣
  ║                                                                  ║
  ║  Real Nodes Tested:     {len(real_servers):>5}                                  ║
  ║  Virtual Nodes Simulated: {num_nodes:>5}                                  ║
  ║  Avg Ping Latency:      {avg_ping:>5.0f}ms                                 ║
  ║  Avg vssh Time:         {avg_vssh if vssh_times else 0:>5.0f}ms                                 ║
  ║  Best Throughput:       {best_parallel['rate'] if parallel_results else 0:>5.1f} ops/sec                          ║
  ║  Estimated {num_nodes}-node scan: {estimated_time if 'estimated_time' in dir() else 0:>5.1f}s                            ║
  ║                                                                  ║
  ╠══════════════════════════════════════════════════════════════════╣
  ║                                                                  ║
  ║  ✅ VERDICT: {"Scales to " + str(num_nodes) + "+ nodes":^42}    ║
  ║                                                                  ║
  ╚══════════════════════════════════════════════════════════════════╝
""")

    # Marketing badge
    if num_nodes >= 100:
        badge_text = f"Tested with {num_nodes}+ nodes"
        print(f"""
  ┌────────────────────────────────────────┐
  │  🏆 SCALABILITY CERTIFIED              │
  │                                        │
  │     ✓ {badge_text:<30} │
  │     ✓ Sub-second per-node latency     │
  │     ✓ Parallel execution optimized    │
  │                                        │
  │  Ready for enterprise deployment.     │
  └────────────────────────────────────────┘
""")

def cmd_config(args):
    """Show or modify mpop configuration"""
    config = _load_config()

    if not config:
        print(f"""
  {C.YELLOW}Config not found.{C.R}
  Run {C.BOLD}mpop init{C.R} to create one.
""")
        return

    if not args:
        # Show current config summary
        conn = config.get("connection", {})
        vpn_type = conn.get("vpn", "wire")
        ssh_method = conn.get("ssh_method", "ssh")
        use_agent = conn.get("use_server_agent", False)

        servers = config.get("servers", {})
        relays = config.get("relays", [])

        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}⚙️  mpop Configuration{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}General:{C.R}
    Language:       {C.GREEN}{config.get('language', 'en')}{C.R}
    Ollama Host:    {config.get('ollama_host', 'localhost:11434')}
    Ollama Model:   {config.get('ollama_model', 'qwen2.5:7b')}
    LLM Model:      {config.get('llm_model', 'gpt-4o-mini')}

  {C.BOLD}Connection:{C.R}
    VPN Type:       {C.GREEN}{vpn_type}{C.R}
    SSH Method:     {C.GREEN}{ssh_method}{C.R}
    Server Agent:   {C.GREEN if use_agent else C.YELLOW}{'enabled' if use_agent else 'disabled'}{C.R}

  {C.BOLD}Network:{C.R}
    VPN Prefix:     {config.get('vpn_prefix', 'N/A')}
    Tailscale:      {config.get('tailscale_prefix', 'N/A')}
    API Port:       {config.get('api_port', 8786)}

  {C.BOLD}Servers:{C.R} {len(servers)} configured
    {', '.join(servers.keys())}

  {C.BOLD}Relays:{C.R} {len(relays)} configured

  {C.BOLD}Config file:{C.R}
    {CONFIG_FILE}

  {C.DIM}Edit: nano {CONFIG_FILE}{C.R}
  {C.DIM}Test: mpop config test{C.R}
""")
        return

    if args[0] == "test":
        # Test connection to each server
        global_method = _get_ssh_method()
        global_vpn = _get_vpn_type()
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🔌 Connection Test{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Default Method:{C.R}  {C.CYAN}{global_method}{C.R}
  {C.BOLD}Default VPN:{C.R}     {C.CYAN}{global_vpn}{C.R}
  {C.BOLD}Config:{C.R}          {CONFIG_FILE}
""")
        servers = config.get("servers", {})
        my_name = get_my_name()

        # Header
        print(f"  {C.BOLD}{'NAME':<6} {'VPN':<5} {'METHOD':<6} {'TARGET':<18} {'USER':<8} {'LATENCY':<10} {'STATUS'}{C.R}")
        print(f"  {'-'*80}")

        ok_count = 0
        fail_count = 0

        for name, srv in servers.items():
            user = srv.get("user", "root")
            port = srv.get("ssh_port", 22)

            # Server-specific override or global
            srv_method = srv.get("ssh_method") or global_method
            srv_vpn = srv.get("vpn") or global_vpn

            # Short display names
            method_short = {"vssh": "vssh", "ssh": "ssh", "tailscale-ssh": "ts-ssh"}.get(srv_method, srv_method)
            vpn_short = {"wire": "aw", "tailscale": "ts", "none": "-"}.get(srv_vpn, srv_vpn)

            # Mark overrides with *
            if srv.get("ssh_method"):
                method_short = f"{method_short}*"
            if srv.get("vpn"):
                vpn_short = f"{vpn_short}*"

            if name == my_name:
                print(f"  {C.CYAN}{name:<6}{C.R} {'-':<5} {'-':<6} {'localhost':<18} {user:<8} {'-':<10} {C.CYAN}LOCAL{C.R}")
                continue

            # Show target for the connection
            if srv_method == "tailscale-ssh":
                target = srv.get("tailscale_hostname") or srv.get("tailscale_ip") or name
            elif srv_vpn == "tailscale":
                target = srv.get("tailscale_ip") or srv.get("ip")
            elif srv_vpn == "wire":
                target = srv.get("ip")
            else:
                target = srv.get("public_ip") or srv.get("ip")

            # Measure latency
            import time
            start = time.time()
            result = ssh_exec(name, "echo OK", timeout=10)
            latency = (time.time() - start) * 1000  # ms

            if result and "OK" in result:
                ok_count += 1
                latency_str = f"{latency:.0f}ms"
                if latency < 100:
                    latency_color = C.GREEN
                elif latency < 500:
                    latency_color = C.YELLOW
                else:
                    latency_color = C.RED
                print(f"  {name:<6} {vpn_short:<5} {method_short:<6} {target:<18} {user:<8} {latency_color}{latency_str:<10}{C.R} {C.GREEN}✅ OK{C.R}")
            else:
                fail_count += 1
                print(f"  {name:<6} {vpn_short:<5} {method_short:<6} {target:<18} {user:<8} {'-':<10} {C.RED}❌ FAIL{C.R}")

        print(f"  {'-'*80}")
        print(f"  {C.BOLD}Total:{C.R} {C.GREEN}{ok_count} OK{C.R} | {C.RED if fail_count else C.DIM}{fail_count} FAIL{C.R}")
        print(f"  {C.DIM}* = server-specific override{C.R}")
        return

    if args[0] == "path":
        print(CONFIG_FILE)
        return

    if args[0] == "edit":
        import os
        editor = os.environ.get("EDITOR", "nano")
        os.system(f"{editor} {CONFIG_FILE}")
        return

    if args[0] == "get":
        # mpop config get key
        if len(args) < 2:
            print(f"  Usage: mpop config get <key>")
            print(f"  Example: mpop config get connection.vpn")
            return
        key = args[1]
        value = _get_config(key)
        if value is not None:
            if isinstance(value, (dict, list)):
                print(json.dumps(value, indent=2))
            else:
                print(value)
        else:
            print(f"  Key not found: {key}")
        return

    if args[0] == "set":
        # mpop config set key value
        if len(args) < 3:
            print(f"  Usage: mpop config set <key> <value>")
            print(f"  Example: mpop config set connection.vpn tailscale")
            return

        key = args[1]
        # Resolve known aliases to canonical dot-notation path
        key = _CONFIG_ALIASES.get(key, key)
        raw_value = " ".join(args[2:])  # Allow spaces in value

        # Auto-detect value type
        if raw_value.lower() == "true":
            value = True
        elif raw_value.lower() == "false":
            value = False
        elif raw_value.lower() == "null":
            value = None
        elif raw_value.isdigit():
            value = int(raw_value)
        elif raw_value.replace(".", "", 1).isdigit():
            value = float(raw_value)
        elif raw_value.startswith(("{", "[")):
            try:
                value = json.loads(raw_value)
            except Exception:
                value = raw_value
        else:
            value = raw_value

        # Set the value using dot notation
        keys = key.split(".")
        current = config

        # Navigate to parent
        for k in keys[:-1]:
            if k not in current:
                current[k] = {}
            current = current[k]

        # Get old value for display
        old_value = current.get(keys[-1], "<not set>")

        # Set new value
        current[keys[-1]] = value

        # Save config
        try:
            with open(CONFIG_FILE, "w") as f:
                json.dump(config, f, indent=2, ensure_ascii=False)

            print(f"  {C.GREEN}✓{C.R} {key}")
            print(f"    {C.DIM}{old_value}{C.R} → {C.CYAN}{value}{C.R}")
        except Exception as e:
            print(f"  {C.RED}Error saving config:{C.R} {e}")
        return

    if args[0] == "del" or args[0] == "delete":
        # mpop config del key
        if len(args) < 2:
            print(f"  Usage: mpop config del <key>")
            return

        key = args[1]
        keys = key.split(".")
        current = config

        # Navigate to parent
        for k in keys[:-1]:
            if k not in current:
                print(f"  Key not found: {key}")
                return
            current = current[k]

        if keys[-1] not in current:
            print(f"  Key not found: {key}")
            return

        old_value = current[keys[-1]]
        del current[keys[-1]]

        try:
            with open(CONFIG_FILE, "w") as f:
                json.dump(config, f, indent=2, ensure_ascii=False)

            print(f"  {C.GREEN}✓{C.R} Deleted {key}")
            print(f"    Was: {C.DIM}{old_value}{C.R}")
        except Exception as e:
            print(f"  {C.RED}Error saving config:{C.R} {e}")
        return

    if args[0] == "auto":
        # Auto-detect best connection method for each server
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🔍 Auto-detecting best connection method...{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")
        servers = config.get("servers", {})
        my_name = get_my_name()
        changes = []

        for name, srv in servers.items():
            if name == my_name:
                continue

            # Skip if no tailscale_ip (can't test tailscale)
            ts_ip = srv.get("tailscale_ip")
            aw_ip = srv.get("ip")

            if not ts_ip and not aw_ip:
                print(f"  {name}: {C.DIM}skipped (no IP){C.R}")
                continue

            print(f"  {name}: ", end="", flush=True)

            # Test wire first (vssh)
            aw_ok = False
            aw_latency = 9999
            if aw_ip:
                import time
                start = time.time()
                result = vssh_exec(aw_ip, "echo OK", timeout=5)
                if result and "OK" in result:
                    aw_ok = True
                    aw_latency = (time.time() - start) * 1000

            # Test tailscale (vssh)
            ts_ok = False
            ts_latency = 9999
            if ts_ip:
                import time
                start = time.time()
                result = vssh_exec(ts_ip, "echo OK", timeout=5)
                if result and "OK" in result:
                    ts_ok = True
                    ts_latency = (time.time() - start) * 1000

            # Determine best option
            current_vpn = srv.get("vpn", "")
            best_vpn = None

            if aw_ok and ts_ok:
                # Both work - pick faster one (with 20ms tolerance)
                if aw_latency < ts_latency - 20:
                    best_vpn = "wire"
                    reason = f"wire faster ({aw_latency:.0f}ms vs {ts_latency:.0f}ms)"
                else:
                    best_vpn = "tailscale"
                    reason = f"tailscale ({ts_latency:.0f}ms)"
            elif aw_ok:
                best_vpn = "wire"
                reason = f"only wire works ({aw_latency:.0f}ms)"
            elif ts_ok:
                best_vpn = "tailscale"
                reason = f"only tailscale works ({ts_latency:.0f}ms)"
            else:
                print(f"{C.RED}❌ both failed{C.R}")
                continue

            # Check if change needed
            if current_vpn != best_vpn:
                changes.append((name, current_vpn or "default", best_vpn))
                srv["vpn"] = best_vpn
                print(f"{C.YELLOW}→ {best_vpn}{C.R} ({reason})")
            else:
                print(f"{C.GREEN}✓ {best_vpn}{C.R} ({reason})")

        if changes:
            # Save config
            try:
                with open(CONFIG_FILE, "w") as f:
                    json.dump(config, f, indent=2, ensure_ascii=False)

                print(f"\n  {C.BOLD}Changes applied:{C.R}")
                for name, old, new in changes:
                    print(f"    {name}: {C.DIM}{old}{C.R} → {C.CYAN}{new}{C.R}")
            except Exception as e:
                print(f"\n  {C.RED}Error saving config:{C.R} {e}")
        else:
            print(f"\n  {C.GREEN}✓{C.R} No changes needed")

        return

    # Show specific key (shorthand for 'get')
    key = args[0]
    value = _get_config(key)
    if value is not None:
        if isinstance(value, (dict, list)):
            print(json.dumps(value, indent=2))
        else:
            print(value)
    else:
        print(f"  Key not found: {key}")

def _vault_auto_init() -> str:
    """Try to initialize SecureVault as part of mpop init.

    Prompts for a master password interactively.
    Returns: "initialized" | "already_initialized" | "skipped" | "not_available" | "error"
    """
    import sys as _sys
    import os as _os

    vault_dir = _os.path.expanduser("~/.sv-vault")
    meta_path = _os.path.join(vault_dir, "meta.json")

    if _os.path.exists(meta_path):
        return "already_initialized"

    # Try to import vault module (repo neighbour or installed)
    _vault_mod = None
    for _path in [
        _os.path.normpath(_os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "vault")),
        _os.path.expanduser("~/.local/lib/vault"),
    ]:
        if _os.path.isdir(_path) and _path not in _sys.path:
            _sys.path.insert(0, _path)
    try:
        from vault import SecureVaultManager as _SVM
        _vault_mod = _SVM
    except ImportError:
        return "not_available"

    # Interactive password prompt
    print(f"\n  {C.BOLD}SecureVault init{C.R}  (password / secret manager)")
    print(f"  {C.DIM}Press Enter to skip. Set up later with 'sv init'.{C.R}")
    try:
        import getpass as _gp
        pw = _gp.getpass("  Master password (skip=Enter): ")
        if not pw:
            return "skipped"
        pw2 = _gp.getpass("  Confirm password: ")
        if pw != pw2:
            print(f"  {C.YELLOW}Passwords do not match -- skipping Vault setup{C.R}")
            return "skipped"
    except (KeyboardInterrupt, EOFError):
        print()
        return "skipped"

    try:
        import base64 as _b64
        _transport = None
        try:
            from transport import VsshTransport as _VT
            _transport = _VT()
        except (FileNotFoundError, ImportError, Exception):
            pass
        vm = _vault_mod(vault_dir, transport=_transport)
        res = vm.init(pw)
        shares = [(i, _b64.b64encode(s).decode()) for i, s in res["shares"]]
        print(f"  {C.GREEN}✓ Vault initialized{C.R}  ID: {res['vault_id'][:8]}  ({res['shamir']})")
        print(f"  {C.YELLOW}⚠  Shamir shares -- store each one separately in a safe place!{C.R}")
        for idx, b64 in shares:
            print(f"     Share {idx}: {b64}")
        return "initialized"
    except Exception as _e:
        print(f"  {C.YELLOW}Vault init failed: {_e}{C.R}")
        return "error"


def cmd_init(args):
    """Initialize mpop configuration"""
    import os

    config_dir = os.path.expanduser("~/.mpop")
    config_file = os.path.join(config_dir, "config.json")

    # Check if already exists
    if os.path.exists(config_file) and "--force" not in args:
        print(f"""
  {C.YELLOW}Config already exists:{C.R} {config_file}

  Use {C.BOLD}mpop init --force{C.R} to overwrite.
  Or edit directly: {C.CYAN}nano {config_file}{C.R}
""")
        return

    # Create directory
    os.makedirs(config_dir, exist_ok=True)

    # Auto-detect local machine info
    def _detect_self_info():
        import socket, subprocess, re, platform, getpass
        hostname = socket.gethostname().lower()
        short = re.sub(r'[^a-z0-9]', '', hostname.split('.')[0])
        name = short[:6] if len(short) > 6 else (short or 'local')

        wire_ip = None
        for iface in ['awg0', 'wg0']:
            try:
                if platform.system() == 'Darwin':
                    r = subprocess.run(['ifconfig', iface], capture_output=True, text=True, timeout=3)
                else:
                    r = subprocess.run(['ip', 'addr', 'show', iface], capture_output=True, text=True, timeout=3)
                if r.returncode == 0:
                    m = re.search(r'inet\s+(10\.\d+\.\d+\.\d+)', r.stdout)
                    if m:
                        wire_ip = m.group(1)
                        break
            except Exception:
                pass

        tailscale_ip = None
        try:
            r = subprocess.run(['tailscale', 'ip', '--4'], capture_output=True, text=True, timeout=3)
            if r.returncode == 0:
                ts = r.stdout.strip()
                if ts.startswith('100.'):
                    tailscale_ip = ts
        except Exception:
            pass

        user = os.environ.get('USER') or os.environ.get('USERNAME') or getpass.getuser()
        import platform as _plat
        role = 'Local Mac' if _plat.system() == 'Darwin' else 'Local Linux'
        info = {'local': True, 'role': role, 'user': user}
        if wire_ip:
            info['wire_ip'] = wire_ip
        if tailscale_ip:
            info['tailscale_ip'] = tailscale_ip
        return name, info, hostname

    _self_name, _self_info, _self_hostname = _detect_self_info()

    # Auto-detect local IP for dashboard_url
    _local_ip = _self_info.get("wire_ip") or _self_info.get("tailscale_ip") or ""
    _dashboard_url = f"http://{_local_ip}:8721" if _local_ip else ""

    # Sample config template
    sample_config = {
        "_comment": "mpop configuration - edit this file to match your infrastructure",
        "language": "en",
        "_language_options": ["en", "ko"],
        "dashboard_url": _dashboard_url,
        "connection": {
            "vpn": "tailscale",
            "ssh_method": "vssh",
            "use_server_agent": True,
            "_vpn_options": ["wire", "tailscale", "none"],
            "_ssh_options": ["vssh", "ssh", "tailscale-ssh"]
        },
        "vpn_prefix": "10.0.",
        "tailscale_prefix": "100.",
        "default_ssh_port": 22,
        "api_port": 8786,
        "ollama_host": "localhost:11434",
        "ollama_model": "qwen2.5:7b",
        "llm_model": "ollama",
        "memory_model": "ollama",
        "servers": {
            "web1": {
                "ip": "10.0.1.10",
                "tailscale_ip": "100.x.x.x",
                "public_ip": "203.0.113.10",
                "user": "deploy",
                "ssh_port": 22,
                "role": "Web server",
                "type": "web",
                "services": ["nginx", "docker"],
                "public": True
            },
            "db1": {
                "ip": "10.0.1.20",
                "tailscale_ip": "100.x.x.x",
                "user": "deploy",
                "ssh_port": 22,
                "role": "Database server",
                "type": "database",
                "services": ["postgresql", "redis"],
                "public": False
            },
            "app1": {
                "ip": "10.0.1.30",
                "tailscale_ip": "100.x.x.x",
                "user": "deploy",
                "ssh_port": 22,
                "role": "Application server",
                "type": "app",
                "services": ["docker"],
                "public": False
            }
        },
        "relays": [
            "your-relay-server-ip"
        ],
        "hostname_map": {
            _self_hostname: _self_name,
            "server1": "web1"
        }
    }

    # Insert self as first server
    _servers = {_self_name: _self_info}
    _servers.update(sample_config["servers"])
    sample_config["servers"] = _servers

    # Write config
    with open(config_file, 'w') as f:
        json.dump(sample_config, f, indent=2)

    # #16 vault auto-init: prompt user to set up SecureVault now
    _vault_result = _vault_auto_init()

    # #2: fix terminal PATH
    _path_result = _fix_terminal_path()

    _vault_note = (
        f"  {C.GREEN}✓ SecureVault initialized{C.R}"
        if _vault_result == "initialized" else
        f"  {C.DIM}3. SecureVault init (later): {C.CYAN}sv init{C.R}"
        if _vault_result in ("skipped", "not_available", "error") else
        f"  {C.DIM}3. SecureVault: already initialized{C.R}"
    )
    _path_note = (
        f"  {C.GREEN}✓ PATH configured{C.R} — In a new terminal {C.CYAN}mpop{C.R} is ready to use"
        if _path_result == "fixed" else
        f"  {C.DIM}  PATH: already configured{C.R}"
        if _path_result == "already_ok" else
        f"  {C.YELLOW}  PATH: manual setup required -> export PATH=\"$(python3 -m site --user-scripts):$PATH\"{C.R}"
    )

    print(f"""
  {C.GREEN}✓ Config created:{C.R} {config_file}

  {C.BOLD}Next steps:{C.R}
  1. Edit the config file:
     {C.CYAN}nano {config_file}{C.R}

  2. Add your servers:
     - ip: VPN/internal IP
     - public_ip: Public IP (if any)
     - user: SSH username
     - ssh_port: SSH port
     - role: Description

{_vault_note}

{_path_note}

  4. Setup server-agent on your VPS (enables dashboard, trends, monitoring):
     {C.CYAN}mpop config set dashboard_url http://<your-vps-ip>:8721{C.R}
     {C.CYAN}mpop setup agent <server-name> -x{C.R}
     Or all servers at once: {C.CYAN}mpop setup agent all -x{C.R}

  5. Setup local AI (free, recommended):
     Install: https://ollama.com
     Then: {C.CYAN}ollama pull qwen2.5:7b && ollama serve{C.R}
     Or cloud: {C.CYAN}mpop config set ai.engine openai{C.R}

  6. Set VSSH secret (if using vssh):
     {C.CYAN}export VSSH_SECRET="your-secret-here"{C.R}

  7. Check everything:
     {C.CYAN}mpop doctor{C.R}

  8. Test connection:
     {C.CYAN}mpop servers{C.R}

  {C.DIM}Docs: mpop help | mpop doctor{C.R}
""")



def cmd_doctor(args):
    """Check mpop prerequisites and configuration health"""
    import urllib.request as _ur
    import shutil as _sh

    print(f"\n  {C.BOLD}mpop doctor{C.R} — system check\n")
    ok_count = 0
    warn_count = 0

    def _ok(msg):
        nonlocal ok_count
        ok_count += 1
        print(f"  {C.GREEN}✓{C.R} {msg}")

    def _warn(msg, hint=""):
        nonlocal warn_count
        warn_count += 1
        print(f"  {C.YELLOW}⚠{C.R}  {msg}")
        if hint:
            print(f"     {C.DIM}→ {hint}{C.R}")

    def _fail(msg, hint=""):
        nonlocal warn_count
        warn_count += 1
        print(f"  {C.RED}✗{C.R}  {msg}")
        if hint:
            print(f"     {C.DIM}→ {hint}{C.R}")

    # 1. Config
    cfg_path = os.path.expanduser("~/.mpop/config.json")
    if os.path.exists(cfg_path):
        _ok(f"Config: {cfg_path}")
    else:
        _fail("Config not found", "Run: mpop init")

    # 2. Servers
    servers = _get_config("servers", {})
    if servers:
        _ok(f"{len(servers)} server(s) configured")
    else:
        _warn("No servers configured", "Edit ~/.mpop/config.json")

    # 3. SSH
    if _sh.which("ssh"):
        _ok("ssh available")
    else:
        _fail("ssh not found", "Install OpenSSH")

    # 4. Ollama
    ollama_host = _get_ollama_host()
    try:
        import json as _json
        resp = _ur.urlopen(f"http://{ollama_host}/api/tags", timeout=3)
        tags = _json.loads(resp.read())
        models = [m["name"] for m in tags.get("models", [])]
        _ok(f"Ollama running at {ollama_host}")
        if models:
            _ok(f"Models available: {', '.join(models[:4])}")
        else:
            _warn("Ollama running but no models pulled",
                  "Run: ollama pull qwen2.5:7b")
    except Exception:
        engine = _get_config("ai", {}).get("engine", "ollama")
        if engine == "ollama":
            _fail(f"Ollama not reachable at {ollama_host}",
                  "Install: https://ollama.com  then: ollama pull qwen2.5:7b && ollama serve")
        else:
            _ok(f"AI engine: {engine} (Ollama not needed)")

    # 5. API keys for cloud engines
    engine = _get_config("ai", {}).get("engine", "ollama")
    key_map = {
        "openai": ("OPENAI_API_KEY", "export OPENAI_API_KEY=sk-..."),
        "claude": ("ANTHROPIC_API_KEY", "export ANTHROPIC_API_KEY=sk-ant-..."),
        "gemini": ("GEMINI_API_KEY", "export GEMINI_API_KEY=..."),
    }
    if engine in key_map:
        envvar, hint = key_map[engine]
        if os.environ.get(envvar):
            _ok(f"{envvar} set (engine: {engine})")
        else:
            _fail(f"{envvar} not set", hint)

    # 6. Optional tools
    for tool in ["wire", "vssh"]:
        if _sh.which(tool):
            _ok(f"{tool} available")

    # 7. Python version
    pv = sys.version_info
    if pv >= (3, 8):
        _ok(f"Python {pv.major}.{pv.minor}.{pv.micro}")
    else:
        _fail(f"Python {pv.major}.{pv.minor} too old", "Requires Python 3.8+")

    # 8. Server-agent
    dashboard_url = _get_config("dashboard_url", "")
    if not dashboard_url:
        _warn("server-agent not configured",
              "Run: mpop config set dashboard_url http://<vps-ip>:8721  then: mpop setup agent <server> -x")
    else:
        try:
            resp_sa = _ur.urlopen(f"{dashboard_url.rstrip('/')}/api/status", timeout=3)
            _ok(f"server-agent reachable at {dashboard_url}")
        except Exception:
            _warn(f"server-agent configured but not reachable at {dashboard_url}",
                  "Run: mpop setup agent <server> -x")

    # Summary
    print()
    if warn_count == 0:
        print(f"  {C.GREEN}{C.BOLD}All checks passed!{C.R}  mpop is ready.")
    else:
        print(f"  {C.GREEN}✓ {ok_count} passed{C.R}  {C.YELLOW}⚠ {warn_count} issue(s) found{C.R}")
    print()


def _join_run(server_name: str, cmd: str, use_sudo: bool = False,
              ip: str = "", user: str = "root", port: int = 22,
              password: str = "") -> tuple[bool, str]:
    """Run a command on the joining server.

    Priority:
    1. ssh_exec/ssh_sudo (server already in config)
    2. Plain SSH to ip:port as fallback (server not yet in config)
    Returns (ok, output).
    """
    if server_name in _get_config("servers", {}):
        if use_sudo:
            out = ssh_sudo(server_name, cmd)
        else:
            out = ssh_exec(server_name, cmd)
        return (out is not None), (out or "")

    # Not in config yet — use plain SSH
    if not ip:
        return False, "IP not specified and server not in config"

    ssh_base = ["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no",
                "-o", "BatchMode=yes", "-p", str(port)]

    if use_sudo and password:
        full_cmd = f"echo '{password}' | sudo -S {cmd}"
    elif use_sudo:
        full_cmd = f"sudo {cmd}"
    else:
        full_cmd = cmd

    try:
        result = subprocess.run(
            ssh_base + [f"{user}@{ip}", full_cmd],
            capture_output=True, text=True, timeout=30
        )
        ok = result.returncode == 0
        return ok, (result.stdout.strip() + result.stderr.strip()).strip()
    except Exception as e:
        return False, str(e)


def _join_get_sudo_password(server_name: str) -> str:
    """Get sudo password for server from vault/secret, or prompt."""
    # Try mpop secret '<server>_sudo'
    try:
        _secrets = _load_secrets()
        pwd = _secrets.get(f"{server_name}_sudo", "")
        if pwd:
            return pwd
    except Exception:
        pass
    # Interactive fallback
    try:
        import getpass
        pwd = getpass.getpass(
            f"  sudo password [{server_name}] (press Enter if passwordless): "
        )
        return pwd
    except (KeyboardInterrupt, EOFError):
        return ""


def cmd_join(args):
    """Add a new server to the MeshPOP mesh.

    Usage:
      mpop join <server>                    # server already in config
      mpop join <server> --ip <ip>          # specify IP (adds to config)
      mpop join <server> --ip <ip> --user root --port 22
      mpop join <server> --dry-run          # show steps without executing

    What this does:
      #4  Open firewall ports (WireGuard 51820/udp, vssh 48291/tcp, wire 8786/tcp)
      #5  Exchange WireGuard public keys + SSH authorised keys with the mesh
      #6  Add VPN subnet route (10.99.0.0/16 via Wire interface)
      #7  Sudo handled via vault secret '<server>_sudo' or interactive prompt
      #10 Sync VSSH_SECRET so new node can join the vssh mesh immediately
    """
    if not args or args[0] in ("-h", "--help"):
        print(f"""
  {C.BOLD}mpop join{C.R} — Add a server to the MeshPOP mesh

  {C.BOLD}Usage:{C.R}
    mpop join <server>
    mpop join <server> --ip <ip> [--user root] [--port 22]
    mpop join <server> --dry-run

  {C.BOLD}Steps performed:{C.R}
    1. Verify SSH connectivity
    2. {C.CYAN}#4{C.R}  Open firewall ports  (51820/udp, 48291/tcp, 8790/tcp)
    3. {C.CYAN}#5{C.R}  Exchange peer keys   (WireGuard pubkey + SSH authorized_keys)
    4. {C.CYAN}#6{C.R}  Add subnet route     (10.99.0.0/16 via Wire interface)
    5. {C.CYAN}#10{C.R} Sync vssh secret     (VSSH_SECRET → /etc/environment)
    6.      Register server   (add to ~/.mpop/config.json if not present)

  {C.BOLD}Sudo:{C.R}
    Uses vault secret '{C.CYAN}<server>_sudo{C.R}' automatically.
    If not stored: prompts interactively, then offers to save.
    Store with: {C.CYAN}mpop secret set <server>_sudo{C.R}
""")
        return

    # ── Parse args ────────────────────────────────────────────────────
    server_name = args[0]
    ip = ""
    user = "root"
    port = 22
    dry_run = False

    i = 1
    while i < len(args):
        a = args[i]
        if a == "--ip" and i + 1 < len(args):
            ip = args[i + 1]; i += 2
        elif a == "--user" and i + 1 < len(args):
            user = args[i + 1]; i += 2
        elif a == "--port" and i + 1 < len(args):
            port = int(args[i + 1]); i += 2
        elif a in ("--dry-run", "-n"):
            dry_run = True; i += 1
        else:
            i += 1

    # Fill from config if available
    servers = _get_config("servers", {})
    srv_cfg = servers.get(server_name, {})
    if not ip:
        ip = srv_cfg.get("public_ip") or srv_cfg.get("ip", "")
    if not user or user == "root":
        user = srv_cfg.get("user", "root")
    if port == 22:
        port = srv_cfg.get("ssh_port", 22)

    if not ip:
        print(f"  {C.RED}Error:{C.R} Cannot determine IP. Use --ip or add the server to config.")
        return

    vpn_subnet   = _get_config("vpn_subnet", "10.99.0.0/16")
    vssh_secret  = _get_vssh_secret()
    vssh_port    = 48291
    wg_port      = 51820
    wire_api_port = int(_get_config("api_port", WIRE_API_PORT))

    print(f"""
  {C.BOLD}mpop join{C.R}: {C.CYAN}{server_name}{C.R} @ {ip}:{port}  user={user}
  {"  "+C.YELLOW+"[DRY-RUN]"+C.R if dry_run else ""}""")

    def step(num, label, ok=None):
        if ok is None:
            print(f"  {C.DIM}[{num}]{C.R} {label}…", flush=True)
        elif ok:
            print(f"  {C.GREEN}✓ [{num}]{C.R} {label}")
        else:
            print(f"  {C.RED}✗ [{num}]{C.R} {label}")

    def run(cmd, sudo=False, desc=""):
        if dry_run:
            print(f"      {C.DIM}$ {'sudo ' if sudo else ''}{cmd}{C.R}")
            return True, "(dry-run)"
        ok, out = _join_run(server_name, cmd, use_sudo=sudo,
                            ip=ip, user=user, port=port,
                            password=sudo_password if sudo else "")
        return ok, out

    # ── Step 0: Connectivity check ─────────────────────────────────────
    step(0, "Checking SSH connection")
    ok, out = run("echo MPOP_OK")
    if not ok and not dry_run:
        print(f"  {C.RED}✗{C.R} SSH connection failed: {ip}:{port} (user={user})")
        print(f"  {C.DIM}  Make sure SSH key is registered: ssh-copy-id {user}@{ip}{C.R}")
        return
    step(0, f"SSH connected -- {ip}:{port}", ok or dry_run)

    # ── #7 Sudo password ────────────────────────────────────────────────
    sudo_password = ""
    if not dry_run:
        sudo_password = _join_get_sudo_password(server_name)

    # ── #4 Firewall: open required ports ────────────────────────────────
    step(4, "Opening firewall ports")
    # Detect firewall tool
    ok_ufw, _ = run("command -v ufw")
    has_ufw = ok_ufw

    if has_ufw:
        fw_cmds = [
            f"ufw allow {wg_port}/udp comment 'MeshPOP WireGuard'",
            f"ufw allow {vssh_port}/tcp comment 'MeshPOP vssh'",
            f"ufw allow {wire_api_port}/tcp comment 'MeshPOP wire API'",
        ]
    else:
        fw_cmds = [
            f"iptables -I INPUT -p udp --dport {wg_port} -j ACCEPT -m comment --comment 'MeshPOP WireGuard'",
            f"iptables -I INPUT -p tcp --dport {vssh_port} -j ACCEPT -m comment --comment 'MeshPOP vssh'",
            f"iptables -I INPUT -p tcp --dport {wire_api_port} -j ACCEPT -m comment --comment 'MeshPOP wire'",
        ]

    fw_ok = True
    for fw_cmd in fw_cmds:
        ok, out = run(fw_cmd, sudo=True)
        if not ok and not dry_run:
            fw_ok = False
            print(f"      {C.YELLOW}warn:{C.R} {out[:80]}")
    step(4, f"Opening firewall ports ({wg_port}/udp {vssh_port}/tcp {wire_api_port}/tcp)", fw_ok or dry_run)

    # ── #5 Peer exchange: WireGuard public key ──────────────────────────
    step(5, "WireGuard key exchange")
    # Check if wg is available on new server
    ok_wg, _ = run("command -v wg")
    wg_pubkey = ""
    if ok_wg or dry_run:
        # Generate keypair if not already present
        run("mkdir -p /etc/wireguard && chmod 700 /etc/wireguard")
        ok_key, _ = run("test -f /etc/wireguard/privatekey")
        if not ok_key:
            run("wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey",
                sudo=True)
        else:
            run("wg pubkey < /etc/wireguard/privatekey > /etc/wireguard/publickey 2>/dev/null || true",
                sudo=True)

        ok_pub, wg_pubkey = run("cat /etc/wireguard/publickey")
        if ok_pub and wg_pubkey and not dry_run:
            print(f"      WireGuard public key: {C.CYAN}{wg_pubkey[:44]}{C.R}")
            # Register with Wire coordinator if available
            coord_url = _get_config("wire_coordinator_url", "")
            if coord_url:
                try:
                    import urllib.request, json as _json
                    payload = _json.dumps({
                        "name": server_name, "ip": ip,
                        "public_key": wg_pubkey, "port": wg_port
                    }).encode()
                    req = urllib.request.Request(
                        f"{coord_url}/api/peers/register",
                        data=payload,
                        headers={"Content-Type": "application/json"},
                        method="POST"
                    )
                    urllib.request.urlopen(req, timeout=5)
                    print(f"      {C.GREEN}Wire coordinator registration complete{C.R}")
                except Exception as _e:
                    print(f"      {C.YELLOW}Wire coordinator registration skipped: {_e}{C.R}")
    else:
        print(f"      {C.YELLOW}wg not found -- skipping WireGuard key exchange{C.R}")
        print(f"      Install: sudo apt-get install -y wireguard-tools")

    # SSH authorized_keys: copy this machine's pubkey to new server
    my_pubkey = ""
    ssh_pub_paths = [
        os.path.expanduser("~/.ssh/id_ed25519.pub"),
        os.path.expanduser("~/.ssh/id_rsa.pub"),
        os.path.expanduser("~/.ssh/id_ecdsa.pub"),
    ]
    for p in ssh_pub_paths:
        if os.path.isfile(p):
            with open(p) as f:
                my_pubkey = f.read().strip()
            break
    if my_pubkey and not dry_run:
        ak_cmd = (
            f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
            f"grep -qxF '{my_pubkey}' ~/.ssh/authorized_keys 2>/dev/null || "
            f"echo '{my_pubkey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
        )
        run(ak_cmd)
        print(f"      SSH public key registered")
    step(5, "WireGuard key exchange + SSH authorized_keys", True)

    # ── #6 Subnet routing ───────────────────────────────────────────────
    step(6, f"Adding VPN subnet route ({vpn_subnet})")
    # Find WireGuard interface
    ok_iface, wg_iface = run(
        "ip -o link show | awk -F': ' '{print $2}' | grep -E '^(wg|wire|awlite)[0-9]*$' | head -1"
    )
    if not wg_iface:
        wg_iface = "wg0"  # fallback default

    if wg_iface or dry_run:
        iface = wg_iface.strip() or "wg0"
        # Add route: ip route add <subnet> dev <wg_iface> — idempotent
        route_cmd = (
            f"ip route show {vpn_subnet} | grep -q {vpn_subnet} || "
            f"ip route add {vpn_subnet} dev {iface}"
        )
        ok_route, out_route = run(route_cmd, sudo=True)
        if ok_route or dry_run:
            step(6, f"VPN subnet route ({vpn_subnet} dev {iface})", True)
        else:
            step(6, f"VPN subnet route (warning: {out_route[:60]})", False)
            print(f"      {C.DIM}Manually: sudo ip route add {vpn_subnet} dev {iface}{C.R}")
    else:
        step(6, f"VPN subnet route skipped (no WireGuard interface)", False)
        print(f"      {C.DIM}After Wire starts: sudo ip route add {vpn_subnet} dev wg0{C.R}")

    # ── #10 vssh secret sync ────────────────────────────────────────────
    step(10, "Syncing vssh secret")
    if not vssh_secret:
        print(f"      {C.YELLOW}VSSH_SECRET not set -- configure vssh_secret in config{C.R}")
        step(10, "vssh secret sync skipped", False)
    else:
        # Write to /etc/environment (survives reboots, available to all services)
        env_cmd = (
            f"grep -q 'VSSH_SECRET=' /etc/environment 2>/dev/null && "
            f"sed -i 's/^VSSH_SECRET=.*/VSSH_SECRET={vssh_secret}/' /etc/environment || "
            f"echo 'VSSH_SECRET={vssh_secret}' >> /etc/environment"
        )
        ok_env, _ = run(env_cmd, sudo=True)

        # Also ensure vssh systemd service picks it up (if installed)
        svc_env_dir = "/etc/systemd/system/vssh.service.d"
        svc_env_cmd = (
            f"mkdir -p {svc_env_dir} && "
            f"echo '[Service]\nEnvironment=VSSH_SECRET={vssh_secret}' "
            f"> {svc_env_dir}/vssh_secret.conf && "
            f"systemctl daemon-reload 2>/dev/null && "
            f"systemctl restart vssh 2>/dev/null || true"
        )
        run(svc_env_cmd, sudo=True)

        step(10, "Syncing vssh secret (/etc/environment + systemd)", ok_env or dry_run)

    # ── Register server in config ────────────────────────────────────────
    if server_name not in servers and not dry_run:
        step("cfg", "Adding server to ~/.mpop/config.json")
        config = _load_config()
        if "servers" not in config:
            config["servers"] = {}
        config["servers"][server_name] = {
            "ip": ip,
            "user": user,
            "ssh_port": port,
            "role": "Joined via mpop join",
            "public": False,
        }
        config_path = os.path.expanduser("~/.mpop/config.json")
        with open(config_path, "w") as f:
            import json as _json
            _json.dump(config, f, indent=2)
        step("cfg", f"Server added: {server_name} → {ip}", True)

    # ── #7 Offer to save sudo password ──────────────────────────────────
    if sudo_password and not dry_run:
        try:
            secret_key = f"{server_name}_sudo"
            existing = None
            try:
                existing = _load_secrets().get(secret_key, "")
            except Exception:
                pass
            if not existing:
                print()
                ans = input(f"  Save sudo password to mpop secret? [{secret_key}] (y/N): ").strip().lower()
                if ans == "y":
                    _secs = _load_secrets()
                    _secs[secret_key] = sudo_password
                    _save_secrets(_secs)
                    print(f"  {C.GREEN}✓{C.R} Saved to mpop secret: {secret_key}")
        except Exception:
            pass

    # ── Final verification ───────────────────────────────────────────────
    print()
    if not dry_run:
        ok_ping, ping_out = run("echo MESH_JOINED")
        if ok_ping:
            print(f"  {C.GREEN}✓ {server_name} mesh join complete!{C.R}")
        else:
            print(f"  {C.YELLOW}⚠  Basic setup done, please re-verify SSH connection{C.R}")
    else:
        print(f"  {C.YELLOW}[DRY-RUN]{C.R} The above commands will run. Re-run without --dry-run.")

    print(f"""
  {C.DIM}Next steps:{C.R}
    mpop servers          # # check server list
    mpop info {server_name}      # # check new server status
    vssh status           # # verify vssh connection{C.R}
""")

def cmd_help(args):
    """Show help"""
    print(f"""
  mpop - MeshPOP Network Operations CLI v{VERSION}

  COMMANDS:
    mpop                Main dashboard (API mode - instant)
    mpop dashboard      Same as above (alias)
    mpop --live         Main dashboard (vssh scan - real-time)
    mpop full           Full report (9 tables + AI summary)
    mpop watch          Live monitoring (auto-refresh)
    mpop watch <sec>    Custom interval (e.g., mpop watch 3)
    mpop watch --log    Save to log file
    mpop watch --alert  Enable anomaly detection
    mpop matrix         Connectivity matrix (server-to-server)
    mpop services       Services running on each server
    mpop security       Quick security overview
    mpop temp           CPU/GPU temperature all servers
    mpop gpu            GPU status & utilization
    mpop restart <svc>  Restart service on all servers
    mpop restart <svc> -t <node>  Restart on specific server
    mpop audit          Deep security audit with attack logs
    mpop audit <node>   Audit specific server
    mpop info <node>    Detailed info for a server
    mpop exec <cmd>     Execute command on all servers
    mpop exec -t <node> <cmd>  Execute on specific server
    mpop servers        List all servers
    mpop report         AI status report (GPT-4o-mini)
    mpop report --gpt4  Better quality (GPT-4o)
    mpop roles          Analyze server roles
    mpop roles v1       Analyze specific server
    mpop enforce         Cold automated enforcement
    mpop enforce -x      Execute all fixes (careful!)
    mpop radiomcp       Launch/manage Radio MCP server (internet radio)

  SERVICE LAYER:
    mpop service            List all registered services
    mpop service add <name> Register new service (interactive)
    mpop service status <n> Check service status on all nodes
    mpop service start <n>  Start service on all nodes
    mpop service stop <n>   Stop service on all nodes
    mpop service restart <n> Restart service
    mpop service logs <n>   View service logs from all nodes

  AUTO-HEAL & ANALYSIS:
    mpop heal           Detect problems & show fixes (dry-run)
    mpop heal -x        Execute fixes (with confirmation)
    mpop heal <node>    Check specific server
    mpop predict        Predict future issues (disk full, etc.)
    mpop predict <node> Predict for specific server
    mpop diff <storage1> <storage2> Compare two servers (OS, packages, services)
    mpop map            Service topology map (ASCII)
    mpop export         Generate HTML report
    mpop export <file>  Save to specific file

  LOGS & ALERTS:
    mpop logs <node>    Real-time log streaming
    mpop logs v1 auth   View auth/SSH logs
    mpop logs node2 nginx  View nginx logs
    mpop logai              AI-powered log analysis (all servers)
    mpop logai <node>       Analyze specific server logs
    mpop logai --errors     Focus on errors only
    mpop logai --fix        LLM suggests fixes
    mpop alert          Configure Telegram alerts
    mpop alert check    Check for alerts now
    mpop alert test     Send test notification
    mpop history        Command history
    mpop retry              Show recent failed commands
    mpop retry last         Retry last failed command
    mpop retry <n>          Retry nth failure

  BACKUP & DEPLOY:
    mpop backup create  Backup current node config
    mpop backup create-all  Backup all nodes
    mpop backup list    List available backups
    mpop backup restore Restore from backup
    mpop deploy <file> <nodes>  Deploy file to nodes
    mpop deploy mpop    Update mpop on all servers
    mpop self-update    Update local mpop from v1
    mpop setup <srv> -x       Full setup (mpop + vssh + agent)
    mpop setup all -x         Full setup on all servers
    mpop setup vssh <srv> -x  Install vssh daemon only
    mpop setup agent <srv> -x Install server-agent only
    mpop benchmark      Server performance benchmark

  TREND ANALYSIS (from server-agent on relay1):
    mpop trend          24h trends (mem, disk, uptime)
    mpop trend 48       48h trends
    mpop trend v2       Trends for specific server
    mpop ai             AI trend report with predictions
    mpop ai 48          48h AI trend report
    mpop agent          Server-agent status on v1

    mpop help           Show this help

  INDICATORS:
    Load:   !! > 5.0 (critical), * > 2.0 (warning), normal otherwise
    Disk:   !! > 90% (critical), * > 70% (warning), normal otherwise
    Conn:   ssh:+/- (SSH port), v:+/- (vssh port 48291)

  SECURITY AUDIT:
    - Attack logs (SSH brute force, invalid users)
    - Top attacker IPs (last 24h)
    - fail2ban status and blocked IPs
    - Open port risk analysis
    - Firewall status
    - Recent logins
    - AI-powered recommendations

  SECRET MANAGEMENT (Level 3 + Level 4):
    mpop secret               Show secret help & status
    mpop secret list          List all stored secrets
    mpop secret get <key>     Get secret (MASKED by default)
    mpop secret get <key> --raw --force   For scripts (no prompt)
    mpop secret get <key> --clip          Copy to clipboard (30s auto-clear)
    mpop secret set <key>     Set secret (interactive)
    mpop secret inject <key>  Export as env var (for eval)
    mpop secret inject --keys k1,k2   Multiple keys
    mpop secret history <key> Show version history
    mpop secret history --all All keys history summary
    mpop secret register      Register this machine
    mpop secret machines      List registered machines
    mpop secret export        Export backup with password
    mpop secret import <str>  Import from backup

  LEVEL 4 SECURITY:
    mpop secret level4 setup '<password>'   Enable master password
    mpop secret unlock '<password>'         Unlock session (5 min)
    mpop secret lock                        Lock session
    mpop secret audit                       View audit log
    mpop secret level4 disable              Disable Level 4

  BROWSER IMPORT:
    mpop secret import-csv <file>           Import from Chrome/Safari CSV
    mpop secret import-csv <file> --prefix web-   Add prefix to keys
    mpop secret import-csv <file> --dry-run Preview import

  NETWORKING:
    mpop vpn                  VPN status (wire + Tailscale)
    mpop network              SSH connection info (IP, port, password hints)
    mpop peers                VPN peer list (auto-detect)
    mpop peers --refresh      Force refresh
    mpop dns                  MagicDNS status (*.mesh)
    mpop acl                  Access control lists
    mpop tunnel                Expose services externally (Tailscale Funnel)

  🤖 AI AGENT (LangChain/n8n/OpenInterpreter replacement):
    mpop ask "query"          Natural language server management
    mpop ask "command" -x     Execute generated command
    mpop workflow list        List workflows
    mpop workflow run <name>  Execute workflow
    mpop chain analyze        Multi-step AI analysis
    mpop chain optimize       Optimization suggestions
    mpop chain "query"        Natural language chain

  🤖 AGENT MANAGEMENT (AI-driven with auto-validation):
    mpop agent                    List all agents
    mpop agent make <n> -d -b     Create agent (AI generates + validates)
    mpop agent show <name>        Show agent details and code
    mpop agent run <name>         Run agent locally
    mpop agent deploy <n> <srv>   Deploy to servers via SCP
    mpop agent delete <name>      Delete agent

    mpop agent workflow           List saved workflows
    mpop agent workflow create <n> <a1> <a2>...   Create workflow
    mpop agent workflow run <n>   Run workflow (sequential)
    mpop agent workflow delete <n>  Delete workflow

    mpop agent chain              List saved chains
    mpop agent chain <a1> <a2>    Run chain directly (output -> input)
    mpop agent chain save <n> <a1> <a2>...  Save chain
    mpop agent chain run <n>      Run saved chain
    mpop agent chain delete <n>   Delete chain

  🧬 PROJECT MEMORY (Eliminate AI hallucination):
    mpop memory               Memory help
    mpop memory scan <path>   Scan project → generate memory
    mpop memory list          List stored memories
    mpop memory status <proj> Check memory status
    mpop memory ask <proj> "query"   2-Agent query (hallucination-free)
    mpop memory delete <proj> Delete memory

    2-Agent System:
      Historian: Past/review - answers only from extracted facts
      Advisor:   Future/design - refactoring, improvement suggestions

    MCP Tools (Claude Desktop, 50 tools):
      mpop_dashboard, mpop_servers, mpop_info, mpop_exec
      mpop_security, mpop_audit, mpop_heal, mpop_predict
      mpop_voice, mpop_tts, mpop_news
      mpop_memory, mpop_rag, mpop_brain, mpop_ask
      mpop_write_file, mpop_read_file, mpop_scp, mpop_python
      mpop_agent_list, mpop_agent_make, mpop_agent_deploy, mpop_agent_run

  🧠 AI-OS (Shared Brain):
    mpop brain                Shared AI context status
    mpop brain init           Initialize brain on v1
    mpop brain constitution   Show AI constitution
    mpop project              List all projects
    mpop project new <name>   Create new project
    mpop project <name>       Show project status
    mpop project <name> --log "msg"   Add log entry
    mpop view <project>       Project dashboard (timeline/docs/direction)
    mpop scan                 Scan all servers for projects → brain
    mpop session              Start/show work sessions
    mpop session start "desc" Start new session
    mpop session end          End current session

  📄 DOCUMENTS:
    mpop doc                  All projects with doc counts
    mpop doc <project>        List files in project
    mpop doc <proj>/<file>    View file content
    mpop doc --recent         Recent documents (7 days)
    mpop doc <proj> --grep <term>   Search in project

  ✍️ AI WRITER:
    mpop write                AI writing assistant (interactive)
    mpop write "question"          Single question mode
    mpop write --project X    Project context mode
    mpop write --file F "Q"   Include file in context

  📻 VOICE & NEWS:
    mpop voice                Server status briefing
    mpop voice --en           English briefing
    mpop voice <node>         Specific server report
    mpop voice me             Personal Mac report
    mpop news                 RSS news briefing (tech)
    mpop news korea --ko      Korean news in Korean
    mpop news world           World news
    mpop news dev             Developer news

  🔧 SHELL COMPLETION:
    mpop completion           Show zsh completion script
    mpop completion zsh       Show zsh script
    mpop completion bash      Show bash script
    mpop completion zsh --install   Install zsh completion

  ⚙️ CONFIGURATION:
    mpop config               Show current configuration
    mpop config test          Test SSH connectivity (latency, status)
    mpop config auto          Auto-detect best VPN for each server
    mpop config edit          Edit config in $EDITOR
    mpop config get <key>     Get config value (dot notation)
    mpop config set <key> <v> Set config value
    mpop config del <key>     Delete config key
    mpop init                 Initialize mpop (create ~/.mpop/config.json)

  EXAMPLES:
    mpop                       # Full dashboard
    mpop matrix                # Check connectivity between all nodes
    mpop audit                 # Security audit all servers
    mpop audit node1           # Audit only node1
    mpop info node2               # Detailed info for node2
    mpop exec -t node1 "uptime"  # Run uptime on node1
    mpop secret unlock 'MyPass123!'   # Use single quotes for special chars
""")

def cmd_peers(args):
    """Show discovered VPN peers"""
    force = "--refresh" in args or "-r" in args
    verbose = "--verbose" in args or "-v" in args

    if force:
        print("  🔄 Refreshing peer list...\n")
        discover_peers(force=True)
    else:
        discover_peers()

    vpn_type = detect_vpn_type()
    my_ip = get_vpn_ip()
    my_name = get_my_name()

    print(f"  📡 VPN PEERS ({vpn_type.upper()})")
    print("  " + "=" * 50)
    print(f"  VPN Type: {vpn_type}")
    print(f"  My IP:    {my_ip}")
    print(f"  My Name:  {my_name}")
    print()

    print(f"  {'NAME':<10} {'VPN IP':<18} {'STATUS'}")
    print("  " + "-" * 50)

    servers = get_servers()
    for name, ip in sorted(servers.items()):
        is_me = (ip == my_ip)
        marker = " ← (me)" if is_me else ""

        # Quick ping check
        status = "✅" if ping_server(ip, timeout=1) else "❌"

        print(f"  {name:<10} {ip:<18} {status}{marker}")

    print()
    print(f"  Total: {len(servers)} peers")
    print()
    print("  Options:")
    print("    mpop peers --refresh    Force refresh peer list")
    print("    mpop peers --verbose    Show more details")

# ==================== MagicDNS Command ====================

DNS_ZONE = "mesh"  # Default zone: node1.mesh, relay1.mesh, etc.
DNS_MARKER_START = "# >>> MPOP MAGIC DNS - DO NOT EDIT BELOW <<<"
DNS_MARKER_END = "# <<< MPOP MAGIC DNS - DO NOT EDIT ABOVE >>>"

def cmd_dns(args):
    """MagicDNS - automatic hostname resolution for VPN peers"""

    if not args or args[0] == "status":
        _dns_status()
    elif args[0] == "install":
        zone = args[1] if len(args) > 1 else DNS_ZONE
        _dns_install(zone)
    elif args[0] == "update":
        quiet = "--quiet" in args or "-q" in args
        _dns_update(quiet=quiet)
    elif args[0] == "remove":
        _dns_remove()
    elif args[0] == "deploy":
        _dns_deploy()
    elif args[0] == "watch":
        interval = 60  # default 60 seconds
        for arg in args:
            if arg.startswith("--interval="):
                interval = int(arg.split("=")[1])
        _dns_watch(interval)
    elif args[0] == "service":
        _dns_service_setup()
    else:
        print(f"""
  🌐 MAGIC DNS - Automatic VPN hostname resolution

  USAGE:
    mpop dns                Show current DNS status
    mpop dns install [zone] Install MagicDNS (default zone: {DNS_ZONE})
    mpop dns update         Update /etc/hosts with current peers
    mpop dns remove         Remove MagicDNS entries
    mpop dns deploy         Install on all VPN nodes

  AUTO-UPDATE:
    mpop dns watch          Watch for peer changes (foreground)
    mpop dns watch --interval=30   Check every 30 seconds
    mpop dns service        Install systemd service (auto-start)

  EXAMPLES:
    mpop dns install        # Creates node1.mesh, relay1.mesh, etc.
    mpop dns install vpn    # Creates node1.vpn, relay1.vpn, etc.
    sudo mpop dns service   # Auto-update daemon

  After install, you can use:
    ssh node1.mesh
    curl http://v1.mesh:8721
    ping worker1.mesh
""")

def _dns_status():
    """Show current MagicDNS status"""
    print("  🌐 MAGIC DNS STATUS")
    print("  " + "=" * 50)

    # Check /etc/hosts
    try:
        with open('/etc/hosts', 'r') as f:
            content = f.read()

        if DNS_MARKER_START in content:
            # Extract our section
            start = content.find(DNS_MARKER_START)
            end = content.find(DNS_MARKER_END)
            if start != -1 and end != -1:
                section = content[start:end + len(DNS_MARKER_END)]
                lines = [l for l in section.split('\n') if l.strip() and not l.startswith('#')]

                print(f"  Status: ✅ Installed")
                print(f"  Entries: {len(lines)}")
                print()
                print("  Current mappings:")
                for line in lines[:10]:
                    parts = line.split()
                    if len(parts) >= 2:
                        print(f"    {parts[1]:<20} → {parts[0]}")
                if len(lines) > 10:
                    print(f"    ... and {len(lines) - 10} more")
        else:
            print("  Status: ❌ Not installed")
            print()
            print("  Run: mpop dns install")
    except Exception as e:
        print(f"  Error reading /etc/hosts: {e}")

    print()

    # Show available peers
    servers = get_servers()
    print(f"  Available peers: {len(servers)}")
    for name, ip in sorted(servers.items())[:5]:
        print(f"    {name}.{DNS_ZONE} → {ip}")
    if len(servers) > 5:
        print(f"    ... and {len(servers) - 5} more")

def _dns_install(zone=DNS_ZONE, quiet=False):
    """Install MagicDNS entries to /etc/hosts"""
    if not quiet:
        print(f"  🌐 Installing MagicDNS (zone: .{zone})")
        print()

    # Get current peers
    servers = get_servers()
    if not servers:
        if not quiet:
            print("  ❌ No peers found. Check VPN connection.")
        return False

    # Build entries
    entries = []
    for name, ip in sorted(servers.items()):
        # Clean name (remove special chars)
        clean_name = ''.join(c for c in name if c.isalnum() or c in '-_').lower()
        if clean_name:
            entries.append(f"{ip}\t{clean_name}.{zone}")

    # Read current /etc/hosts
    try:
        with open('/etc/hosts', 'r') as f:
            content = f.read()
    except Exception as e:
        if not quiet:
            print(f"  ❌ Cannot read /etc/hosts: {e}")
        return False

    # Remove old entries if exist
    if DNS_MARKER_START in content:
        start = content.find(DNS_MARKER_START)
        end = content.find(DNS_MARKER_END)
        if end != -1:
            end += len(DNS_MARKER_END) + 1
            content = content[:start] + content[end:]

    # Add new entries
    new_section = f"""
{DNS_MARKER_START}
# Zone: .{zone}
# Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
# Peers: {len(entries)}
{chr(10).join(entries)}
{DNS_MARKER_END}
"""

    content = content.rstrip() + "\n" + new_section

    # Write back
    try:
        with open('/etc/hosts', 'w') as f:
            f.write(content)
        if not quiet:
            print(f"  ✅ Installed {len(entries)} entries")
            print()
            for entry in entries[:5]:
                parts = entry.split()
                print(f"    {parts[1]:<20} → {parts[0]}")
            if len(entries) > 5:
                print(f"    ... and {len(entries) - 5} more")
            print()
            print(f"  Test: ping {list(servers.keys())[0]}.{zone}")
        return True
    except PermissionError:
        print("  ❌ Permission denied. Run with sudo:")
        print(f"     sudo mpop dns install {zone}")
        return False
    except Exception as e:
        print(f"  ❌ Error writing /etc/hosts: {e}")
        return False

def _dns_update(quiet=False):
    """Update existing MagicDNS entries"""
    # Check if installed
    try:
        with open('/etc/hosts', 'r') as f:
            content = f.read()

        if DNS_MARKER_START not in content:
            if not quiet:
                print("  ❌ MagicDNS not installed. Run: mpop dns install")
            return False

        # Find current zone
        start = content.find(DNS_MARKER_START)
        end = content.find(DNS_MARKER_END)
        section = content[start:end]

        zone = DNS_ZONE
        for line in section.split('\n'):
            if line.startswith('# Zone:'):
                zone = line.split('.')[-1].strip()
                break

        if not quiet:
            print(f"  🔄 Updating MagicDNS (zone: .{zone})")
        return _dns_install(zone, quiet=quiet)
    except Exception as e:
        if not quiet:
            print(f"  ❌ Error: {e}")
        return False

def _dns_remove():
    """Remove MagicDNS entries from /etc/hosts"""
    print("  🗑️  Removing MagicDNS entries...")

    try:
        with open('/etc/hosts', 'r') as f:
            content = f.read()

        if DNS_MARKER_START not in content:
            print("  ℹ️  MagicDNS not installed. Nothing to remove.")
            return True

        # Remove our section
        start = content.find(DNS_MARKER_START)
        end = content.find(DNS_MARKER_END)
        if end != -1:
            end += len(DNS_MARKER_END) + 1
            content = content[:start] + content[end:]

        with open('/etc/hosts', 'w') as f:
            f.write(content.strip() + '\n')

        print("  ✅ MagicDNS entries removed")
        return True
    except PermissionError:
        print("  ❌ Permission denied. Run with sudo:")
        print("     sudo mpop dns remove")
        return False
    except Exception as e:
        print(f"  ❌ Error: {e}")
        return False

def _dns_deploy():
    """Deploy MagicDNS to all VPN nodes"""
    print("  🚀 Deploying MagicDNS to all nodes...")
    print()

    servers = get_servers()
    zone = DNS_ZONE

    # Build the hosts entries
    entries = []
    for name, ip in sorted(servers.items()):
        clean_name = ''.join(c for c in name if c.isalnum() or c in '-_').lower()
        if clean_name:
            entries.append(f"{ip}\t{clean_name}.{zone}")

    # Build entries string
    entries_block = "\n".join(entries)
    marker_start_escaped = DNS_MARKER_START.replace("#", r"\#")
    marker_end_escaped = DNS_MARKER_END.replace("#", r"\#")

    # Script to run on each node
    script = f"""
# Remove old entries
sed -i '/{marker_start_escaped}/,/{marker_end_escaped}/d' /etc/hosts 2>/dev/null || true

# Add new entries
cat >> /etc/hosts << 'MPOP_DNS_EOF'

{DNS_MARKER_START}
# Zone: .{zone}
# Updated: $(date '+%Y-%m-%d %H:%M:%S')
# Peers: {len(entries)}
{entries_block}
{DNS_MARKER_END}
MPOP_DNS_EOF

echo "DNS_INSTALLED:{len(entries)}"
"""

    for name, ip in servers.items():
        print(f"  {name}...", end=" ", flush=True)
        try:
            result = vssh_exec(ip, script, timeout=10)
            if result and "DNS_INSTALLED" in result:
                print("✅")
            else:
                print("⚠️ (check manually)")
        except Exception as e:
            print(f"❌ ({e})")

    print()
    print(f"  ✅ MagicDNS deployed to {len(servers)} nodes")
    print(f"  Zone: .{zone}")
    print()
    print("  Test from any node:")
    print(f"    ping relay1.{zone}")
    print(f"    ssh node1.{zone}")

def _dns_watch(interval=60):
    """Watch for VPN peer changes and auto-update DNS"""
    import signal
    import hashlib

    print(f"  👀 DNS Auto-Update Daemon (interval: {interval}s)")
    print("  " + "=" * 50)
    print("  Press Ctrl+C to stop")
    print()

    # Check if installed
    try:
        with open('/etc/hosts', 'r') as f:
            content = f.read()
        if DNS_MARKER_START not in content:
            print("  ❌ MagicDNS not installed. Run: mpop dns install")
            return
    except Exception as e:
        print(f"  ❌ Error reading /etc/hosts: {e}")
        return

    running = True
    def signal_handler(sig, frame):
        nonlocal running
        print("\n  🛑 Stopping DNS watcher...")
        running = False

    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)

    last_hash = None

    while running:
        try:
            # Get current peers
            servers = get_servers()
            if servers:
                # Create hash of peer list
                peer_str = "|".join(f"{k}:{v}" for k, v in sorted(servers.items()))
                current_hash = hashlib.md5(peer_str.encode()).hexdigest()

                if last_hash is None:
                    last_hash = current_hash
                    ts = datetime.now().strftime('%H:%M:%S')
                    print(f"  [{ts}] Initial state: {len(servers)} peers")
                elif current_hash != last_hash:
                    ts = datetime.now().strftime('%H:%M:%S')
                    print(f"  [{ts}] Peers changed! Updating DNS...")
                    if _dns_update(quiet=True):
                        print(f"  [{ts}] ✅ DNS updated ({len(servers)} peers)")
                    else:
                        print(f"  [{ts}] ⚠️ DNS update failed")
                    last_hash = current_hash

            # Sleep with interrupt check
            for _ in range(interval):
                if not running:
                    break
                time.sleep(1)

        except Exception as e:
            ts = datetime.now().strftime('%H:%M:%S')
            print(f"  [{ts}] ⚠️ Error: {e}")
            time.sleep(interval)

    print("  👋 DNS watcher stopped")

def _dns_service_setup():
    """Setup systemd service for automatic DNS updates"""
    import platform

    system = platform.system()

    if system == "Darwin":
        # macOS: use launchd
        plist_path = os.path.expanduser("~/Library/LaunchAgents/com.mpop.dns.plist")
        mpop_path = "/usr/local/bin/mpop"

        plist_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.mpop.dns</string>
    <key>ProgramArguments</key>
    <array>
        <string>sudo</string>
        <string>{mpop_path}</string>
        <string>dns</string>
        <string>update</string>
        <string>--quiet</string>
    </array>
    <key>StartInterval</key>
    <integer>300</integer>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/mpop-dns.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/mpop-dns.log</string>
</dict>
</plist>
'''
        print("  🍎 Setting up macOS LaunchAgent...")
        print()

        # Create directory if needed
        os.makedirs(os.path.dirname(plist_path), exist_ok=True)

        # Write plist
        with open(plist_path, 'w') as f:
            f.write(plist_content)

        print(f"  Created: {plist_path}")
        print()
        print("  To enable:")
        print(f"    launchctl load {plist_path}")
        print()
        print("  To disable:")
        print(f"    launchctl unload {plist_path}")
        print()
        print("  Note: Requires passwordless sudo for mpop")
        print("    sudo visudo")
        print(f"    # Add: {os.environ.get('USER', 'user')} ALL=(ALL) NOPASSWD: {mpop_path}")

    else:
        # Linux: use systemd
        service_content = '''[Unit]
Description=MagicDNS Auto-Update Service
After=network.target
Wants=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/mpop dns watch --interval=60
Restart=on-failure
RestartSec=30

[Install]
WantedBy=multi-user.target
'''
        service_path = "/etc/systemd/system/mpop-dns.service"

        print("  🐧 Setting up systemd service...")
        print()

        try:
            with open(service_path, 'w') as f:
                f.write(service_content)
            print(f"  Created: {service_path}")
            print()
            print("  To enable:")
            print("    sudo systemctl daemon-reload")
            print("    sudo systemctl enable mpop-dns")
            print("    sudo systemctl start mpop-dns")
            print()
            print("  To check status:")
            print("    sudo systemctl status mpop-dns")
            print("    journalctl -u mpop-dns -f")
        except PermissionError:
            print("  ❌ Permission denied. Run with sudo:")
            print("     sudo mpop dns service")
            print()
            print("  Or create manually:")
            print(f"     sudo tee {service_path} << 'EOF'")
            print(service_content)
            print("EOF")

# ==================== ACL Command ====================

ACL_PATH = "/var/lib/mpop/acl.json"
ACL_IPTABLES_CHAIN = "MPOP_ACL"

# Default ACL (allow all within VPN)
DEFAULT_ACL = {
    "version": 1,
    "description": "MeshPOP ACL Configuration",
    "groups": {
        "relays": ["relay1", "relay2", "relay3"],
        "gpu": [],  # configure in ~/.mpop/config.json roles.gpu
        "dev": [],  # configure in ~/.mpop/config.json roles.dev
        "laptops": [],  # configure in ~/.mpop/config.json roles.laptops,
        "all": ["*"]
    },
    "services": {
        "ssh": [22, 48291],
        "http": [80, 443, 8000, 8080, 8721],
        "database": [3306, 5432, 6379, 27017],
        "all": []
    },
    "rules": [
        {"from": "*", "to": "*", "allow": ["all"], "comment": "Allow all (default)"}
    ],
    "default": "allow"
}

def cmd_acl(args):
    """ACL - Access Control Lists for VPN nodes"""

    if not args or args[0] == "status":
        _acl_status()
    elif args[0] == "show":
        _acl_show()
    elif args[0] == "init":
        _acl_init()
    elif args[0] == "edit":
        _acl_edit()
    elif args[0] == "add":
        _acl_add_rule(args[1:])
    elif args[0] == "remove":
        _acl_remove_rule(args[1:])
    elif args[0] == "test":
        _acl_test(args[1:])
    elif args[0] == "apply":
        _acl_apply()
    elif args[0] == "reset":
        _acl_reset()
    else:
        print(f"""
  🔐 ACL - Access Control Lists

  USAGE:
    mpop acl              Show ACL status
    mpop acl show         Show full ACL config
    mpop acl init         Initialize ACL (first time)
    mpop acl edit         Edit ACL in $EDITOR
    mpop acl apply        Apply ACL to all nodes
    mpop acl reset        Reset to allow-all

  RULES:
    mpop acl add <from> <to> <service>    Add rule
    mpop acl remove <index>               Remove rule
    mpop acl test <from> <to> <port>      Test if allowed

  EXAMPLES:
    mpop acl init                    # Initialize with default config
    mpop acl add dev relays ssh      # Allow dev → relays on SSH
    mpop acl add node1 "*" http         # Allow node1 → all on HTTP
    mpop acl test node1 node2 22        # Test if node1 can reach node2
    mpop acl apply                   # Apply rules via iptables

  GROUPS (predefined):
    relays  = relay1, relay2, relay3
    gpu     = node1, worker1
    dev     = node2, node3, node4, worker2
    laptops = local
    *       = all nodes

  SERVICES (predefined):
    ssh      = 22, 48291
    http     = 80, 443, 8000, 8080, 8721
    database = 3306, 5432, 6379, 27017
    all      = any port
""")

def _acl_load():
    """Load ACL from relay1"""
    cmd = f"cat {ACL_PATH} 2>/dev/null"
    result = vssh_exec(SERVERS.get("v1", FALLBACK_SERVERS["v1"]), cmd, timeout=5)
    if result:
        try:
            return json.loads(result)
        except Exception:
            pass
    return None

def _acl_save(acl):
    """Save ACL to relay1"""
    acl_json = json.dumps(acl, indent=2)
    # Escape for shell
    acl_escaped = acl_json.replace("'", "'\"'\"'")
    cmd = f"mkdir -p /var/lib/mpop && echo '{acl_escaped}' > {ACL_PATH} && echo OK"
    result = vssh_exec(SERVERS.get("v1", FALLBACK_SERVERS["v1"]), cmd, timeout=5)
    return result and "OK" in result

def _acl_status():
    """Show ACL status"""
    print("  🔐 ACL STATUS")
    print("  " + "=" * 50)

    acl = _acl_load()
    if not acl:
        print("  Status: ❌ Not initialized")
        print()
        print("  Run: mpop acl init")
        return

    rules = acl.get("rules", [])
    groups = acl.get("groups", {})
    default = acl.get("default", "deny")

    print(f"  Status: ✅ Active")
    print(f"  Rules: {len(rules)}")
    print(f"  Groups: {len(groups)}")
    print(f"  Default: {default}")
    print()

    print("  Rules:")
    for i, rule in enumerate(rules[:5]):
        fr = rule.get("from", "*")
        to = rule.get("to", "*")
        allow = ", ".join(rule.get("allow", []))
        comment = rule.get("comment", "")
        print(f"    [{i}] {fr} → {to} : {allow}")
        if comment:
            print(f"        # {comment}")

    if len(rules) > 5:
        print(f"    ... and {len(rules) - 5} more")

    print()
    print("  Commands:")
    print("    mpop acl show    Show full config")
    print("    mpop acl edit    Edit config")
    print("    mpop acl apply   Apply to nodes")

def _acl_show():
    """Show full ACL config"""
    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized. Run: mpop acl init")
        return

    print(json.dumps(acl, indent=2))

def _acl_init():
    """Initialize ACL with default config"""
    print("  🔐 Initializing ACL...")

    existing = _acl_load()
    if existing:
        confirm = input("  ACL already exists. Overwrite? (y/N): ")
        if confirm.lower() != 'y':
            print("  Cancelled.")
            return

    if _acl_save(DEFAULT_ACL):
        print("  ✅ ACL initialized with default config")
        print()
        print("  Default: Allow all traffic within VPN")
        print()
        print("  Next steps:")
        print("    mpop acl show     # View config")
        print("    mpop acl edit     # Customize rules")
        print("    mpop acl apply    # Apply to nodes")
    else:
        print("  ❌ Failed to save ACL")

def _acl_edit():
    """Edit ACL in editor"""
    import tempfile

    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized. Run: mpop acl init")
        return

    # Write to temp file
    with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
        json.dump(acl, f, indent=2)
        temp_path = f.name

    # Open in editor
    editor = os.environ.get('EDITOR', 'vim')
    print(f"  Opening {temp_path} in {editor}...")

    try:
        subprocess.run([editor, temp_path])

        # Read back
        with open(temp_path, 'r') as f:
            new_acl = json.load(f)

        # Validate
        if "rules" not in new_acl:
            print("  ❌ Invalid ACL: missing 'rules'")
            return

        # Save
        if _acl_save(new_acl):
            print("  ✅ ACL saved")
            print()
            print("  Run: mpop acl apply")
        else:
            print("  ❌ Failed to save ACL")
    except json.JSONDecodeError as e:
        print(f"  ❌ Invalid JSON: {e}")
    except Exception as e:
        print(f"  ❌ Error: {e}")
    finally:
        os.unlink(temp_path)

def _acl_add_rule(args):
    """Add a rule: mpop acl add <from> <to> <service>"""
    if len(args) < 3:
        print("  Usage: mpop acl add <from> <to> <service>")
        print("  Example: mpop acl add dev relays ssh")
        return

    fr, to, service = args[0], args[1], args[2]
    comment = " ".join(args[3:]) if len(args) > 3 else ""

    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized. Run: mpop acl init")
        return

    new_rule = {
        "from": fr,
        "to": to,
        "allow": [service]
    }
    if comment:
        new_rule["comment"] = comment

    acl["rules"].append(new_rule)

    if _acl_save(acl):
        print(f"  ✅ Rule added: {fr} → {to} : {service}")
        print()
        print("  Run: mpop acl apply")
    else:
        print("  ❌ Failed to save")

def _acl_remove_rule(args):
    """Remove a rule by index"""
    if not args or not args[0].isdigit():
        print("  Usage: mpop acl remove <index>")
        print("  Use 'mpop acl status' to see rule indices")
        return

    idx = int(args[0])

    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized")
        return

    rules = acl.get("rules", [])
    if idx < 0 or idx >= len(rules):
        print(f"  ❌ Invalid index: {idx}")
        return

    removed = rules.pop(idx)
    acl["rules"] = rules

    if _acl_save(acl):
        print(f"  ✅ Rule removed: {removed.get('from')} → {removed.get('to')}")
    else:
        print("  ❌ Failed to save")

def _acl_test(args):
    """Test if a connection is allowed: mpop acl test <from> <to> <port>"""
    if len(args) < 3:
        print("  Usage: mpop acl test <from> <to> <port>")
        print("  Example: mpop acl test node1 node2 22")
        return

    fr, to, port = args[0], args[1], int(args[2])

    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized")
        return

    groups = acl.get("groups", {})
    services = acl.get("services", {})
    rules = acl.get("rules", [])
    default = acl.get("default", "deny")

    def expand_group(name):
        """Expand group name to list of nodes"""
        if name == "*":
            return list(get_servers().keys())
        if name in groups:
            g = groups[name]
            if "*" in g:
                return list(get_servers().keys())
            return g
        return [name]

    def port_in_service(port, svc_name):
        """Check if port is in service"""
        if svc_name == "all":
            return True
        ports = services.get(svc_name, [])
        if not ports:  # empty means all
            return True
        return port in ports

    # Check rules
    from_nodes = expand_group(fr)
    to_nodes = expand_group(to)

    for rule in rules:
        rule_from = expand_group(rule.get("from", "*"))
        rule_to = expand_group(rule.get("to", "*"))
        rule_allow = rule.get("allow", [])

        # Check if from/to match
        from_match = fr in rule_from or any(n in rule_from for n in from_nodes)
        to_match = to in rule_to or any(n in rule_to for n in to_nodes)

        if from_match and to_match:
            # Check if port is allowed
            for svc in rule_allow:
                if port_in_service(port, svc):
                    print(f"  ✅ ALLOWED: {fr} → {to}:{port}")
                    print(f"     Matched rule: {rule.get('from')} → {rule.get('to')} : {', '.join(rule_allow)}")
                    return True

    # Default action
    if default == "allow":
        print(f"  ✅ ALLOWED (default): {fr} → {to}:{port}")
    else:
        print(f"  ❌ DENIED: {fr} → {to}:{port}")
        print(f"     No matching rule, default is {default}")

    return default == "allow"

def _acl_apply():
    """Apply ACL rules to all nodes via iptables"""
    print("  🔐 Applying ACL rules...")
    print()

    acl = _acl_load()
    if not acl:
        print("  ❌ ACL not initialized. Run: mpop acl init")
        return

    servers = get_servers()
    groups = acl.get("groups", {})
    services = acl.get("services", {})
    rules = acl.get("rules", [])
    default = acl.get("default", "deny")

    def expand_group(name):
        if name == "*":
            return list(servers.keys())
        if name in groups:
            g = groups[name]
            if "*" in g:
                return list(servers.keys())
            return g
        return [name]

    def get_ports(svc_name):
        if svc_name == "all":
            return []
        return services.get(svc_name, [])

    # Generate iptables script for each node
    for node, node_ip in servers.items():
        print(f"  {node}...", end=" ", flush=True)

        # Build iptables rules for this node
        iptables_cmds = [
            f"# ACL for {node}",
            f"iptables -N {ACL_IPTABLES_CHAIN} 2>/dev/null || iptables -F {ACL_IPTABLES_CHAIN}",
        ]

        # Add rules where this node is the destination
        for rule in rules:
            rule_from = expand_group(rule.get("from", "*"))
            rule_to = expand_group(rule.get("to", "*"))

            if node not in rule_to and "*" not in rule.get("to", ""):
                continue

            for svc in rule.get("allow", []):
                ports = get_ports(svc)

                for from_node in rule_from:
                    if from_node not in servers:
                        continue
                    from_ip = servers[from_node]

                    if ports:
                        for port in ports:
                            iptables_cmds.append(
                                f"iptables -A {ACL_IPTABLES_CHAIN} -s {from_ip} -p tcp --dport {port} -j ACCEPT"
                            )
                    else:
                        # All ports
                        iptables_cmds.append(
                            f"iptables -A {ACL_IPTABLES_CHAIN} -s {from_ip} -j ACCEPT"
                        )

        # Default policy
        if default == "deny":
            # Deny other VPN traffic
            _vpn_subnet = _get_config("vpn_subnet", "10.99.0.0/16")
            iptables_cmds.append(f"iptables -A {ACL_IPTABLES_CHAIN} -s {_vpn_subnet} -j DROP")
        else:
            _vpn_subnet = _get_config("vpn_subnet", "10.99.0.0/16")
            iptables_cmds.append(f"iptables -A {ACL_IPTABLES_CHAIN} -s {_vpn_subnet} -j ACCEPT")

        # Insert chain into INPUT
        iptables_cmds.extend([
            f"iptables -D INPUT -j {ACL_IPTABLES_CHAIN} 2>/dev/null || true",
            f"iptables -I INPUT 1 -j {ACL_IPTABLES_CHAIN}",
            "echo ACL_APPLIED"
        ])

        script = "\n".join(iptables_cmds)

        try:
            result = vssh_exec(node_ip, script, timeout=10)
            if result and "ACL_APPLIED" in result:
                print("✅")
            else:
                print("⚠️")
        except Exception:
            print("❌")

    print()
    print("  ✅ ACL applied to all nodes")
    print()
    print("  Test: mpop acl test node1 node2 22")

def _acl_reset():
    """Reset ACL to allow-all"""
    print("  🔓 Resetting ACL to allow-all...")

    servers = get_servers()

    # Remove iptables chain on all nodes
    script = f"""
iptables -D INPUT -j {ACL_IPTABLES_CHAIN} 2>/dev/null || true
iptables -F {ACL_IPTABLES_CHAIN} 2>/dev/null || true
iptables -X {ACL_IPTABLES_CHAIN} 2>/dev/null || true
echo ACL_RESET
"""

    for node, ip in servers.items():
        print(f"  {node}...", end=" ", flush=True)
        try:
            result = vssh_exec(ip, script, timeout=10)
            if result and "ACL_RESET" in result:
                print("✅")
            else:
                print("⚠️")
        except Exception:
            print("❌")

    # Reset config to default
    if _acl_save(DEFAULT_ACL):
        print()
        print("  ✅ ACL reset to default (allow-all)")
    else:
        print()
        print("  ⚠️ Cleared iptables but failed to reset config")

# ==================== Tunnel Command ====================
# Expose internal services to the internet via reverse proxy

TUNNEL_PATH = "/var/lib/mpop/tunnels.json"
TUNNEL_NGINX_DIR = "/etc/nginx/sites-enabled"
TUNNEL_RELAY = os.environ.get("MPOP_TUNNEL_RELAY", "")  # Set via config

def cmd_tunnel(args):
    """Tunnel - Expose internal services to the internet ()"""

    if not args or args[0] == "list":
        _tunnel_list()
    elif args[0] == "add":
        _tunnel_add(args[1:])
    elif args[0] == "remove":
        _tunnel_remove(args[1:])
    elif args[0] == "deploy":
        _tunnel_deploy()
    elif args[0] == "status":
        _tunnel_status()
    else:
        print(f"""
  ⚔️  TUNNEL - Pierce through the firewall, expose your services!

  USAGE:
    mpop tunnel                    List active tunnels
    mpop tunnel add <target>       Add tunnel (expose service)
    mpop tunnel remove <name>      Remove tunnel
    mpop tunnel deploy             Deploy nginx config
    mpop tunnel status             Check tunnel health

  ADD OPTIONS:
    mpop tunnel add node2:8080                      # Auto domain: node2-8080.{TUNNEL_RELAY}.mesh
    mpop tunnel add node2:8080 --domain chat.com    # Custom domain
    mpop tunnel add node2:8080 --name chat          # Custom name
    mpop tunnel add node2:8080 --ssl                # Enable SSL (Let's Encrypt)
    mpop tunnel add node2:8080 --auth user:pass     # Basic auth

  EXAMPLES:
    # Expose chat app on node2
    mpop tunnel add node2:8080 --domain chat.example.com --ssl

    # Expose API on relay1 with basic auth
    mpop tunnel add v1:8721 --name api --auth admin:secret

    # Deploy to relay
    mpop tunnel deploy

  NOTES:
    - Tunnels are proxied through {TUNNEL_RELAY} (relay server)
    - SSL certificates via Let's Encrypt (requires valid domain)
    - Config stored at {TUNNEL_PATH}
""")

def _tunnel_load():
    """Load tunnels config from relay1"""
    relay_ip = SERVERS.get(TUNNEL_RELAY, FALLBACK_SERVERS.get(TUNNEL_RELAY))
    cmd = f"cat {TUNNEL_PATH} 2>/dev/null"
    result = vssh_exec(relay_ip, cmd, timeout=5)
    if result:
        try:
            return json.loads(result)
        except Exception:
            pass
    return {"tunnels": []}

def _tunnel_save(config):
    """Save tunnels config to relay1"""
    relay_ip = SERVERS.get(TUNNEL_RELAY, FALLBACK_SERVERS.get(TUNNEL_RELAY))
    config_json = json.dumps(config, indent=2)
    config_escaped = config_json.replace("'", "'\"'\"'")
    cmd = f"mkdir -p /var/lib/mpop && echo '{config_escaped}' > {TUNNEL_PATH} && echo OK"
    result = vssh_exec(relay_ip, cmd, timeout=5)
    return result and "OK" in result

def _tunnel_list():
    """List active tunnels"""
    print("  🔗 ACTIVE TUNNELS")
    print("  " + "=" * 60)

    config = _tunnel_load()
    tunnels = config.get("tunnels", [])

    if not tunnels:
        print("  No tunnels configured.")
        print()
        print("  Add one: mpop tunnel add node2:8080 --domain example.com")
        return

    print(f"  {'NAME':<12} {'TARGET':<18} {'DOMAIN':<25} {'SSL'}")
    print("  " + "-" * 60)

    for f in tunnels:
        name = f.get("name", "?")
        target = f"{f.get('node')}:{f.get('port')}"
        domain = f.get("domain", "-")
        ssl = "✅" if f.get("ssl") else "❌"
        auth = "🔐" if f.get("auth") else ""

        print(f"  {name:<12} {target:<18} {domain:<25} {ssl} {auth}")

    print()
    print(f"  Total: {len(tunnels)} tunnel(s)")
    print()
    print("  Commands:")
    print("    mpop tunnel deploy    Apply nginx config")
    print("    mpop tunnel status    Check health")

def _tunnel_add(args):
    """Add a tunnel: mpop tunnel add <node>:<port> [options]"""
    if not args:
        print("  Usage: mpop tunnel add <node>:<port> [--domain DOMAIN] [--ssl] [--auth user:pass]")
        return

    # Parse target
    target = args[0]
    if ":" not in target:
        print(f"  ❌ Invalid target: {target}")
        print("  Format: <node>:<port> (e.g., node2:8080)")
        return

    node, port = target.split(":", 1)
    port = int(port)

    if node not in get_servers():
        print(f"  ❌ Unknown node: {node}")
        print(f"  Available: {', '.join(get_servers().keys())}")
        return

    # Parse options
    domain = None
    name = None
    ssl = False
    auth = None

    i = 1
    while i < len(args):
        if args[i] == "--domain" and i + 1 < len(args):
            domain = args[i + 1]
            i += 2
        elif args[i] == "--name" and i + 1 < len(args):
            name = args[i + 1]
            i += 2
        elif args[i] == "--ssl":
            ssl = True
            i += 1
        elif args[i] == "--auth" and i + 1 < len(args):
            auth = args[i + 1]
            i += 2
        else:
            i += 1

    # Generate defaults
    if not name:
        name = f"{node}-{port}"

    if not domain:
        # Get relay public IP for auto domain hint
        relay_ip = SERVERS.get(TUNNEL_RELAY, FALLBACK_SERVERS.get(TUNNEL_RELAY))
        domain = f"{name}.example.com"
        print(f"  ⚠️  No domain specified. Using placeholder: {domain}")
        print(f"      Point your domain to relay IP and re-add with --domain")

    # Load existing config
    config = _tunnel_load()
    tunnels = config.get("tunnels", [])

    # Check for duplicates
    for f in tunnels:
        if f.get("name") == name:
            print(f"  ❌ Tunnel '{name}' already exists. Remove it first.")
            return

    # Add tunnel
    tunnel_entry = {
        "name": name,
        "node": node,
        "port": port,
        "domain": domain,
        "ssl": ssl,
        "auth": auth,
        "created": datetime.now().isoformat()
    }

    tunnels.append(tunnel_entry)
    config["tunnels"] = tunnels

    if _tunnel_save(config):
        print(f"  ✅ Tunnel ready: {name}")
        print()
        print(f"     Target:  {node}:{port}")
        print(f"     Domain:  {domain}")
        print(f"     SSL:     {'Yes' if ssl else 'No'}")
        print(f"     Auth:    {'Yes' if auth else 'No'}")
        print()
        print("  Next: mpop tunnel deploy")
    else:
        print("  ❌ Failed to save tunnel config")

def _tunnel_remove(args):
    """Remove a tunnel by name"""
    if not args:
        print("  Usage: mpop tunnel remove <name>")
        return

    name = args[0]

    config = _tunnel_load()
    tunnels = config.get("tunnels", [])

    # Find and remove
    new_tunnels = [f for f in tunnels if f.get("name") != name]

    if len(new_tunnels) == len(tunnels):
        print(f"  ❌ Tunnel not found: {name}")
        return

    config["tunnels"] = new_tunnels

    if _tunnel_save(config):
        print(f"  ✅ Tunnel removed: {name}")
        print()
        print("  Next: mpop tunnel deploy")
    else:
        print("  ❌ Failed to save config")

def _tunnel_deploy():
    """Deploy nginx config to relay"""
    print("  🚀 Deploying tunnels to relay...")
    print()

    config = _tunnel_load()
    tunnels = config.get("tunnels", [])

    if not tunnels:
        print("  ℹ️  No tunnels to deploy")
        return

    relay_ip = SERVERS.get(TUNNEL_RELAY, FALLBACK_SERVERS.get(TUNNEL_RELAY))
    servers = get_servers()

    # Check nginx is installed
    check_cmd = "which nginx >/dev/null 2>&1 && echo OK || echo MISSING"
    result = vssh_exec(relay_ip, check_cmd, timeout=5)
    if not result or "MISSING" in result:
        print("  ❌ nginx not installed on relay")
        print(f"     Run on {TUNNEL_RELAY}: apt install nginx")
        return

    # Generate nginx configs
    for tun in tunnels:
        name = tun.get("name")
        node = tun.get("node")
        port = tun.get("port")
        domain = tun.get("domain")
        ssl = tun.get("ssl", False)
        auth = tun.get("auth")

        print(f"  {name}...", end=" ", flush=True)

        # Get target IP
        target_ip = servers.get(node)
        if not target_ip:
            print("❌ (unknown node)")
            continue

        # Build nginx config
        upstream_name = f"mpop_{name}"

        if ssl:
            nginx_config = f"""
# Tunnel: {name}
# Generated by mpop tunnel
upstream {upstream_name} {{
    server {target_ip}:{port};
}}

server {{
    listen 80;
    server_name {domain};
    return 301 https://$server_name$request_uri;
}}

server {{
    listen 443 ssl http2;
    server_name {domain};

    ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;

    location / {{
        proxy_pass http://{upstream_name};
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }}
}}
"""
        else:
            nginx_config = f"""
# Tunnel: {name}
# Generated by mpop tunnel
upstream {upstream_name} {{
    server {target_ip}:{port};
}}

server {{
    listen 80;
    server_name {domain};

    location / {{
        proxy_pass http://{upstream_name};
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }}
}}
"""

        # Add basic auth if specified
        if auth:
            user, passwd = auth.split(":", 1) if ":" in auth else (auth, auth)
            # Create htpasswd entry
            auth_setup = f"""
htpasswd -bc /etc/nginx/.htpasswd_{name} '{user}' '{passwd}' 2>/dev/null || echo '{user}:$(openssl passwd -apr1 '{passwd}')' > /etc/nginx/.htpasswd_{name}
"""
            vssh_exec(relay_ip, auth_setup, timeout=5)

            # Add auth to location block
            nginx_config = nginx_config.replace(
                "location / {",
                f"location / {{\n        auth_basic \"Restricted\";\n        auth_basic_user_file /etc/nginx/.htpasswd_{name};"
            )

        # Write nginx config
        config_escaped = nginx_config.replace("'", "'\"'\"'")
        config_path = f"{TUNNEL_NGINX_DIR}/mpop-{name}.conf"

        write_cmd = f"echo '{config_escaped}' > {config_path} && echo OK"
        result = vssh_exec(relay_ip, write_cmd, timeout=5)

        if result and "OK" in result:
            print("✅")
        else:
            print("❌")
            continue

    # Test and reload nginx
    print()
    print("  Testing nginx config...", end=" ", flush=True)

    test_cmd = "nginx -t 2>&1"
    result = vssh_exec(relay_ip, test_cmd, timeout=10)

    if result and "successful" in result:
        print("✅")

        print("  Reloading nginx...", end=" ", flush=True)
        reload_cmd = "systemctl reload nginx && echo OK"
        result = vssh_exec(relay_ip, reload_cmd, timeout=10)

        if result and "OK" in result:
            print("✅")
        else:
            print("⚠️")
    else:
        print("❌")
        print(f"  Error: {result}")
        return

    print()
    print(f"  ✅ {len(tunnels)} tunnel(s) deployed")
    print()
    print("  Access:")
    for f in tunnels:
        proto = "https" if f.get("ssl") else "http"
        print(f"    {proto}://{f.get('domain')}/")

    print()
    print("  For SSL, run on relay:")
    print(f"    certbot --nginx -d <domain>")

def _tunnel_status():
    """Check tunnel health"""
    print("  🔍 TUNNEL STATUS")
    print("  " + "=" * 60)

    config = _tunnel_load()
    tunnels = config.get("tunnels", [])

    if not tunnels:
        print("  No tunnels configured")
        return

    relay_ip = SERVERS.get(TUNNEL_RELAY, FALLBACK_SERVERS.get(TUNNEL_RELAY))
    servers = get_servers()

    print(f"  {'NAME':<12} {'TARGET':<15} {'NGINX':<8} {'BACKEND':<10}")
    print("  " + "-" * 50)

    for tun in tunnels:
        name = tun.get("name")
        node = tun.get("node")
        port = tun.get("port")
        target_ip = servers.get(node, "?")

        # Check nginx config exists
        check_nginx = f"test -f {TUNNEL_NGINX_DIR}/mpop-{name}.conf && echo OK"
        nginx_ok = vssh_exec(relay_ip, check_nginx, timeout=3)
        nginx_status = "✅" if nginx_ok and "OK" in nginx_ok else "❌"

        # Check backend is reachable
        check_backend = f"nc -z -w2 {target_ip} {port} && echo OK"
        backend_ok = vssh_exec(relay_ip, check_backend, timeout=5)
        backend_status = "✅" if backend_ok and "OK" in backend_ok else "❌"

        print(f"  {name:<12} {node}:{port:<7} {nginx_status:<8} {backend_status:<10}")

    print()

# ==================== Voice News Command ====================
# "Hear ye, hear ye!" - Audio news briefing using LLM + Edge TTS


# ==================== Brain (Shared AI Context) ====================

BRAIN_PATH = "/var/lib/mpop/brain"
BRAIN_HUB = os.environ.get("MPOP_BRAIN_HUB", "")  # Set via config

def _brain_exec(cmd, timeout=10):
    """Execute command on brain hub (relay1)"""
    servers = get_servers()
    hub_ip = servers.get(BRAIN_HUB)
    if not hub_ip:
        return None
    return vssh_exec(hub_ip, cmd, timeout=timeout)

def _brain_read(path):
    """Read file from brain hub"""
    result = _brain_exec(f"cat {BRAIN_PATH}/{path} 2>/dev/null")
    if not result:
        return None
    return result.strip()

def _brain_write(path, content):
    """Write file to brain hub"""
    # Escape content for shell
    escaped = content.replace("'", "'\\''")
    cmd = f"mkdir -p $(dirname {BRAIN_PATH}/{path}) && cat > {BRAIN_PATH}/{path} << 'MPOP_EOF'\n{content}\nMPOP_EOF"
    result = _brain_exec(cmd, timeout=15)
    return result is not None

def _brain_append(path, line):
    """Append line to file on brain hub"""
    escaped = line.replace("'", "'\\''")
    cmd = f"mkdir -p $(dirname {BRAIN_PATH}/{path}) && echo '{escaped}' >> {BRAIN_PATH}/{path}"
    result = _brain_exec(cmd, timeout=10)
    return result is not None

def cmd_brain(args):
    """Brain - Shared AI context management"""

    if not args or args[0] == "status":
        # Show brain status
        print("\n  🧠 BRAIN STATUS")
        print("  " + "=" * 50)

        # Check if brain exists
        result = _brain_exec(f"ls -la {BRAIN_PATH} 2>/dev/null | head -20")
        if not result:
            print(f"\n  ⚠️  Brain not initialized on {BRAIN_HUB}")
            print(f"      Run: mpop brain init")
            return

        # Show structure
        print(f"\n  📍 Hub: {BRAIN_HUB}")
        print(f"  📁 Path: {BRAIN_PATH}")

        # Constitution
        const = _brain_read("constitution.md")
        if const:
            lines = const.count('\n') + 1
            print(f"\n  📜 Constitution: {lines} lines")
        else:
            print(f"\n  📜 Constitution: (not found)")

        # Context
        ctx = _brain_read("context.json")
        if ctx:
            try:
                data = json.loads(ctx)
                updated = data.get("updated", "unknown")
                active = len(data.get("active_work", {}))
                print(f"  ⚡ Context: updated {updated}, {active} active work")
            except Exception:
                print(f"  ⚡ Context: (parse error)")
        else:
            print(f"  ⚡ Context: (not found)")

        # Projects
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null | wc -l")
        if result:
            count = result.strip()
            print(f"  📂 Projects: {count}")

        # Locks
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/locks/*.lock 2>/dev/null | wc -l")
        if result and result.strip() != "0":
            print(f"  🔒 Active locks: {result.strip()}")

        print()
        return

    subcmd = args[0]

    if subcmd == "init":
        # Initialize brain structure
        print("\n  🧠 Initializing Brain...")

        # Create directory structure
        dirs = [
            "projects",
            "conversations",
            "knowledge",
            "locks",
            "sessions"
        ]

        for d in dirs:
            result = _brain_exec(f"mkdir -p {BRAIN_PATH}/{d} && echo ok")
            if not result or "ok" not in result:
                print(f"  ❌ Failed to create {d}")
                return
            print(f"  ✓ Created {d}/")

        # Create constitution.md
        constitution = """# mpop AI Constitution

## Article 1: Safety
- No deleting production data
- No modifying root filesystem
- No exporting sensitive data

## Article 2: Transparency
- Submit all actions as Plan first
- No T2/T3 without user approval
- Report failures immediately

## Article 3: Collaboration
- Do not interfere with other AI work
- Log context changes
- Yield on resource conflicts

## Article 4: Scope
- Work only on assigned nodes
- Work only on authorized projects
- Reference secrets only, never expose values
"""
        if _brain_write("constitution.md", constitution):
            print(f"  ✓ Created constitution.md")

        # Create initial context.json
        context = {
            "updated": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"),
            "active_work": {},
            "nodes": {},
            "recent_conversations": [],
            "pending_handoffs": []
        }
        if _brain_write("context.json", json.dumps(context, indent=2)):
            print(f"  ✓ Created context.json")

        print(f"\n  ✅ Brain initialized on {BRAIN_HUB}")
        print(f"     Path: {BRAIN_PATH}")
        return

    elif subcmd == "constitution":
        # Show constitution
        const = _brain_read("constitution.md")
        if const:
            print("\n" + const)
        else:
            print("\n  ⚠️  Constitution not found. Run: mpop brain init")
        return

    elif subcmd == "context":
        # Show current context
        ctx = _brain_read("context.json")
        if ctx:
            try:
                data = json.loads(ctx)
                print("\n  ⚡ BRAIN CONTEXT")
                print("  " + "=" * 50)
                print(json.dumps(data, indent=2, ensure_ascii=False))
            except Exception:
                print(ctx)
        else:
            print("\n  ⚠️  Context not found. Run: mpop brain init")
        return

    elif subcmd == "locks":
        # Show active locks
        result = _brain_exec(f"ls -la {BRAIN_PATH}/locks/*.lock 2>/dev/null")
        print("\n  🔒 ACTIVE LOCKS")
        print("  " + "=" * 50)
        if err or not result or "No such file" in result:
            print("  (no locks)")
        else:
            for line in result.strip().split('\n'):
                if '.lock' in line:
                    parts = line.split()
                    if len(parts) >= 9:
                        name = parts[-1].split('/')[-1].replace('.lock', '')
                        print(f"  {name}")
        print()
        return

    elif subcmd == "unlock" and len(args) > 1:
        # Force unlock
        lock_name = args[1].replace('.lock', '')
        result = _brain_exec(f"rm -f {BRAIN_PATH}/locks/{lock_name}.lock && echo ok")
        if result and "ok" in result:
            print(f"  ✓ Unlocked: {lock_name}")
        else:
            print(f"  ❌ Failed to unlock: {lock_name}")
        return

    elif subcmd == "backup":
        # Backup brain to local or show backups
        backup_name = args[1] if len(args) > 1 else f"brain-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
        backup_file = f"/tmp/{backup_name}.tar.gz"

        print(f"\n  🧠 BRAIN BACKUP")
        print("  " + "=" * 50)

        # Create backup on relay1
        result = _brain_exec(f"cd /var/lib/mpop && tar -czf /tmp/{backup_name}.tar.gz brain/ 2>&1 && ls -lh /tmp/{backup_name}.tar.gz")
        if not result or "No such file" in result:
            print(f"  ❌ Failed to create backup")
            return

        # Get size
        size = "unknown"
        for line in result.strip().split('\n'):
            if backup_name in line:
                parts = line.split()
                if len(parts) >= 5:
                    size = parts[4]

        print(f"  ✅ Backup created on {BRAIN_HUB}")
        print(f"     File: /tmp/{backup_name}.tar.gz")
        print(f"     Size: {size}")
        print(f"\n  Download:")
        print(f"     scp -P 51830 root@{BRAIN_HUB}:/tmp/{backup_name}.tar.gz .")
        print(f"\n  Or list backups:")
        print(f"     mpop brain backups")
        return

    elif subcmd == "backups":
        # List available backups on relay1
        print(f"\n  🧠 BRAIN BACKUPS")
        print("  " + "=" * 50)
        result = _brain_exec("ls -lht /tmp/brain-*.tar.gz 2>/dev/null | head -10")
        if not result or "No such file" in result:
            print("  (no backups found)")
        else:
            for line in result.strip().split('\n'):
                if line.strip():
                    parts = line.split()
                    if len(parts) >= 9:
                        size = parts[4]
                        date = f"{parts[5]} {parts[6]} {parts[7]}"
                        name = parts[-1].split('/')[-1]
                        print(f"  {name:40} {size:>8}  {date}")
        return

    elif subcmd == "restore" and len(args) > 1:
        # Restore brain from backup
        backup_file = args[1]
        if not backup_file.endswith('.tar.gz'):
            backup_file = f"/tmp/{backup_file}.tar.gz"
        elif not backup_file.startswith('/'):
            backup_file = f"/tmp/{backup_file}"

        print(f"\n  🧠 BRAIN RESTORE")
        print("  " + "=" * 50)
        print(f"  ⚠️  This will OVERWRITE current brain data!")
        print(f"  File: {backup_file}")

        # Check if backup exists
        result = _brain_exec(f"test -f {backup_file} && echo exists")
        if not result or "exists" not in result:
            print(f"\n  ❌ Backup file not found: {backup_file}")
            return

        # Confirm
        confirm = input("\n  Type 'yes' to confirm: ")
        if confirm.lower() != 'yes':
            print("  Cancelled")
            return

        # Restore
        result = _brain_exec(f"cd /var/lib/mpop && rm -rf brain.old && mv brain brain.old 2>/dev/null; tar -xzf {backup_file} && echo ok")
        if result and "ok" in result:
            print(f"\n  ✅ Brain restored from {backup_file}")
            print(f"     Old data saved to: /var/lib/mpop/brain.old/")
        else:
            print(f"\n  ❌ Restore failed")
        return

    elif subcmd == "download" and len(args) > 1:
        # Download backup to local
        backup_name = args[1]
        if not backup_name.endswith('.tar.gz'):
            backup_name = f"{backup_name}.tar.gz"

        import subprocess
        local_path = f"./{backup_name}"
        remote_path = f"/tmp/{backup_name}"

        print(f"\n  Downloading {backup_name}...")
        result = subprocess.run(
            ["scp", "-P", "51830", "-o", "StrictHostKeyChecking=no",
             f"root@{BRAIN_HUB}:{remote_path}", local_path],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print(f"  ✅ Downloaded: {local_path}")
        else:
            print(f"  ❌ Download failed: {result.stderr}")
        return

    elif subcmd == "search" and len(args) > 1:
        # Search across all brain content
        keyword = " ".join(args[1:])
        print(f"\n  🔍 BRAIN SEARCH: '{keyword}'")
        print("  " + "=" * 50)

        # Search in projects, knowledge, conversations
        result = _brain_exec(f"grep -rn --include='*.md' --include='*.json' --include='*.txt' '{keyword}' {BRAIN_PATH}/ 2>/dev/null | head -50")
        if not result or not result.strip():
            print(f"  (no results)")
            return

        # Parse results
        current_file = None
        for line in result.strip().split('\n'):
            if ':' in line:
                # Format: /path/file.md:line_num:content
                parts = line.split(':', 2)
                if len(parts) >= 3:
                    file_path = parts[0].replace(BRAIN_PATH + '/', '')
                    line_num = parts[1]
                    content = parts[2].strip()[:80]

                    # Group by file
                    if file_path != current_file:
                        current_file = file_path
                        print(f"\n  📄 {file_path}")

                    print(f"     L{line_num}: {content}")

        print()
        return

    elif subcmd == "grep" and len(args) > 1:
        # Alias for search
        args[0] = "search"
        return cmd_brain(args)

    else:
        print(f"""
  🧠 BRAIN - Shared AI Context

  Usage:
    mpop brain              Show status
    mpop brain init         Initialize brain on {BRAIN_HUB}
    mpop brain constitution Show AI constitution
    mpop brain context      Show current context
    mpop brain locks        Show active locks
    mpop brain unlock <n>   Force unlock
    mpop brain search <kw>  Search all brain content

  Backup:
    mpop brain backup [name]   Create backup on v1
    mpop brain backups         List available backups
    mpop brain restore <name>  Restore from backup
    mpop brain download <name> Download backup to local
""")


def cmd_write(args):
    """Write - Delegate document/summary tasks to GPT"""

    if "--help" in args:
        print("""
  ✍️  WRITE - Document writing via AI

  Usage:
    mpop write                              Interactive mode
    mpop write "Write a project README"     Single question
    mpop write --project tokmor             With project context
    mpop write --file docs/FAQ.md "Summarize" Include file

  Options:
    --file <path>      Include file content as context
    --project <name>   Include project AI context
    --model <name>     Model override (e.g. gpt-4o, claude-opus-4-5)
    --engine <name>    Engine override: ollama|openai|claude|gemini
    --save <path>      Save result to file

  AI Engine (config ~/.mpop/config.json -> ai.engine):
    ollama   - Local Ollama (default, free)  https://ollama.com
    openai   - OpenAI API  (mpop secret set openai)
    claude   - Anthropic   (mpop secret set anthropic)
    gemini   - Google AI   (mpop secret set gemini)
""")
        return
        return

    # Engine & model from config (no API key required for ollama)
    _cfg_engine, _cfg_model = _get_ai_config()
    write_engine = _cfg_engine
    model = _cfg_model
    context = ""
    save_path = None
    prompt_parts = []

    i = 0
    while i < len(args):
        arg = args[i]

        if arg == "--model" and i + 1 < len(args):
            model = args[i + 1]
            i += 2
        elif arg == "--file" and i + 1 < len(args):
            file_path = args[i + 1]
            try:
                # Local file or brain file
                if os.path.exists(file_path):
                    with open(file_path, 'r') as f:
                        context += f"\n\n=== {file_path} ===\n{f.read()}"
                else:
                    # Read from brain
                    content = _brain_read(file_path)
                    if content:
                        context += f"\n\n=== {file_path} ===\n{content}"
            except Exception as e:
                print(f"  ⚠️  Failed to read file: {e}")
            i += 2
        elif arg == "--project" and i + 1 < len(args):
            proj_name = args[i + 1]
            # Read project meta
            meta_content = _brain_read(f"projects/{proj_name}/project.json")
            if meta_content:
                try:
                    meta = json.loads(meta_content)
                    ctx = meta.get("context", {})
                    context += f"\n\n=== Project: {proj_name} ===\n"
                    context += f"Description: {meta.get('description', '')}\n"
                    context += f"Servers: {', '.join(meta.get('servers', []))}\n"
                    context += f"Summary: {ctx.get('summary', '')}\n"
                except Exception:
                    pass
            # Include MASTER_INDEX
            index_content = _brain_read(f"projects/{proj_name}/MASTER_INDEX.json")
            if index_content:
                context += f"\n\n=== Document Index ===\n{index_content[:3000]}"
            i += 2
        elif arg == "--context" and i + 1 < len(args):
            context += f"\n\n{args[i + 1]}"
            i += 2
        elif arg == "--save" and i + 1 < len(args):
            save_path = args[i + 1]
            i += 2
        else:
            prompt_parts.append(arg)
            i += 1

    prompt = " ".join(prompt_parts)

    def call_gpt(messages):
        """Call configured LLM with message history (extracts last user message)"""
        # For multi-turn, build a combined prompt from history
        user_msgs = [m["content"] for m in messages if m["role"] == "user"]
        prompt = user_msgs[-1] if user_msgs else ""
        # Include prior context in prompt if conversation exists
        if len(user_msgs) > 1:
            history = "\n".join(f"User: {m['content']}" for m in messages[1:-1] if m["role"] == "user")
            prompt = f"Conversation so far:\n{history}\n\nUser: {prompt}"
        result = ai_generate(prompt, force_engine=write_engine, force_model=model)
        return result, {}

    # Build system message
    system_msg = "You are a technical documentation expert. Be concise and clear."
    if context:
        system_msg += f"\n\nReference context:\n{context}"

    messages = [{"role": "system", "content": system_msg}]
    total_tokens = 0

    # Single question mode
    if prompt:
        print(f"\n  ✍️  Write ({model})")
        print("  " + "=" * 50)

        messages.append({"role": "user", "content": prompt})
        answer, usage = call_gpt(messages)
        print(f"\n{answer}\n")

        if save_path:
            with open(save_path, 'w') as f:
                f.write(answer)
            print(f"  ✅ Saved: {save_path}")

        print(f"  📊 Tokens: {usage.get('total_tokens', 0)}")
        return

    # Interactive mode
    print(f"""
  ✍️  WRITE - Interactive mode ({model})
  ══════════════════════════════════════════════════
  Document writing, summarization, and organization via conversation.

  Commands:
    /save <file>  Save conversation    /clear  Clear    /exit  Exit
  ──────────────────────────────────────────────────
""")

    conversation_log = []

    while True:
        try:
            user_input = input("  You: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n  👋 Exit")
            break

        if not user_input:
            continue

        # Command processing
        if user_input.startswith("/"):
            if user_input == "/exit" or user_input == "/quit":
                print("  👋 Exit")
                break
            elif user_input == "/clear":
                messages = [{"role": "system", "content": system_msg}]
                conversation_log = []
                print("  🔄 Conversation cleared\n")
                continue
            elif user_input.startswith("/save "):
                file_path = user_input[6:].strip()
                try:
                    with open(file_path, 'w') as f:
                        for entry in conversation_log:
                            f.write(f"## {entry['role'].upper()}\n{entry['content']}\n\n")
                    print(f"  ✅ Saved: {file_path}\n")
                except Exception as e:
                    print(f"  ❌ Save failed: {e}\n")
                continue
            else:
                print(f"  ⚠️  Unknown command: {user_input}\n")
                continue

        # GPT call
        messages.append({"role": "user", "content": user_input})
        conversation_log.append({"role": "user", "content": user_input})

        print(f"  {write_engine.upper()}: ", end="", flush=True)
        answer, usage = call_gpt(messages)
        print(answer)
        print()

        messages.append({"role": "assistant", "content": answer})
        conversation_log.append({"role": "assistant", "content": answer})

        total_tokens += usage.get("total_tokens", 0)

    print(f"\n  📊 Total Tokens: {total_tokens}")


def cmd_scan(args):
    """Scan project folders across servers and register to brain"""

    # Server list to scan
    servers = list(_get_config("servers", {}).keys())

    # Project detection patterns (if this file exists, it's a project folder)
    project_markers = [
        "package.json",      # Node.js
        "setup.py",          # Python
        "pyproject.toml",    # Python modern
        "Cargo.toml",        # Rust
        "go.mod",            # Go
        "Makefile",          # C/C++
        "CMakeLists.txt",    # CMake
        "pom.xml",           # Java Maven
        "build.gradle",      # Java Gradle
        "*.xcodeproj",       # iOS/macOS
        "Podfile",           # iOS CocoaPods
        "pubspec.yaml",      # Flutter/Dart
    ]

    # Scan exclusion patterns
    exclude_dirs = [
        "node_modules", ".git", "venv", "__pycache__", ".venv",
        "vendor", "build", "dist", ".cache", "Library", "Applications",
        ".Trash", "Pictures", "Music", "Movies", "Downloads"
    ]

    print(f"""
  ╔══════════════════════════════════════════════════════════════╗
  ║  🔍 PROJECT SCANNER - Project scanner across servers                 ║
  ╚══════════════════════════════════════════════════════════════╝
""")

    if "--help" in args or "-h" in args:
        print("""
  Usage:
    mpop scan                 Scan all servers
    mpop scan --server v1     Scan specific server
    mpop scan --register      Register found projects to brain
    mpop scan --depth 3       Search depth (default: 2)

  Project detection criteria:
    package.json, setup.py, Cargo.toml, go.mod etc.
""")
        return

    # Parse options
    target_server = None
    do_register = "--register" in args
    max_depth = 2

    if "--server" in args:
        idx = args.index("--server")
        if len(args) > idx + 1:
            target_server = args[idx + 1]
            servers = [target_server]

    if "--depth" in args:
        idx = args.index("--depth")
        if len(args) > idx + 1:
            try:
                max_depth = int(args[idx + 1])
            except Exception:
                pass

    all_projects = []
    today = datetime.now().strftime("%Y-%m-%d")

    for server in servers:
        print(f"  [{server}] Scanning...", end=" ", flush=True)

        # Find projects in home directory
        # Search for project marker files with find command
        marker_conditions = " -o ".join([f'-name "{m}"' for m in project_markers[:6]])
        exclude_conditions = " ".join([f'! -path "*/{d}/*"' for d in exclude_dirs])

        # Scan multiple locations: $HOME, /root, /home/*, /opt
        # Runs as root on server but projects may be in /home/username
        cmd = f"""{{
            find $HOME -maxdepth {max_depth + 1} -type f \\( {marker_conditions} \\) {exclude_conditions} 2>/dev/null;
            [ -d /root ] && [ "$HOME" != "/root" ] && find /root -maxdepth {max_depth + 1} -type f \\( {marker_conditions} \\) {exclude_conditions} 2>/dev/null;
            for d in /home/*; do [ -d "$d" ] && find "$d" -maxdepth {max_depth + 1} -type f \\( {marker_conditions} \\) {exclude_conditions} 2>/dev/null; done;
            [ -d /opt ] && find /opt -maxdepth {max_depth + 1} -type f \\( {marker_conditions} \\) {exclude_conditions} 2>/dev/null;
        }} | sort -u | head -200"""

        result = vssh_exec(server, cmd.strip(), timeout=30)

        if result is None:
            print("✗ Connection failed")
            continue

        if not result.strip():
            print("(No projects)")
            continue

        # Parse results - extract project folders
        project_dirs = set()
        for line in result.strip().split('\n'):
            if line.strip():
                # Extract directory from file path
                project_dir = os.path.dirname(line.strip())
                if project_dir and project_dir != "~":
                    project_dirs.add(project_dir)

        if not project_dirs:
            print("(No projects)")
            continue

        print(f"✓ {len(project_dirs)}found")

        for pdir in sorted(project_dirs):
            # Extract project name
            pname = os.path.basename(pdir)
            if not pname or pname == os.environ.get("MPOP_USER", "user") or pname == "root":
                continue

            # Detect project type
            ptype = "unknown"
            if "package.json" in result and pdir in result:
                ptype = "node"
            elif "setup.py" in result or "pyproject.toml" in result:
                ptype = "python"
            elif "Cargo.toml" in result:
                ptype = "rust"
            elif "go.mod" in result:
                ptype = "go"
            elif ".xcodeproj" in result or "Podfile" in result:
                ptype = "ios"

            proj_info = {
                "name": pname,
                "server": server,
                "path": pdir,
                "type": ptype,
                "found": today
            }
            all_projects.append(proj_info)
            print(f"       {pname:25} [{ptype:6}] {pdir}")

    # Result summary
    print(f"\n  {'=' * 60}")
    print(f"  Total {len(all_projects)}projects found")

    # Stats by server
    by_server = {}
    for p in all_projects:
        by_server.setdefault(p["server"], []).append(p)

    print(f"\n  By server:")
    for server in servers:
        count = len(by_server.get(server, []))
        if count:
            print(f"     {server:4}: {count}items")

    # Stats by type
    by_type = {}
    for p in all_projects:
        by_type.setdefault(p["type"], []).append(p)

    print(f"\n  By type:")
    for ptype, projects in sorted(by_type.items(), key=lambda x: -len(x[1])):
        print(f"     {ptype:8}: {len(projects)}items")

    # Register to brain
    if do_register and all_projects:
        print(f"\n  Registering to Brain...")

        for proj in all_projects:
            # Check if project already registered
            exists = _brain_exec(f"test -d {BRAIN_PATH}/projects/{proj['name']} && echo yes")
            if exists and "yes" in exists:
                print(f"     {proj['name']}: Already exists (skip)")
                continue

            # Register new project
            _brain_exec(f"mkdir -p {BRAIN_PATH}/projects/{proj['name']}")

            meta = {
                "name": proj["name"],
                "created": today,
                "description": f"[{proj['type']}] Auto-discovered on {proj['server']}",
                "servers": [proj["server"]],
                "remote_path": proj["path"],
                "type": proj["type"],
                "context": {"summary": f"{proj['server']}:{proj['path']}found at"}
            }
            _brain_write(f"projects/{proj['name']}/project.json", json.dumps(meta, ensure_ascii=False, indent=2))

            # Operation log
            entry = {"ts": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), "action": "discovered", "by": "scan", "note": f"{proj['server']}:{proj['path']}"}
            _brain_append(f"projects/{proj['name']}/ops.jsonl", json.dumps(entry, ensure_ascii=False))

            print(f"     ✓ {proj['name']} registered")

        print(f"\n  ✅ Registration complete")

    elif all_projects and not do_register:
        print(f"\n  💡 To register: mpop scan --register")

    # Save JSON (for analysis)
    scan_result = {
        "scanned_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"),
        "servers": servers,
        "projects": all_projects
    }
    _brain_write("scans/latest.json", json.dumps(scan_result, ensure_ascii=False, indent=2))
    print(f"\n  Results saved: brain/scans/latest.json")


def cmd_view(args):
    """Project dashboard - timeline, servers, docs at a glance"""

    if not args:
        # Display project list
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
        if not result:
            print("\n  ⚠️  No projects. Create one: mpop project new <name>")
            return

        projects = [p.strip() for p in result.strip().split('\n') if p.strip()]
        print("\n  📊 PROJECT DASHBOARD")
        print("  " + "=" * 60)
        print(f"\n  Available: {', '.join(projects)}")
        print(f"\n  Usage: mpop view <project>")
        return

    proj_name = args[0]

    # Check if project exists
    result = _brain_exec(f"test -d {BRAIN_PATH}/projects/{proj_name} && echo yes")
    if not result or "yes" not in result:
        print(f"\n  ⚠️  Project not found: {proj_name}")
        return

    # Read project meta
    meta_content = _brain_read(f"projects/{proj_name}/project.json")
    meta = {}
    if meta_content:
        try:
            meta = json.loads(meta_content)
        except Exception:
            pass

    ctx = meta.get("context", {})
    servers = meta.get("servers", [])

    print(f"""
  ╔══════════════════════════════════════════════════════════════╗
  ║  📊 {proj_name.upper():^54} ║
  ╚══════════════════════════════════════════════════════════════╝
""")

    # 1. Overview
    print("  📌 Overview")
    print("  " + "-" * 60)
    if ctx.get("summary"):
        # Split summary into multiple lines
        summary = ctx["summary"]
        lines = [summary[i:i+55] for i in range(0, len(summary), 55)]
        for line in lines[:3]:
            print(f"     {line}")
    else:
        print(f"     (No summary - mpop project {proj_name} --context \"summary\" to set)")
    print()

    # 2. Server & Path
    print("  🖥️  Infrastructure")
    print("  " + "-" * 60)
    print(f"     Servers: {', '.join(servers) if servers else '(none)'}")
    if meta.get("local_path"):
        print(f"     Local: {meta['local_path']}")
    if meta.get("remote_path"):
        print(f"     Remote: {meta['remote_path']}")
    print()

    # 3. Development chronicle (recent ops log)
    print("  📜 Development Timeline (last 10)")
    print("  " + "-" * 60)
    ops_log = _brain_exec(f"tail -10 {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
    if ops_log:
        for line in ops_log.strip().split('\n'):
            if line.strip():
                try:
                    e = json.loads(line)
                    ts = e.get("ts", "")[:10]
                    action = e.get("action", "")[:12]
                    note = e.get("note", "")[:40]
                    print(f"     {ts}  {action:12}  {note}")
                except Exception:
                    pass
    else:
        print("     (no records)")
    print()

    # 4. Documentation status
    print("  📄 Documentation Status")
    print("  " + "-" * 60)

    # Read MASTER_INDEX.json
    index_json = _brain_read(f"projects/{proj_name}/MASTER_INDEX.json")
    if index_json:
        try:
            docs = json.loads(index_json)
            # Count by status
            active = len([d for d in docs if d.get("status") == "active"])
            draft = len([d for d in docs if d.get("status") == "draft"])
            archived = len([d for d in docs if d.get("status") == "archived"])
            print(f"     Total {len(docs)}items | Active: {active} | Draft: {draft} | Archived: {archived}")

            # Main tags
            tag_count = {}
            for d in docs:
                for tag in d.get("tags", "").split(","):
                    tag = tag.strip()
                    if tag:
                        tag_count[tag] = tag_count.get(tag, 0) + 1
            top_tags = sorted(tag_count.items(), key=lambda x: -x[1])[:5]
            if top_tags:
                print(f"     Tags: {', '.join([f'{t}({c})' for t,c in top_tags])}")
        except Exception:
            pass
    else:
        # Count files only
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects/{proj_name}/*.md 2>/dev/null | wc -l")
        if result:
            print(f"     .md files: {result.strip()}items")
        else:
            print("     (no docs)")
    print()

    # 5. Issues & TODO
    issues = ctx.get("issues", [])
    todos = [t for t in meta.get("todos", []) if not t.get("done")]

    if issues or todos:
        print("  ⚡ Active Tasks")
        print("  " + "-" * 60)
        if issues:
            for i in issues[:3]:
                print(f"     ⚠️  {i.get('desc', '')[:50]}")
        if todos:
            for t in todos[:3]:
                print(f"     📋 {t.get('task', '')[:50]}")
        print()

    # 6. Related commands
    print("  🔧 Quick Commands")
    print("  " + "-" * 60)
    print(f"     mpop brain search '<keyword>'     # Search docs")
    print(f"     mpop project {proj_name} --pull    # Sync from server")
    print(f"     mpop project {proj_name} --doc <file>  # View doc")
    print(f"     mpop project {proj_name} --history     # Full log")
    print()


def cmd_project(args):
    """Project - Operations log + AI context system"""

    hostname = get_hostname()
    now_ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
    today = datetime.now().strftime("%Y-%m-%d")

    def _get_project_meta(name):
        """Read project metadata"""
        content = _brain_read(f"projects/{name}/project.json")
        if content:
            try:
                return json.loads(content)
            except Exception:
                pass
        return None

    def _save_project_meta(name, meta):
        """Save project metadata"""
        content = json.dumps(meta, ensure_ascii=False, indent=2)
        _brain_write(f"projects/{name}/project.json", content)

    def _add_ops_log(name, action, **kwargs):
        """Add operation log"""
        entry = {"ts": now_ts, "action": action, "by": hostname, **kwargs}
        _brain_append(f"projects/{name}/ops.jsonl", json.dumps(entry, ensure_ascii=False))

    # --active: view active projects
    if args and args[0] == "--active":
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
        if not result:
            print("  ⚠️  No projects")
            return
        for proj in result.strip().split('\n'):
            if proj.strip():
                meta = _get_project_meta(proj.strip())
                if meta and meta.get("active"):
                    print(f"\n  ★ Active: {proj.strip()}")
                    # Show summary
                    print(f"  Description: {meta.get('description', '')}")
                    ctx = meta.get("context", {})
                    if ctx.get("recent_work"):
                        print(f"  Recent: {ctx['recent_work'][:50]}")
                    todos = [t for t in meta.get("todos", []) if not t.get("done")]
                    if todos:
                        print(f"\n  📋 TODOs ({len(todos)}):")
                        for i, t in enumerate(todos[:3]):
                            print(f"     [{i}] {t.get('task', '')}")
                    issues = ctx.get("issues", [])
                    if issues:
                        print(f"\n  ⚠️  Issues ({len(issues)}):")
                        for i in issues[:2]:
                            print(f"     - {i.get('desc', '')}")
                    return
        print("  ⚠️  No active project. Use: mpop project <name> --activate")
        return

    if not args or args[0] == "list":
        # Project list + recent activity
        print("\n  📂 PROJECTS (Operations log + AI context)")
        print("  " + "=" * 60)

        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
        if not result:
            print("  (no projects)")
            print(f"\n  Create: mpop project new <name>")
            return

        projects = [p.strip() for p in result.strip().split('\n') if p.strip()]

        for proj in projects:
            meta = _get_project_meta(proj)
            if meta:
                active = "★" if meta.get("active") else " "
                desc = meta.get("description", "")[:28]
                servers = ",".join(meta.get("servers", [])[:3])
                todos = len([t for t in meta.get("todos", []) if not t.get("done")])
                todo_str = f"📋{todos}" if todos else ""
                last_ops = _brain_exec(f"tail -1 {BRAIN_PATH}/projects/{proj}/ops.jsonl 2>/dev/null")
                last_date = ""
                if last_ops:
                    try:
                        last_date = json.loads(last_ops).get("ts", "")[:10]
                    except Exception:
                        pass
                print(f"  {active} {proj:15} {desc:28} [{servers}] {todo_str:4} {last_date}")
            else:
                print(f"    {proj:15} (legacy - run: mpop project {proj} --migrate)")

        print(f"\n  Total: {len(projects)} projects")
        return

    subcmd = args[0]

    if subcmd == "new" and len(args) > 1:
        # Create new project
        name = args[1]
        desc = " ".join(args[2:]) if len(args) > 2 else ""

        print(f"\n  📂 Creating project: {name}")

        result = _brain_exec(f"mkdir -p {BRAIN_PATH}/projects/{name} && echo ok")
        if not result or "ok" not in result:
            print(f"  ❌ Failed to create project directory")
            return

        # Create project.json
        meta = {
            "name": name,
            "created": today,
            "description": desc,
            "servers": [],
            "files": [],
            "context": {
                "summary": "",
                "recent_work": "",
                "issues": [],
                "resolved": []
            }
        }
        _save_project_meta(name, meta)
        print(f"  ✓ Created project.json")

        # First operation log
        _add_ops_log(name, "created", note="Project created")
        print(f"  ✓ Created ops.jsonl")

        print(f"\n  ✅ Project created: {name}")
        print(f"     mpop project {name} --servers v1,node2")
        print(f"     mpop project {name} --context \"Project summary\"")
        return

    # Check if project exists
    result = _brain_exec(f"test -d {BRAIN_PATH}/projects/{subcmd} && echo yes")
    if result and "yes" in result:
        proj_name = subcmd
        proj_args = args[1:] if len(args) > 1 else []
        meta = _get_project_meta(proj_name)

        # --migrate: convert existing project to new format
        if "--migrate" in proj_args:
            if meta:
                print(f"  Already migrated")
                return
            meta = {
                "name": proj_name,
                "created": today,
                "description": "",
                "servers": [],
                "files": [],
                "context": {"summary": "", "recent_work": "", "issues": [], "resolved": []}
            }
            _save_project_meta(proj_name, meta)
            print(f"  ✅ Migrated to new format")
            return

        if not meta:
            meta = {"name": proj_name, "created": today, "description": "", "servers": [], "files": [], "context": {"summary": "", "recent_work": "", "issues": [], "resolved": []}}

        # --log: add operation log
        if "--log" in proj_args:
            idx = proj_args.index("--log")
            if len(proj_args) > idx + 1:
                message = " ".join(proj_args[idx + 1:])
                _add_ops_log(proj_name, "note", note=message)
                print(f"  ✅ Logged: {message}")
            return

        # --deploy: deployment log
        if "--deploy" in proj_args:
            idx = proj_args.index("--deploy")
            servers = proj_args[idx + 1].split(",") if len(proj_args) > idx + 1 else meta.get("servers", [])
            note = " ".join(proj_args[idx + 2:]) if len(proj_args) > idx + 2 else ""
            _add_ops_log(proj_name, "deploy", servers=servers, note=note)
            print(f"  ✅ Deploy logged: {servers}")
            return

        # --restart: restart log
        if "--restart" in proj_args:
            idx = proj_args.index("--restart")
            service = proj_args[idx + 1] if len(proj_args) > idx + 1 else proj_name
            server = proj_args[idx + 2] if len(proj_args) > idx + 2 else ""
            _add_ops_log(proj_name, "restart", service=service, server=server)
            print(f"  ✅ Restart logged: {service} on {server}")
            return

        # --issue: add issue
        if "--issue" in proj_args:
            idx = proj_args.index("--issue")
            if len(proj_args) > idx + 1:
                issue = " ".join(proj_args[idx + 1:])
                meta.setdefault("context", {}).setdefault("issues", []).append({"ts": today, "desc": issue})
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "issue", note=issue)
                print(f"  ✅ Issue added: {issue}")
            return

        # --resolve: resolve issue
        if "--resolve" in proj_args:
            idx = proj_args.index("--resolve")
            if len(proj_args) > idx + 1:
                resolution = " ".join(proj_args[idx + 1:])
                ctx = meta.setdefault("context", {})
                # Move most recent issue to resolved
                issues = ctx.get("issues", [])
                if issues:
                    resolved_issue = issues.pop()
                    ctx.setdefault("resolved", []).append({"ts": today, "issue": resolved_issue.get("desc", ""), "resolution": resolution})
                else:
                    ctx.setdefault("resolved", []).append({"ts": today, "resolution": resolution})
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "resolve", note=resolution)
                print(f"  ✅ Resolved: {resolution}")
            return

        # --servers: set related servers
        if "--servers" in proj_args:
            idx = proj_args.index("--servers")
            if len(proj_args) > idx + 1:
                servers = proj_args[idx + 1].split(",")
                meta["servers"] = servers
                _save_project_meta(proj_name, meta)
                print(f"  ✅ Servers set: {servers}")
            return

        # --context: set AI context
        if "--context" in proj_args:
            idx = proj_args.index("--context")
            if len(proj_args) > idx + 1:
                summary = " ".join(proj_args[idx + 1:])
                meta.setdefault("context", {})["summary"] = summary
                _save_project_meta(proj_name, meta)
                print(f"  ✅ Context set: {summary}")
            return

        # --work: recent work log
        if "--work" in proj_args:
            idx = proj_args.index("--work")
            if len(proj_args) > idx + 1:
                work = " ".join(proj_args[idx + 1:])
                meta.setdefault("context", {})["recent_work"] = work
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "work", note=work)
                print(f"  ✅ Work logged: {work}")
            return

        # --todo: add todo
        if "--todo" in proj_args:
            idx = proj_args.index("--todo")
            if len(proj_args) > idx + 1:
                todo = " ".join(proj_args[idx + 1:])
                meta.setdefault("todos", []).append({"ts": today, "task": todo, "done": False})
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "todo", note=todo)
                print(f"  ✅ TODO added: {todo}")
                # Show todo list
                todos = [t for t in meta.get("todos", []) if not t.get("done")]
                if todos:
                    print(f"\n  📋 Pending ({len(todos)}):")
                    for i, t in enumerate(todos):
                        print(f"     [{i}] {t.get('task', '')}")
            return

        # --done: complete todo
        if "--done" in proj_args:
            idx = proj_args.index("--done")
            todos = meta.get("todos", [])
            pending = [(i, t) for i, t in enumerate(todos) if not t.get("done")]
            if not pending:
                print("  ⚠️  No pending TODOs")
                return
            # Specify index or complete first
            todo_idx = 0
            if len(proj_args) > idx + 1:
                try:
                    todo_idx = int(proj_args[idx + 1])
                except Exception:
                    pass
            if todo_idx < len(pending):
                real_idx, todo = pending[todo_idx]
                todos[real_idx]["done"] = True
                todos[real_idx]["done_at"] = today
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "done", note=todo.get("task", ""))
                print(f"  ✅ Done: {todo.get('task', '')}")
                # Show remaining todos
                remaining = [t for t in todos if not t.get("done")]
                if remaining:
                    print(f"\n  📋 Remaining ({len(remaining)}):")
                    for i, t in enumerate(remaining):
                        print(f"     [{i}] {t.get('task', '')}")
                else:
                    print(f"\n  🎉 All TODOs completed!")
            return

        # --next: view next task
        if "--next" in proj_args:
            todos = [t for t in meta.get("todos", []) if not t.get("done")]
            issues = meta.get("context", {}).get("issues", [])
            print(f"\n  📋 NEXT: {proj_name}")
            print("  " + "=" * 50)
            if todos:
                print(f"  TODOs ({len(todos)}):")
                for i, t in enumerate(todos[:5]):
                    print(f"     [{i}] {t.get('task', '')}")
            if issues:
                print(f"\n  Open Issues ({len(issues)}):")
                for i in issues[:3]:
                    print(f"     ⚠️  {i.get('desc', '')}")
            if not todos and not issues:
                print("  ✨ Nothing pending!")
            return

        # --activate: set this project as active
        if "--activate" in proj_args:
            # Deactivate other projects
            result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
            if result:
                for p in result.strip().split('\n'):
                    if p.strip():
                        other_meta = _get_project_meta(p.strip())
                        if other_meta and other_meta.get("active"):
                            other_meta["active"] = False
                            _save_project_meta(p.strip(), other_meta)
            # Activate this project
            meta["active"] = True
            _save_project_meta(proj_name, meta)
            _add_ops_log(proj_name, "activate", note="Project activated")
            print(f"  ✅ Activated: {proj_name}")
            return

        # --history: view operation log
        if "--history" in proj_args:
            result = _brain_exec(f"tail -20 {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
            print(f"\n  📜 OPS LOG: {proj_name}")
            print("  " + "=" * 60)
            if result:
                for line in result.strip().split('\n'):
                    if line.strip():
                        try:
                            e = json.loads(line)
                            ts = e.get("ts", "")[:16].replace("T", " ")
                            action = e.get("action", "")[:10]
                            by = e.get("by", "")[:6]
                            note = e.get("note", "")[:35]
                            servers = ",".join(e.get("servers", []))[:10]
                            extra = servers if servers else note
                            print(f"  {ts}  {action:10}  {by:6}  {extra}")
                        except Exception:
                            pass
            else:
                print("  (no logs)")
            return

        # --change: file change log (git style)
        if "--change" in proj_args:
            idx = proj_args.index("--change")
            remaining = proj_args[idx + 1:]
            if len(remaining) < 2:
                print("  Usage: mpop project <name> --change <file> \"description\"")
                return
            file_ref = remaining[0]
            message = " ".join(remaining[1:])
            _add_ops_log(proj_name, "change", file=file_ref, note=message)
            print(f"  ✅ Change logged: {file_ref} - {message}")
            return

        # --doc: view document
        if "--doc" in proj_args:
            idx = proj_args.index("--doc")
            if len(proj_args) > idx + 1:
                doc = proj_args[idx + 1]
                content = _brain_read(f"projects/{proj_name}/{doc}")
                if content:
                    print(f"\n{content}")
                else:
                    print(f"\n  ⚠️  Document not found: {doc}")
            return

        # --write: write document
        if "--write" in proj_args:
            idx = proj_args.index("--write")
            if len(proj_args) <= idx + 1:
                print("  Usage: mpop project <name> --write <file.md>")
                return
            doc = proj_args[idx + 1]
            print(f"  Writing {doc} for {proj_name}")
            print("  Enter content (Ctrl+D to save):")
            try:
                import sys
                content = sys.stdin.read()
                if _brain_write(f"projects/{proj_name}/{doc}", content):
                    _add_ops_log(proj_name, "doc_update", file=doc, note=f"Updated {doc}")
                    print(f"  ✅ Saved: {doc}")
            except Exception:
                print("  ❌ Cancelled")
            return

        # --files: file list
        if "--files" in proj_args:
            result = _brain_exec(f"ls -la {BRAIN_PATH}/projects/{proj_name}/ 2>/dev/null")
            print(f"\n  📁 Files in {proj_name}:")
            if result:
                for line in result.strip().split('\n')[1:]:
                    parts = line.split()
                    if len(parts) >= 9:
                        print(f"  {parts[-1]:20} {parts[4]:>8}")
            return

        # --path: register local path
        if "--path" in proj_args:
            idx = proj_args.index("--path")
            if len(proj_args) > idx + 1:
                local_path = os.path.expanduser(proj_args[idx + 1])
                if not os.path.isdir(local_path):
                    print(f"  ❌ Directory not found: {local_path}")
                    return
                meta["local_path"] = local_path
                _save_project_meta(proj_name, meta)
                _add_ops_log(proj_name, "config", note=f"Local path set: {local_path}")
                print(f"  ✅ Local path set: {local_path}")
            else:
                if meta.get("local_path"):
                    print(f"  📁 Local path: {meta['local_path']}")
                else:
                    print("  ⚠️  No local path set")
                    print(f"     mpop project {proj_name} --path ~/path/to/project")
            return

        # --sync: local folder <-> brain sync
        if "--sync" in proj_args:
            idx = proj_args.index("--sync")
            # Specify path or use registered path
            if len(proj_args) > idx + 1 and not proj_args[idx + 1].startswith("--"):
                local_path = os.path.expanduser(proj_args[idx + 1])
            else:
                local_path = meta.get("local_path", "")

            if not local_path:
                print("  ⚠️  No path specified")
                print(f"     mpop project {proj_name} --sync ~/path/to/docs")
                print(f"     or set: mpop project {proj_name} --path ~/project")
                return

            if not os.path.isdir(local_path):
                print(f"  ❌ Directory not found: {local_path}")
                return

            # Find .md files recursively (all subdirs)
            import glob
            files = glob.glob(os.path.join(local_path, "**", "*.md"), recursive=True)
            # Exclude node_modules, .git, etc
            exclude = ['node_modules', '.git', 'venv', '__pycache__', '.venv', 'vendor', 'build', 'dist']
            files = [f for f in files if not any(ex in f for ex in exclude)]

            if not files:
                print(f"  ⚠️  No .md files found in {local_path}")
                return

            print(f"  📤 Syncing {len(files)} files to brain...")
            synced = 0
            for f in sorted(files):
                # Keep relative path (folder/file.md -> folder_file.md)
                rel_path = os.path.relpath(f, local_path)
                # Convert path separator to _ (flatten folder structure)
                fname = rel_path.replace(os.sep, "_")
                try:
                    with open(f, 'r') as fp:
                        content = fp.read()
                    if _brain_write(f"projects/{proj_name}/{fname}", content):
                        print(f"     ✓ {rel_path}")
                        synced += 1
                except Exception as e:
                    print(f"     ✗ {rel_path}: {e}")

            _add_ops_log(proj_name, "sync", note=f"{synced}files synced from {local_path}")
            print(f"\n  ✅ Synced {synced}/{len(files)} files")
            return

        # --sync-docs: quickly sync only docs from registered path
        if "--sync-docs" in proj_args:
            local_path = meta.get("local_path", "")
            if not local_path:
                print("  ⚠️  No local path registered")
                print(f"     Set first: mpop project {proj_name} --path ~/project")
                return

            docs_path = os.path.join(local_path, "docs")
            if not os.path.isdir(docs_path):
                docs_path = local_path

            import glob
            files = glob.glob(os.path.join(docs_path, "*.md"))

            if not files:
                print(f"  ⚠️  No .md files in {docs_path}")
                return

            synced = 0
            for f in sorted(files):
                fname = os.path.basename(f)
                try:
                    with open(f, 'r') as fp:
                        content = fp.read()
                    if _brain_write(f"projects/{proj_name}/{fname}", content):
                        synced += 1
                except Exception:
                    pass

            print(f"  ✅ Quick sync: {synced} docs")
            return

        # --export: output CHANGELOG
        if "--export" in proj_args:
            result = _brain_exec(f"cat {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
            if not result:
                print("  ⚠️ No history")
                return
            entries = []
            for l in result.strip().split('\n'):
                if l.strip():
                    try:
                        entries.append(json.loads(l))
                    except Exception:
                        pass
            by_date = {}
            for e in entries:
                d = e.get("ts", "")[:10]
                by_date.setdefault(d, []).append(e)
            print(f"# {proj_name} Changelog\n")
            for date in sorted(by_date.keys(), reverse=True):
                print(f"## {date}\n")
                for e in by_date[date]:
                    f = e.get("file", "")
                    n = e.get("note", "")
                    a = e.get("action", "")
                    print(f"- [{a}] {'`'+f+'`: ' if f else ''}{n}")
                print()
            return

        # --watch: real-time file watch and push (Push mode)
        if "--watch" in proj_args:
            local_path = meta.get("local_path", "")
            if not local_path:
                print("  ⚠️  No local path registered")
                print(f"     Set first: mpop project {proj_name} --path ~/project")
                return

            if not os.path.isdir(local_path):
                print(f"  ❌ Directory not found: {local_path}")
                return

            print(f"\n  👁️  WATCH MODE: {proj_name}")
            print("  " + "=" * 50)
            print(f"  Path: {local_path}")
            print(f"  Watching: **/*.md (recursive)")
            print(f"  Mode: Push (local → brain)")
            print(f"\n  Ctrl+C to stop\n")

            import time
            import glob

            exclude = ['node_modules', '.git', 'venv', '__pycache__', '.venv', 'vendor', 'build', 'dist']
            file_mtimes = {}

            # Initial scan
            files = glob.glob(os.path.join(local_path, "**", "*.md"), recursive=True)
            files = [f for f in files if not any(ex in f for ex in exclude)]
            for f in files:
                file_mtimes[f] = os.path.getmtime(f)
            print(f"  Tracking {len(files)} files...")

            try:
                while True:
                    time.sleep(2)  # Poll every 2 seconds
                    current_files = glob.glob(os.path.join(local_path, "**", "*.md"), recursive=True)
                    current_files = [f for f in current_files if not any(ex in f for ex in exclude)]

                    for f in current_files:
                        mtime = os.path.getmtime(f)
                        if f not in file_mtimes or mtime > file_mtimes[f]:
                            # File changed or new
                            rel_path = os.path.relpath(f, local_path)
                            fname = rel_path.replace(os.sep, "_")
                            try:
                                with open(f, 'r') as fp:
                                    content = fp.read()
                                if _brain_write(f"projects/{proj_name}/{fname}", content):
                                    ts = datetime.now().strftime("%H:%M:%S")
                                    print(f"  [{ts}] ✓ {rel_path}")
                                    _add_ops_log(proj_name, "sync_file", file=rel_path, note="auto-watch")
                            except Exception as e:
                                print(f"  ✗ {rel_path}: {e}")
                            file_mtimes[f] = mtime

                    # Check for deleted files
                    deleted = set(file_mtimes.keys()) - set(current_files)
                    for f in deleted:
                        del file_mtimes[f]

            except KeyboardInterrupt:
                print(f"\n  👋 Watch stopped. Synced files are on brain.")
            return

        # --pull: pull project files from server (Pull mode)
        if "--pull" in proj_args:
            idx = proj_args.index("--pull")
            # Target server (default: first registered server for project)
            servers = meta.get("servers", [])
            if len(proj_args) > idx + 1 and not proj_args[idx + 1].startswith("--"):
                target_server = proj_args[idx + 1]
            elif servers:
                target_server = servers[0]
            else:
                print("  ⚠️  No server specified")
                print(f"     mpop project {proj_name} --pull <server>")
                print(f"     or set: mpop project {proj_name} --servers worker2")
                return

            # Remote path (guess from project name or configured path)
            remote_path = meta.get("remote_path", f"~/{proj_name}")
            if "--from" in proj_args:
                from_idx = proj_args.index("--from")
                if len(proj_args) > from_idx + 1:
                    remote_path = proj_args[from_idx + 1]

            print(f"\n  📥 PULL MODE: {proj_name}")
            print("  " + "=" * 50)
            print(f"  From: {target_server}:{remote_path}")
            print(f"  To: brain/projects/{proj_name}/")

            # Get docs folder from remote server via vssh
            docs_path = f"{remote_path}/docs"

            # Get remote file list
            cmd = f"ls -1 {docs_path}/*.md 2>/dev/null || ls -1 {remote_path}/*.md 2>/dev/null"
            result = vssh_exec(target_server, cmd, timeout=15)

            if not result or not result.strip():
                print(f"  ⚠️  No .md files found on {target_server}")
                return

            files = [f.strip() for f in result.strip().split('\n') if f.strip().endswith('.md')]
            print(f"  Found {len(files)} files")

            synced = 0
            for f in files:
                fname = os.path.basename(f)
                # Get file content
                content = vssh_exec(target_server, f"cat {f}", timeout=30)
                if content:
                    if _brain_write(f"projects/{proj_name}/{fname}", content):
                        print(f"     ✓ {fname}")
                        synced += 1
                    else:
                        print(f"     ✗ {fname}: write failed")
                else:
                    print(f"     ✗ {fname}: read failed")

            _add_ops_log(proj_name, "pull", note=f"{synced}files from {target_server}")
            print(f"\n  ✅ Pulled {synced}/{len(files)} files from {target_server}")
            return

        # --remote-path: set remote path (for pull)
        if "--remote-path" in proj_args:
            idx = proj_args.index("--remote-path")
            if len(proj_args) > idx + 1:
                remote_path = proj_args[idx + 1]
                meta["remote_path"] = remote_path
                _save_project_meta(proj_name, meta)
                print(f"  ✅ Remote path set: {remote_path}")
            else:
                print(f"  Current: {meta.get('remote_path', '(not set)')}")
            return

        # --ai: output context for Claude (copy-paste)
        if "--ai" in proj_args:
            ctx = meta.get("context", {})
            print(f"\n=== {proj_name} AI Context ===")
            print(f"Project: {proj_name}")
            print(f"Description: {meta.get('description', '')}")
            print(f"Servers: {', '.join(meta.get('servers', []))}")
            print(f"Summary: {ctx.get('summary', '')}")
            print(f"Recent Work: {ctx.get('recent_work', '')}")
            if ctx.get("issues"):
                print(f"Open Issues:")
                for i in ctx["issues"]:
                    print(f"  - {i.get('desc', '')}")
            if ctx.get("resolved"):
                print(f"Recently Resolved:")
                for r in ctx["resolved"][-3:]:
                    print(f"  - {r.get('resolution', '')}")
            # Recent operation log
            result = _brain_exec(f"tail -5 {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
            if result:
                print(f"Recent Ops:")
                for line in result.strip().split('\n'):
                    if line.strip():
                        try:
                            e = json.loads(line)
                            print(f"  - {e.get('ts', '')[:10]} {e.get('action', '')}: {e.get('note', '')[:40]}")
                        except Exception:
                            pass
            print("=" * 40)
            return

        # Default: show project status
        print(f"\n  📂 {proj_name}")
        print("  " + "=" * 60)
        print(f"  Description: {meta.get('description', '(none)')}")
        print(f"  Servers:     {', '.join(meta.get('servers', [])) or '(none)'}")
        print(f"  Created:     {meta.get('created', '')}")

        ctx = meta.get("context", {})
        if ctx.get("summary"):
            print(f"\n  🧠 AI Context:")
            print(f"     {ctx['summary']}")
        if ctx.get("recent_work"):
            print(f"     Recent: {ctx['recent_work']}")
        if ctx.get("issues"):
            print(f"\n  ⚠️  Open Issues:")
            for i in ctx["issues"][-3:]:
                print(f"     - {i.get('desc', '')}")
        if ctx.get("resolved"):
            print(f"\n  ✅ Recently Resolved:")
            for r in ctx["resolved"][-3:]:
                print(f"     - {r.get('resolution', '')}")

        # Recent operation log
        result = _brain_exec(f"tail -5 {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
        if result:
            print(f"\n  📜 Recent Ops:")
            for line in result.strip().split('\n'):
                if line.strip():
                    try:
                        e = json.loads(line)
                        ts = e.get("ts", "")[:10]
                        action = e.get("action", "")
                        note = e.get("note", "")[:40]
                        print(f"     {ts} {action}: {note}")
                    except Exception:
                        pass

        print(f"\n  Commands:")
        print(f"     --log \"msg\"      Operation note")
        print(f"     --work \"work\"    Work record")
        print(f"     --issue \"issue\"   Register issue")
        print(f"     --resolve \"resolve\" Resolve issue")
        print(f"     --history        Full history")
        print(f"     --ai             Claude context")
        print(f"     --sync [path]    Folder sync (push)")
        print(f"     --watch          Real-time watch (push)")
        print(f"     --pull           Pull from server")
        print()
        return

    # Help
    print("""
  📂 PROJECT - Operations log + AI context + docs

  Project management:
    mpop project                    Project list
    mpop project new <name> [desc]  New project
    mpop project <name>             Project status

  Operation logs:
    mpop project <name> --log "memo"     Operation note add
    mpop project <name> --work "work"    work log content
    mpop project <name> --deploy v1,node2   Deploy log
    mpop project <name> --restart svc    Restart log
    mpop project <name> --change <file> "desc"  File change log
    mpop project <name> --history        Full history view
    mpop project <name> --export         CHANGELOG output

  Issue management:
    mpop project <name> --issue "issue"   Register issue
    mpop project <name> --resolve "resolve" Resolve issue

  AI context:
    mpop project <name> --context "summary" Project summary set
    mpop project <name> --servers v1,node2  Related servers set
    mpop project <name> --ai             Claudecontext for output

  TODO management:
    mpop project --active                Active projects view
    mpop project <name> --activate       Activate this project
    mpop project <name> --todo "TODO"    TODO add
    mpop project <name> --done [n]       TODO done (n: number, default 1)
    mpop project <name> --next           Next TODO view

  Docs management:
    mpop project <name> --doc <file>     docs view
    mpop project <name> --write <file>   Write docs (stdin)
    mpop project <name> --files          File list

  Folder sync (Push: local→brain, Pull: server→brain):
    mpop project <name> --path <folder>  Register local path
    mpop project <name> --sync [folder]  Full sync (**/*.md)
    mpop project <name> --sync-docs      Quick sync (for git hook)
    mpop project <name> --watch          Real-time watch Push (2s interval)
    mpop project <name> --pull [server]  Pull from server
    mpop project <name> --remote-path    Remote path set
""")


# ==================== Doc (Document Browser) ====================

def cmd_doc(args):
    """Doc - Simple document browser for projects"""

    # Help
    if args and args[0] in ("help", "-h", "--help"):
        print("""
  📄 DOC - Document Browser

  USAGE:
    mpop doc                     All projects list
    mpop doc <project>           Project file list
    mpop doc <project>/<file>    File content view
    mpop doc --recent            Recently modified docs (5 items)
    mpop doc <project> --grep <term>   search
    mpop doc <project> -i        Interactive selection (fzf)

  EXAMPLES:
    mpop doc tokmor              # tokmor project doc list
    mpop doc tokmor/STATUS.md    # View STATUS.md content
    mpop doc tokmor --grep BERT  # Search BERT
""")
        return

    # No args: show all projects with doc counts
    if not args:
        print("\n  📚 PROJECTS")
        print("  " + "=" * 50)
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
        if result:
            for proj in result.strip().split('\n'):
                if proj.strip():
                    # Count docs
                    count = _brain_exec(f"find {BRAIN_PATH}/projects/{proj.strip()} -name '*.md' -type f 2>/dev/null | wc -l")
                    count = count.strip() if count else "0"
                    print(f"  📂 {proj.strip():20} ({count} docs)")
            print()
            print("  View: mpop doc <project>")
        else:
            print("  (no projects)")
        return

    # --recent: recently modified docs
    if args[0] == "--recent":
        print("\n  📄 RECENT DOCUMENTS (recent)")
        print("  " + "=" * 50)
        result = _brain_exec(f"find {BRAIN_PATH}/projects -name '*.md' -type f -mtime -7 -exec ls -lt {{}} + 2>/dev/null | head -10")
        if result:
            for line in result.strip().split('\n'):
                if line.strip():
                    parts = line.split()
                    if len(parts) >= 9:
                        fname = parts[-1]
                        date = " ".join(parts[5:8])
                        # Extract project/file
                        if "/projects/" in fname:
                            rel = fname.split("/projects/")[1]
                            print(f"  {date:12}  {rel}")
        else:
            print("  (no recent documents)")
        return

    # Parse project/file
    target = args[0]
    rest_args = args[1:] if len(args) > 1 else []

    if "/" in target:
        # project/file.md - show file content
        parts = target.split("/", 1)
        proj_name = parts[0]
        file_name = parts[1]

        # Try multiple paths
        paths_to_try = [
            f"{BRAIN_PATH}/projects/{proj_name}/docs/{file_name}",
            f"{BRAIN_PATH}/projects/{proj_name}/{file_name}",
        ]

        content = None
        for p in paths_to_try:
            result = _brain_exec(f"cat '{p}' 2>/dev/null")
            if result:
                content = result
                break

        if content:
            print(f"\n  📄 {proj_name}/{file_name}")
            print("  " + "=" * 60)
            print(content)
        else:
            print(f"  ❌ File not found: {target}")
            # Show available files
            result = _brain_exec(f"find {BRAIN_PATH}/projects/{proj_name} -name '*.md' -type f 2>/dev/null")
            if result:
                print(f"\n  Available files in {proj_name}:")
                for f in result.strip().split('\n')[:10]:
                    if f.strip():
                        rel = f.split(f"/{proj_name}/")[-1] if f"/{proj_name}/" in f else f.split("/")[-1]
                        print(f"    {rel}")
        return

    # Project name only - show files
    proj_name = target

    # --grep: search
    if "--grep" in rest_args or "-g" in rest_args:
        try:
            idx = rest_args.index("--grep") if "--grep" in rest_args else rest_args.index("-g")
            term = rest_args[idx + 1] if idx + 1 < len(rest_args) else ""
        except Exception:
            term = ""

        if term:
            print(f"\n  🔍 Searching '{term}' in {proj_name}")
            print("  " + "=" * 50)
            result = _brain_exec(f"grep -r -l -i '{term}' {BRAIN_PATH}/projects/{proj_name} 2>/dev/null | head -20")
            if result:
                for f in result.strip().split('\n'):
                    if f.strip():
                        rel = f.split(f"/{proj_name}/")[-1]
                        # Show matching line
                        match = _brain_exec(f"grep -i -local '{term}' '{f}' 2>/dev/null")
                        match_preview = match.strip()[:60] if match else ""
                        print(f"  {rel}")
                        if match_preview:
                            print(f"    └─ {match_preview}...")
            else:
                print(f"  No matches for '{term}'")
        return

    # -i: interactive (fzf)
    if "-i" in rest_args:
        # Check if fzf is available
        fzf_check = _brain_exec("which fzf 2>/dev/null")
        if not fzf_check:
            print("  ⚠️  fzf not installed. Install: brew install fzf")
            print("  Falling back to list mode...")
            rest_args = []  # Fall through to list
        else:
            # Use fzf for selection
            result = _brain_exec(f"find {BRAIN_PATH}/projects/{proj_name} -name '*.md' -type f 2>/dev/null")
            if result:
                files = [f.strip() for f in result.strip().split('\n') if f.strip()]
                if files:
                    # Create temp file with choices
                    choices = []
                    for f in files:
                        rel = f.split(f"/{proj_name}/")[-1]
                        choices.append(rel)

                    # Simple selection without fzf (interactive terminal needed)
                    print(f"\n  📂 {proj_name} - Select file:")
                    print("  " + "-" * 40)
                    for i, c in enumerate(choices[:15]):
                        print(f"  [{i}] {c}")
                    print()
                    print("  Usage: mpop doc {}/{}<filename>".format(proj_name, "" if not choices else choices[0].rsplit("/",1)[0]+"/" if "/" in choices[0] else ""))
            return

    # Default: show project files
    print(f"\n  📂 PROJECT: {proj_name}")
    print("  " + "=" * 50)

    # Check if project exists
    result = _brain_exec(f"ls -d {BRAIN_PATH}/projects/{proj_name} 2>/dev/null")
    if not result:
        print(f"  ❌ Project not found: {proj_name}")
        # Show available projects
        result = _brain_exec(f"ls -1 {BRAIN_PATH}/projects 2>/dev/null")
        if result:
            print("\n  Available projects:")
            for p in result.strip().split('\n')[:10]:
                if p.strip():
                    print(f"    {p.strip()}")
        return

    # List files
    result = _brain_exec(f"find {BRAIN_PATH}/projects/{proj_name} -name '*.md' -type f 2>/dev/null | head -30")
    if result:
        files = {}
        for f in result.strip().split('\n'):
            if f.strip():
                rel = f.split(f"/{proj_name}/")[-1]
                # Group by directory
                if "/" in rel:
                    dir_name = rel.rsplit("/", 1)[0]
                    file_name = rel.rsplit("/", 1)[1]
                else:
                    dir_name = ""
                    file_name = rel
                if dir_name not in files:
                    files[dir_name] = []
                files[dir_name].append(file_name)

        for dir_name in sorted(files.keys()):
            if dir_name:
                print(f"\n  📁 {dir_name}/")
            for fname in sorted(files[dir_name]):
                prefix = "    " if dir_name else "  "
                full_path = f"{dir_name}/{fname}" if dir_name else fname
                print(f"{prefix}📄 {fname}")

        print()
        print(f"  View: mpop doc {proj_name}/<file>")
    else:
        print("  (no markdown files)")

    # Also show ops.jsonl if exists
    ops = _brain_exec(f"wc -l < {BRAIN_PATH}/projects/{proj_name}/ops.jsonl 2>/dev/null")
    if ops and ops.strip().isdigit() and int(ops.strip()) > 0:
        print(f"\n  📜 ops.jsonl: {ops.strip()} entries")


# ==================== Session (Work Tracking) ====================

def cmd_session(args):
    """Session - Track work sessions across AI and nodes"""

    hostname = get_hostname()
    my_name = get_my_name()
    now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
    today = datetime.now().strftime("%Y-%m-%d")

    if not args or args[0] == "status":
        # Show current session
        ctx = _brain_read("context.json")
        if not ctx:
            print("\n  ⚠️  Brain not initialized. Run: mpop brain init")
            return

        try:
            data = json.loads(ctx)
            active = data.get("active_sessions", {})

            print("\n  📝 ACTIVE SESSIONS")
            print("  " + "=" * 50)

            if not active:
                print("  (no active sessions)")
            else:
                for sid, sess in active.items():
                    started = sess.get("started", "")[:16].replace("T", " ")
                    node = sess.get("node", "?")
                    desc = sess.get("description", "")[:40]
                    notes = len(sess.get("notes", []))
                    print(f"  [{sid}] {node} - {desc}")
                    print(f"         Started: {started}, Notes: {notes}")

            print()
        except Exception:
            print("\n  ⚠️  Failed to parse context.json")
        return

    subcmd = args[0]

    if subcmd == "start":
        # Start new session
        description = " ".join(args[1:]) if len(args) > 1 else "work session"

        # Generate session ID
        import random
        sid = f"s{random.randint(1000, 9999)}"

        # Read current context
        ctx = _brain_read("context.json")
        if not ctx:
            print("\n  ⚠️  Brain not initialized. Run: mpop brain init")
            return

        try:
            data = json.loads(ctx)
        except Exception:
            data = {}

        # Add session
        if "active_sessions" not in data:
            data["active_sessions"] = {}

        data["active_sessions"][sid] = {
            "description": description,
            "node": my_name,
            "host": hostname,
            "started": now,
            "notes": []
        }
        data["updated"] = now

        # Write back
        if _brain_write("context.json", json.dumps(data, indent=2, ensure_ascii=False)):
            print(f"\n  ✅ Session started: {sid}")
            print(f"     {description}")
            print(f"\n  Add notes: mpop session note \"message\"")
            print(f"  End:       mpop session end")

            # Also log to sessions history
            entry = json.dumps({
                "ts": now,
                "type": "start",
                "sid": sid,
                "node": my_name,
                "description": description
            }, ensure_ascii=False)
            _brain_append(f"sessions/{today}.jsonl", entry)
        else:
            print("\n  ❌ Failed to start session")
        return

    elif subcmd == "note":
        # Add note to current session
        if len(args) < 2:
            print("\n  Usage: mpop session note \"message\"")
            return

        message = " ".join(args[1:])

        # Find my session
        ctx = _brain_read("context.json")
        if not ctx:
            print("\n  ⚠️  Brain not initialized")
            return

        try:
            data = json.loads(ctx)
            active = data.get("active_sessions", {})

            # Find session on this node
            my_session = None
            my_sid = None
            for sid, sess in active.items():
                if sess.get("node") == my_name:
                    my_session = sess
                    my_sid = sid
                    break

            if not my_session:
                print(f"\n  ⚠️  No active session on {my_name}")
                print(f"     Start one: mpop session start \"description\"")
                return

            # Add note
            my_session["notes"].append({
                "ts": now,
                "message": message
            })
            data["updated"] = now

            if _brain_write("context.json", json.dumps(data, indent=2, ensure_ascii=False)):
                print(f"  ✓ Note added to session {my_sid}")

                # Also log
                entry = json.dumps({
                    "ts": now,
                    "type": "note",
                    "sid": my_sid,
                    "node": my_name,
                    "message": message
                }, ensure_ascii=False)
                _brain_append(f"sessions/{today}.jsonl", entry)
            else:
                print("\n  ❌ Failed to add note")

        except Exception as e:
            print(f"\n  ❌ Error: {e}")
        return

    elif subcmd == "end":
        # End current session
        ctx = _brain_read("context.json")
        if not ctx:
            print("\n  ⚠️  Brain not initialized")
            return

        try:
            data = json.loads(ctx)
            active = data.get("active_sessions", {})

            # Find session on this node
            my_sid = None
            for sid, sess in active.items():
                if sess.get("node") == my_name:
                    my_sid = sid
                    break

            if not my_sid:
                print(f"\n  ⚠️  No active session on {my_name}")
                return

            # Get session info before removing
            sess = active[my_sid]
            desc = sess.get("description", "")
            notes_count = len(sess.get("notes", []))

            # Remove session
            del active[my_sid]
            data["updated"] = now

            if _brain_write("context.json", json.dumps(data, indent=2, ensure_ascii=False)):
                print(f"\n  ✅ Session ended: {my_sid}")
                print(f"     {desc}")
                print(f"     Notes: {notes_count}")

                # Log
                entry = json.dumps({
                    "ts": now,
                    "type": "end",
                    "sid": my_sid,
                    "node": my_name,
                    "description": desc,
                    "notes_count": notes_count
                }, ensure_ascii=False)
                _brain_append(f"sessions/{today}.jsonl", entry)
            else:
                print("\n  ❌ Failed to end session")

        except Exception as e:
            print(f"\n  ❌ Error: {e}")
        return

    elif subcmd == "history":
        # Show session history
        days = 7
        if len(args) > 1:
            try:
                days = int(args[1])
            except Exception:
                pass

        print(f"\n  📜 SESSION HISTORY (last {days} days)")
        print("  " + "=" * 50)

        from datetime import timedelta
        found = False

        for i in range(days):
            date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
            content = _brain_read(f"sessions/{date}.jsonl")
            if content:
                if not found:
                    found = True
                print(f"\n  {date}:")
                for line in content.strip().split('\n'):
                    if line.strip():
                        try:
                            entry = json.loads(line)
                            ts = entry.get("ts", "")[-8:-1]  # HH:MM:SS
                            typ = entry.get("type", "")
                            node = entry.get("node", "")
                            if typ == "start":
                                desc = entry.get("description", "")[:30]
                                print(f"    {ts}  ▶ {node}: {desc}")
                            elif typ == "note":
                                msg = entry.get("message", "")[:40]
                                print(f"    {ts}    📝 {msg}")
                            elif typ == "end":
                                print(f"    {ts}  ⏹ {node}: Session exit")
                        except Exception:
                            pass

        if not found:
            print("  (no sessions found)")
        print()
        return

    elif subcmd == "show" and len(args) > 1:
        # Show specific session details
        target_sid = args[1]

        ctx = _brain_read("context.json")
        if ctx:
            try:
                data = json.loads(ctx)
                active = data.get("active_sessions", {})
                if target_sid in active:
                    sess = active[target_sid]
                    print(f"\n  📝 SESSION: {target_sid}")
                    print("  " + "=" * 50)
                    print(f"  Description: {sess.get('description', '')}")
                    print(f"  Node: {sess.get('node', '')}")
                    print(f"  Started: {sess.get('started', '')}")
                    print(f"\n  Notes:")
                    for note in sess.get("notes", []):
                        ts = note.get("ts", "")[-8:-1]
                        msg = note.get("message", "")
                        print(f"    {ts}  {msg}")
                    print()
                    return
            except Exception:
                pass

        print(f"\n  ⚠️  Session not found: {target_sid}")
        return

    else:
        print(f"""
  📝 SESSION - Work Tracking

  Usage:
    mpop session                Show active sessions
    mpop session start "desc"   Start new session
    mpop session note "msg"     Add note to current session
    mpop session end            End current session
    mpop session show <sid>     Show session details
    mpop session history        Show session history (7 days)
    mpop session history 30     Show session history (30 days)
""")


# ==================== Voice (Audio News) ====================

VOICE_LLM_HOST = _get_config("voice_llm_host", _get_config("ollama_host", "localhost:11434").split(":")[0])  # configure in ~/.mpop/config.json
VOICE_LLM_MODEL = "gemma3:4b"  # Fast model for voice scripts

def _voice_clean_for_tts(text, lang="en"):
    """Prepare text for TTS. English only -- returns text unchanged."""
    return text

# Backward-compatible alias
_voice_clean_korean = _voice_clean_for_tts

def cmd_voice(args):
    """Voice News - Audio briefing of server/network status"""

    # Parse options
    lang = "en"  # Default: English
    play = True
    save_path = None
    target_server = None  # Specific server for detailed report

    servers = get_servers()

    desktop_mode = False
    period = "today"  # today, week, month, 6month, year
    for i, arg in enumerate(args):
        if arg in ("--en", "--english"):
            lang = "en"
        elif arg in ("--ko", "--korean"):
            lang = "ko"
        elif arg == "--no-play":
            play = False
        elif arg == "--save" and i + 1 < len(args):
            save_path = args[i + 1]
        elif arg in ("me", "local", "desktop", "mac", "pc"):
            desktop_mode = True
        elif arg in ("--week", "--weekly", "week"):
            period = "week"
        elif arg in ("--month", "--monthly", "month"):
            period = "month"
        elif arg in ("--6month", "--6months", "6month"):
            period = "6month"
        elif arg in ("--year", "--yearly", "year"):
            period = "year"
        elif arg in servers:
            target_server = arg

    if args and args[0] == "help":
        print("""
  📻 VOICE NEWS - Audio briefing of server/desktop status

  USAGE:
    mpop voice              Network overview (all servers)
    mpop voice v1           Detailed report for v1 server
    mpop voice me           Desktop report (this Mac/PC)
    mpop voice --ko         Korean briefing
    mpop voice --no-play    Generate audio only
    mpop voice --save FILE  Save to file

  DESKTOP REPORT (me/local/desktop):
    - CPU, memory, disk usage
    - Running apps and top processes
    - Network connections and open ports
    - Recent file changes
    - Battery status (if laptop)
    - Security: open ports, suspicious processes

  SERVER REPORT:
    - System status, security, logs
    - Docker containers, services

  EXAMPLES:
    mpop voice              # All servers overview
    mpop voice me           # This Mac/PC status
    mpop voice me --ko      # Korean desktop report
    mpop voice v1 --ko      # v1 server in Korean
""")
        return

    # Desktop mode (local Mac/PC)
    if desktop_mode:
        period_names = {
            "today": "Today",
            "week": "This week",
            "month": "This month",
            "6month": "6 months",
            "year": "1 year"
        }
        period_title = period_names.get(period, "Today")
        print(f"  📻 DESKTOP REPORT: {period_title} report")
        print()
        print("  [1/4] Collecting desktop status...", end=" ", flush=True)
        desktop_data = _voice_collect_desktop(period=period)
        print("✅")

        print("  [2/4] Writing desktop report (LLM)...", end=" ", flush=True)
        script = _voice_generate_desktop_script(desktop_data, lang, period=period)
    # Server-specific detailed report
    elif target_server:
        print(f"  📻 SERVER REPORT: {target_server}")
        print()
        print("  [1/4] Collecting server details...", end=" ", flush=True)
        server_data = _voice_collect_server_detail(target_server)
        if not server_data:
            print("❌")
            print(f"  ⚠️ Cannot collect data from {target_server}")
            return
        print("✅")

        print("  [2/4] Writing detailed report (LLM)...", end=" ", flush=True)
        script = _voice_generate_server_script(server_data, lang)
    else:
        # Network overview
        print("  📻 VOICE NEWS - Network Overview")
        print()

        print("  [1/4] Collecting server status...", end=" ", flush=True)
        status_data = _voice_collect_status()
        print("✅")

        print("  [2/4] Writing news script (LLM)...", end=" ", flush=True)
        script = _voice_generate_script(status_data, lang)

    if not script:
        print("❌")
        print("  ⚠️ Failed to generate script.")
        return
    print("✅")

    # Step 3: Generate audio
    print("  [3/4] Generating audio (Edge TTS)...", end=" ", flush=True)
    audio_path = _voice_generate_audio(script, lang, save_path)

    if not audio_path:
        print("❌")
        print("  ⚠️ Failed to generate audio.")
        return
    print("✅")

    # Step 4: Play audio
    if play:
        print("  [4/4] Playing audio...", end=" ", flush=True)
        _voice_play_audio(audio_path)
        print("✅")
    else:
        print(f"  [4/4] Audio saved: {audio_path}")

    print()
    print("  📜 Script:")
    print("  " + "-" * 50)
    for line in script.split('\n'):
        if line.strip():
            print(f"  {line}")
    print()

def _voice_collect_desktop(period="today"):
    """Collect local desktop (Mac/Windows/Linux) system info"""
    import platform

    # Period to days mapping
    period_days = {
        "today": 1,
        "week": 7,
        "month": 30,
        "6month": 180,
        "year": 365
    }
    days = period_days.get(period, 1)

    # Time-based greeting
    hour = datetime.now().hour
    if 5 <= hour < 12:
        time_greeting = "Good morning"
    elif 12 <= hour < 18:
        time_greeting = "Good afternoon"
    elif 18 <= hour < 22:
        time_greeting = "Good evening"
    else:
        time_greeting = "Late night"

    data = {
        "hostname": platform.node(),
        "os": platform.system(),
        "os_version": platform.release(),
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "time_greeting": time_greeting,
        "hour": hour,
        "period": period,
        "period_days": days,
        "cpu": {},
        "memory": {},
        "disk": {},
        "battery": {},
        "network": {},
        "apps": [],
        "top_processes": [],
        "security": {},
        "recent_files": [],
        "calendar": []
    }

    is_mac = platform.system() == "Darwin"
    is_linux = platform.system() == "Linux"

    try:
        # CPU info
        if is_mac:
            result = subprocess.run(["sysctl", "-n", "hw.ncpu"], capture_output=True, text=True, timeout=5)
            data["cpu"]["cores"] = result.stdout.strip()
            result = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5)
            data["cpu"]["model"] = result.stdout.strip()[:50]
            # Load average
            result = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=5)
            load = result.stdout.strip().replace("{", "").replace("}", "").split()
            if len(load) >= 3:
                data["cpu"]["load_1m"] = load[0]
                data["cpu"]["load_5m"] = load[1]
                data["cpu"]["load_15m"] = load[2]
        else:
            result = subprocess.run(["nproc"], capture_output=True, text=True, timeout=5)
            data["cpu"]["cores"] = result.stdout.strip()
            result = subprocess.run(["cat", "/proc/loadavg"], capture_output=True, text=True, timeout=5)
            load = result.stdout.strip().split()
            if len(load) >= 3:
                data["cpu"]["load_1m"] = load[0]
                data["cpu"]["load_5m"] = load[1]
                data["cpu"]["load_15m"] = load[2]
    except Exception:
        pass

    try:
        # Memory
        if is_mac:
            result = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5)
            lines = result.stdout.split("\n")
            page_size = 16384  # typical
            free = wired = active = inactive = 0
            for line in lines:
                if "page size" in line.lower():
                    page_size = int(line.split()[-2])
                elif "Pages free:" in line:
                    free = int(line.split()[-1].replace(".", "")) * page_size
                elif "Pages wired" in line:
                    wired = int(line.split()[-1].replace(".", "")) * page_size
                elif "Pages active:" in line:
                    active = int(line.split()[-1].replace(".", "")) * page_size
                elif "Pages inactive:" in line:
                    inactive = int(line.split()[-1].replace(".", "")) * page_size
            total_result = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5)
            total = int(total_result.stdout.strip())
            used = total - free - inactive
            data["memory"]["total_gb"] = f"{total / (1024**3):.1f}"
            data["memory"]["used_gb"] = f"{used / (1024**3):.1f}"
            data["memory"]["percent"] = f"{(used / total) * 100:.0f}%"
        else:
            result = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=5)
            lines = result.stdout.split("\n")
            for line in lines:
                if "Mem:" in line:
                    parts = line.split()
                    total = int(parts[1])
                    used = int(parts[2])
                    data["memory"]["total_gb"] = f"{total / 1024:.1f}"
                    data["memory"]["used_gb"] = f"{used / 1024:.1f}"
                    data["memory"]["percent"] = f"{(used / total) * 100:.0f}%"
    except Exception:
        pass

    try:
        # Disk - check Data volume on Mac (actual user data)
        if is_mac:
            result = subprocess.run(["df", "-h", "/System/Volumes/Data"], capture_output=True, text=True, timeout=5)
        else:
            result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5)
        lines = result.stdout.strip().split("\n")
        if len(lines) >= 2:
            parts = lines[1].split()
            if len(parts) >= 5:
                data["disk"]["total"] = parts[1]
                data["disk"]["used"] = parts[2]
                data["disk"]["available"] = parts[3]
                data["disk"]["percent"] = parts[4]
    except Exception:
        pass

    try:
        # Battery (Mac)
        if is_mac:
            result = subprocess.run(["pmset", "-g", "batt"], capture_output=True, text=True, timeout=5)
            output = result.stdout
            if "InternalBattery" in output:
                # Extract percentage
                import re
                match = re.search(r'(\d+)%', output)
                if match:
                    data["battery"]["percent"] = match.group(1)
                if "charging" in output.lower():
                    data["battery"]["status"] = "Charging"
                elif "discharging" in output.lower():
                    data["battery"]["status"] = "On Battery"
                else:
                    data["battery"]["status"] = "Plugged In"
    except Exception:
        pass

    try:
        # Top processes
        if is_mac:
            result = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5)
        else:
            result = subprocess.run(["ps", "aux", "--sort=-%cpu"], capture_output=True, text=True, timeout=5)
        lines = result.stdout.strip().split("\n")[1:6]  # Top 5
        for line in lines:
            parts = line.split()
            if len(parts) >= 11:
                # Get process name (last part of path, clean up)
                cmd = " ".join(parts[10:])
                name = cmd.split("/")[-1].split()[0]  # Get basename
                # Skip system processes
                if name.startswith("-") or name.startswith("["):
                    continue
                # Clean common prefixes
                name = name.replace("com.apple.", "").replace(".app", "")
                data["top_processes"].append({
                    "name": name[:25],
                    "cpu": parts[2],
                    "mem": parts[3]
                })
    except Exception:
        pass

    try:
        # Running apps (Mac only - GUI apps)
        if is_mac:
            result = subprocess.run(
                ["osascript", "-e", 'tell application "System Events" to get name of every process whose background only is false'],
                capture_output=True, text=True, timeout=10
            )
            apps = result.stdout.strip().split(", ")
            data["apps"] = [a.strip() for a in apps if a.strip()][:15]
    except Exception:
        pass

    try:
        # Network connections
        if is_mac:
            result = subprocess.run(["netstat", "-an"], capture_output=True, text=True, timeout=5)
            established = len([l for l in result.stdout.split("\n") if "ESTABLISHED" in l])
            listening = len([l for l in result.stdout.split("\n") if "LISTEN" in l])
            data["network"]["established"] = established
            data["network"]["listening"] = listening
        else:
            result = subprocess.run(["ss", "-s"], capture_output=True, text=True, timeout=5)
            data["network"]["summary"] = result.stdout.strip()[:200]
    except Exception:
        pass

    try:
        # Open ports (security check)
        if is_mac:
            result = subprocess.run(["lsof", "-i", "-P", "-n"], capture_output=True, text=True, timeout=10)
            ports = set()
            outgoing = {}  # app -> list of destinations
            for line in result.stdout.split("\n"):
                if "LISTEN" in line:
                    parts = line.split()
                    for p in parts:
                        if ":" in p and "*:" in p:
                            port = p.split(":")[-1]
                            if port.isdigit():
                                ports.add(port)
                elif "ESTABLISHED" in line:
                    parts = line.split()
                    if len(parts) >= 9:
                        app = parts[0]
                        # Get destination (last part with ->)
                        for p in parts:
                            if "->" in p:
                                dest = p.split("->")[-1]
                                if app not in outgoing:
                                    outgoing[app] = set()
                                outgoing[app].add(dest[:40])
            data["security"]["open_ports"] = sorted(list(ports), key=lambda x: int(x))[:10]
            # Categorize outgoing connections
            google_apps = []
            apple_apps = []
            other_apps = []
            for app, dests in outgoing.items():
                for dest in dests:
                    if "google" in dest.lower() or "1e100" in dest or "142.250" in dest or "172.217" in dest:
                        if app not in google_apps:
                            google_apps.append(app)
                    elif "apple" in dest.lower() or "icloud" in dest.lower() or "17.253" in dest:
                        if app not in apple_apps:
                            apple_apps.append(app)
                    else:
                        if app not in other_apps:
                            other_apps.append(app)
            data["security"]["google_connected"] = google_apps[:5]
            data["security"]["apple_connected"] = apple_apps[:5]
            data["security"]["other_connected"] = other_apps[:8]
            # Summary
            outgoing_summary = []
            for app, dests in sorted(outgoing.items(), key=lambda x: -len(x[1]))[:5]:
                outgoing_summary.append(f"{app}: {len(dests)}items")
            data["security"]["outgoing"] = outgoing_summary
    except Exception:
        pass

    try:
        # Recent downloads (last 3)
        if is_mac:
            downloads_path = os.path.expanduser("~/Downloads")
            files = []
            for f in os.listdir(downloads_path):
                full_path = os.path.join(downloads_path, f)
                if os.path.isfile(full_path):
                    mtime = os.path.getmtime(full_path)
                    files.append((f, mtime))
            files.sort(key=lambda x: x[1], reverse=True)
            data["recent_downloads"] = [f[0][:40] for f in files[:3]]
    except Exception:
        pass

    try:
        # Recent files worked on (Documents, Desktop - modified in last 3 hours)
        if is_mac:
            recent_files = []
            three_hours_ago = time.time() - (3 * 60 * 60)
            for folder in ["~/Documents", "~/Desktop"]:
                folder_path = os.path.expanduser(folder)
                if os.path.exists(folder_path):
                    for f in os.listdir(folder_path):
                        if f.startswith("."):
                            continue
                        full_path = os.path.join(folder_path, f)
                        if os.path.isfile(full_path):
                            mtime = os.path.getmtime(full_path)
                            if mtime > three_hours_ago:
                                recent_files.append((f, mtime))
            recent_files.sort(key=lambda x: x[1], reverse=True)
            data["recent_files"] = [f[0][:35] for f in recent_files[:5]]
    except Exception:
        pass

    try:
        # Uptime
        if is_mac:
            result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5)
            uptime_str = result.stdout.strip()
            # Extract uptime part
            if "up" in uptime_str:
                up_part = uptime_str.split("up")[1].split(",")[0].strip()
                data["uptime"] = up_part
    except Exception:
        pass

    # Work tracking - project activity
    data["projects"] = {"active": [], "stale": [], "summary": {}}
    try:
        meshpop_path = os.path.expanduser("~/MeshPOP")
        if os.path.exists(meshpop_path):
            period_cutoff = time.time() - (days * 24 * 60 * 60)
            thirty_days_ago = time.time() - (30 * 24 * 60 * 60)
            total_files = 0
            total_projects = 0

            for item in os.listdir(meshpop_path):
                proj_path = os.path.join(meshpop_path, item)
                if os.path.isdir(proj_path) and not item.startswith('.'):
                    # Count recent files
                    recent_count = 0
                    latest_time = 0
                    for root, dirs, files in os.walk(proj_path):
                        dirs[:] = [d for d in dirs if d not in ['.git', 'node_modules', '__pycache__', 'build']]
                        for f in files:
                            if not f.startswith('.'):
                                fpath = os.path.join(root, f)
                                try:
                                    mtime = os.path.getmtime(fpath)
                                    if mtime > latest_time:
                                        latest_time = mtime
                                    if mtime > period_cutoff:
                                        recent_count += 1
                                except Exception:
                                    pass

                    if recent_count > 0:
                        data["projects"]["active"].append({"name": item, "files": recent_count})
                        total_files += recent_count
                        total_projects += 1
                    elif latest_time > 0 and latest_time < thirty_days_ago:
                        days_ago = int((time.time() - latest_time) / (24 * 60 * 60))
                        data["projects"]["stale"].append({"name": item, "days": days_ago})

            # Sort and summary
            data["projects"]["active"].sort(key=lambda x: -x["files"])
            data["projects"]["stale"].sort(key=lambda x: -x["days"])
            data["projects"]["summary"] = {
                "total_files": total_files,
                "total_projects": total_projects,
                "period_label": f"{days}days" if days > 1 else "Today"
            }
    except Exception:
        pass

    return data

def _voice_generate_desktop_script(desktop_data, lang="en", period="today"):
    """Generate voice script for local desktop status"""
    import urllib.request

    hostname = desktop_data.get("hostname", "this computer")
    os_name = desktop_data.get("os", "unknown")
    cpu = desktop_data.get("cpu", {})
    memory = desktop_data.get("memory", {})
    disk = desktop_data.get("disk", {})
    battery = desktop_data.get("battery", {})
    apps = desktop_data.get("apps", [])
    top_procs = desktop_data.get("top_processes", [])
    network = desktop_data.get("network", {})
    security = desktop_data.get("security", {})
    downloads = desktop_data.get("recent_downloads", [])
    recent_files = desktop_data.get("recent_files", [])
    time_greeting = desktop_data.get("time_greeting", "Hello")
    uptime = desktop_data.get("uptime", "")
    outgoing = security.get("outgoing", [])
    projects = desktop_data.get("projects", {})
    active_projects = projects.get("active", [])
    stale_projects = projects.get("stale", [])
    proj_summary = projects.get("summary", {})

    period_labels = {
        "today": "Today",
        "week": "This week",
        "month": "This month",
        "6month": "Last 6 months",
        "year": "Last year"
    }
    period_label = period_labels.get(period, "Today")

    _active_str = (", ".join([p["name"] + "(" + str(p["files"]) + " files)"
                              for p in active_projects[:4]])
                  if active_projects else "none")
    _stale_str  = (", ".join([p["name"] + "(" + str(p["days"]) + "d)"
                              for p in stale_projects[:3]])
                  if stale_projects else "none")
    prompt = f"""You are a friendly personal assistant giving a {period_label} status briefing.
Speak naturally as if talking to a friend. Keep it under 250 words (~75 seconds).

Data collected:
- OS: {os_name}
- Uptime: {uptime}
- System load: {cpu.get("load_1m", "?")} (lower is better)
- Memory: {memory.get("total_gb", "?")} GB total, {memory.get("used_gb", "?")} GB used ({memory.get("percent", "?")})
- Disk: {disk.get("percent", "?")} used ({disk.get("available", "?")} free)
- Battery: {battery.get("percent", "?")}% {battery.get("status", "")}
- Running apps: {", ".join(apps[:6]) if apps else "none"}
- Top processes: {", ".join([p.get("name","") for p in top_procs[:3]]) if top_procs else "none"}
- Recent work files: {", ".join(recent_files[:3]) if recent_files else "none"}
- Network connections: {network.get("established", "?")}
- Recent downloads: {downloads[0] if downloads else "none"}

Work summary ({period_label}):
- Projects active: {_active_str}
- Stale projects: {_stale_str}

Rules:
1. Start with "{time_greeting}"
2. Natural conversational tone
3. Mention active projects and any stale ones needing attention
4. One security tip
5. End with: "This analysis was processed locally on your Mac"
6. Plain text only - no markdown, no special characters."""

    try:
        req_data = json.dumps({
            "model": VOICE_LLM_MODEL,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0.7}
        }).encode()

        req = urllib.request.Request(
            f"{VOICE_LLM_HOST}/api/generate",
            data=req_data,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read())
            script = result.get("response", "").strip()
            script = script.replace("*", "").replace("#", "").replace("`", "")
            # Apply Korean cleanup if Korean
            return script
    except Exception:
        return None

def _voice_collect_server_detail(server_name):
    """Collect comprehensive information for a specific server"""
    import urllib.request
    servers = get_servers()
    ip = servers.get(server_name)
    if not ip:
        return None

    data = {
        "name": server_name,
        "ip": ip,
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "online": False,
        "system": {},
        "security": {},
        "automation": {},
        "logs": {},
        "processes": [],
        "docker": [],
        "network": {},
        "services": []
    }

    # Check if online
    if not ping_server(ip, timeout=2):
        return data
    data["online"] = True

    # Get from server-agent API if available
    try:
        v1_ip = servers.get("v1", FALLBACK_SERVERS.get("v1"))
        req = urllib.request.Request(f"http://{v1_ip}:8721/api/status")
        with urllib.request.urlopen(req, timeout=5) as resp:
            api_result = json.loads(resp.read())
            agent_data = api_result.get("agent_reports", {})

            hostname_map = {
                "vultr": "v1", "vultr2": "v2", "vultr3": "v3",
                "v1": "v1", "v2": "v2", "v3": "v3",
                "node1": "node1", "node2": "node2", "node3": "node3", "node4": "node4",
                "worker1": "worker1", "worker2": "worker2", "dell": "worker1",
                "workstation": "local", "local": "local"
            }

            for hostname, report in agent_data.items():
                mapped = hostname_map.get(hostname.split(".")[0], hostname)
                if mapped == server_name:
                    data["system"] = {
                        "load": report.get("load", "N/A"),
                        "memory": report.get("memory", "N/A"),
                        "disk": report.get("disk", "N/A"),
                        "uptime": report.get("uptime", "N/A"),
                        "cpu_temp": report.get("cpu_temp", "N/A"),
                        "gpu_temp": report.get("gpu_temp", "N/A"),
                    }
                    data["processes"] = report.get("top_processes", [])[:10]
                    data["docker"] = report.get("docker", [])[:10]
                    break
    except Exception:
        pass

    # Collect detailed info via SSH/vssh - run in parallel batches
    def safe_exec(cmd, timeout=8):
        try:
            result = exec_ssh(server_name, cmd, timeout=timeout)
            if result and not result.startswith("[!]"):
                return result.strip()
        except Exception:
            pass
        return ""

    # SYSTEM INFO
    # CPU/Memory/Load detailed
    result = safe_exec("cat /proc/loadavg 2>/dev/null; free -m 2>/dev/null | grep Mem")
    if result:
        lines = result.split("\n")
        if len(lines) >= 1:
            parts = lines[0].split()
            if len(parts) >= 3:
                data["system"]["load_1m"] = parts[0]
                data["system"]["load_5m"] = parts[1]
                data["system"]["load_15m"] = parts[2]
        if len(lines) >= 2 and "Mem" in lines[1]:
            mem_parts = lines[1].split()
            if len(mem_parts) >= 3:
                total = int(mem_parts[1])
                used = int(mem_parts[2])
                data["system"]["memory_total_mb"] = total
                data["system"]["memory_used_mb"] = used
                data["system"]["memory_percent"] = f"{(used/total)*100:.1f}%"

    # GPU info (NVIDIA)
    result = safe_exec("nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total --format=csv,noheader 2>/dev/null || echo 'no-gpu'")
    if result and result != "no-gpu":
        data["system"]["gpu_info"] = result

    # CPU cores and usage
    result = safe_exec("nproc 2>/dev/null; grep 'cpu ' /proc/stat 2>/dev/null")
    if result:
        lines = result.split("\n")
        if lines:
            data["system"]["cpu_cores"] = lines[0]

    # Disk details
    result = safe_exec("df -h | grep -E '^/dev|^Filesystem' | head -6")
    if result:
        data["system"]["disk_detail"] = result

    # SECURITY
    # SSH attacks (last 24h)
    result = safe_exec("journalctl -u ssh --since '24 hours ago' 2>/dev/null | grep -c 'Failed password' || grep -c 'Failed password' /var/log/auth.log 2>/dev/null || echo 0")
    if result:
        data["security"]["ssh_attacks_24h"] = result

    # Top attackers
    result = safe_exec("journalctl -u ssh --since '24 hours ago' 2>/dev/null | grep 'Failed password' | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head -5 || grep 'Failed password' /var/log/auth.log 2>/dev/null | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head -5")
    if result:
        data["security"]["top_attackers"] = [l.strip() for l in result.split("\n")[:5] if l.strip()]

    # fail2ban status
    result = safe_exec("sudo fail2ban-client status sshd 2>/dev/null | grep -E 'Currently banned|Total banned' || echo 'not installed'")
    if result:
        data["security"]["fail2ban"] = result

    # Firewall status
    result = safe_exec("sudo ufw status 2>/dev/null | head -5 || sudo iptables -L -n 2>/dev/null | head -10 || firewall-cmd --state 2>/dev/null || echo 'unknown'")
    if result:
        data["security"]["firewall"] = result

    # Suspicious processes (high CPU or unknown)
    result = safe_exec("ps aux --sort=-%cpu 2>/dev/null | head -6 | tail -5")
    if result:
        data["security"]["top_cpu_processes"] = result

    # Last logins
    result = safe_exec("last -n 5 2>/dev/null | head -5")
    if result:
        data["security"]["last_logins"] = result

    # Root login check
    result = safe_exec("grep 'Accepted' /var/log/auth.log 2>/dev/null | grep root | tail -3 || journalctl -u ssh 2>/dev/null | grep 'Accepted' | grep root | tail -3")
    if result:
        data["security"]["root_logins"] = result

    # AUTOMATION
    # Cron jobs (system + user)
    result = safe_exec("crontab -l 2>/dev/null | grep -v '^#' | head -10; sudo cat /etc/crontab 2>/dev/null | grep -v '^#' | grep -v '^$' | tail -5")
    if result:
        data["automation"]["cron_jobs"] = result

    # Systemd services (running)
    result = safe_exec("systemctl list-units --type=service --state=running 2>/dev/null | head -15 | tail -14")
    if result:
        data["services"] = [l.strip() for l in result.split("\n") if l.strip()]

    # Systemd timers (scheduled tasks)
    result = safe_exec("systemctl list-timers --all 2>/dev/null | head -10")
    if result:
        data["automation"]["systemd_timers"] = result

    # Failed services
    result = safe_exec("systemctl list-units --type=service --state=failed 2>/dev/null | head -5")
    if result:
        data["automation"]["failed_services"] = result

    # NETWORK
    # Open ports
    result = safe_exec("ss -tuln 2>/dev/null | grep LISTEN | awk '{print $5}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\\n' ',' || netstat -tuln 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\\n' ','")
    if result:
        data["network"]["open_ports"] = result.rstrip(",")

    # Connection summary
    result = safe_exec("ss -s 2>/dev/null | head -4")
    if result:
        data["network"]["connection_summary"] = result

    # Active connections by state
    result = safe_exec("ss -tan 2>/dev/null | tail -n +2 | awk '{print $1}' | sort | uniq -c | sort -rn")
    if result:
        data["network"]["connection_states"] = result

    # VPN interface status
    result = safe_exec("ip addr show awlite0 2>/dev/null | head -3 || ip addr show wire0 2>/dev/null | head -3 || ip addr show wg0 2>/dev/null | head -3")
    if result:
        data["network"]["vpn_interface"] = result

    # LOGS
    # Recent errors
    result = safe_exec("journalctl -p err --since '1 hour ago' 2>/dev/null | tail -8 || dmesg 2>/dev/null | grep -i error | tail -5")
    if result:
        data["logs"]["recent_errors"] = [l.strip() for l in result.split("\n")[:8] if l.strip()]

    # Recent warnings
    result = safe_exec("journalctl -p warning --since '1 hour ago' 2>/dev/null | tail -5")
    if result:
        data["logs"]["recent_warnings"] = [l.strip() for l in result.split("\n")[:5] if l.strip()]

    # Kernel messages
    result = safe_exec("dmesg 2>/dev/null | tail -5")
    if result:
        data["logs"]["kernel_recent"] = result

    # DOCKER
    result = safe_exec("docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' 2>/dev/null | head -10")
    if result and not "Cannot connect" in result:
        data["docker"] = [l for l in result.split("\n") if l.strip()]

    return data

def _voice_generate_server_script(server_data, lang="en"):
    """Generate comprehensive voice script for a single server using LLM"""
    if not server_data:
        return None

    name = server_data.get("name", "unknown")

    # Extract key metrics for natural speech
    system = server_data.get('system', {})
    security = server_data.get('security', {})
    automation = server_data.get('automation', {})
    network = server_data.get('network', {})
    logs = server_data.get('logs', {})
    docker = server_data.get('docker', [])

    # Build natural language prompt
    prompt = f"""You are a network operations announcer giving a status briefing for server "{name}".
Speak naturally, like a news anchor. Keep it under 250 words (~90 seconds).

Server data:
- Status: {"online" if server_data.get("online", False) else "OFFLINE"}
- Load: {system.get("load", system.get("load_1m", "?"))}
- Memory: {system.get("memory", system.get("memory_percent", "?"))}
- Disk: {system.get("disk", "?")}
- Intrusion attempts (24h): {security.get("ssh_attacks_24h", "0")}
- Fail2ban: {security.get("fail2ban", "?")}
- Errors logged: {len(logs.get("recent_errors", []))}
- Containers running: {len(docker) if isinstance(docker, list) else 0}

Rules:
1. Natural announcer tone
2. Spell out abbreviations (SSH -> secure shell, CPU -> processor)
3. Numbers read naturally ("0.5" -> "point five")
4. Natural sentences: "currently running normally", "attention required"
5. Total ~250 words, ~90 seconds
6. No markdown, no special characters, no colons - plain text only."""
    # Call local LLM
    import urllib.request
    try:
        req_data = json.dumps({
            "model": VOICE_LLM_MODEL,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": 0.7}
        }).encode()

        req = urllib.request.Request(
            f"{VOICE_LLM_HOST}/api/generate",
            data=req_data,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read())
            script = result.get("response", "").strip()
            # Clean up
            script = script.replace("*", "").replace("#", "").replace("`", "")
            # Apply Korean cleanup if Korean
            return script
    except Exception as e:
        return None

def _voice_collect_status():
    """Collect detailed server status from server-agent API"""
    import urllib.request
    data = {
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "servers": [],
        "vpn_peers": [],
        "issues": [],
        "stats": {},
        "security": {},
        "details": {}
    }

    servers = get_servers()
    total_online = 0

    # Get all data from server-agent API (relay1:8721)
    agent_data = {}
    try:
        v1_ip = servers.get("v1", FALLBACK_SERVERS.get("v1"))
        req = urllib.request.Request(f"http://{v1_ip}:8721/api/status")
        with urllib.request.urlopen(req, timeout=5) as resp:
            api_result = json.loads(resp.read())
            agent_data = api_result.get("agent_reports", {})
    except Exception:
        pass

    # Map hostnames to node names (agent hostname → mpop node name)
    hostname_map = {
        "vultr": "v1", "vultr2": "v2", "vultr3": "v3",
        "v1": "v1", "v2": "v2", "v3": "v3",
        "node1": "node1", "node2": "node2", "node3": "node3", "node4": "node4",
        "worker1": "worker1", "worker2": "worker2", "dell": "worker1",
        "workstation": "local", "local": "local"
    }

    for name, ip in servers.items():
        online = ping_server(ip, timeout=1)
        if online:
            total_online += 1

        # Find agent report for this server
        detail = {}
        for hostname, report in agent_data.items():
            mapped = hostname_map.get(hostname.split(".")[0], hostname)
            if mapped == name:
                detail = {
                    "load": report.get("load", "N/A"),
                    "memory": report.get("memory", "N/A"),
                    "disk": report.get("disk", "N/A"),
                    "docker": report.get("docker_count", 0),
                }
                try:
                    load_val = float(str(detail["load"]).split(",")[0])
                    if load_val > 5.0:
                        data["issues"].append(f"{name} high load {load_val:.1f}")
                except Exception:
                    pass
                break

        data["servers"].append({
            "name": name,
            "ip": ip,
            "online": online,
            "detail": detail
        })
        if not online:
            data["issues"].append(f"{name} offline")

    data["stats"]["total_servers"] = len(servers)
    data["stats"]["online_servers"] = total_online
    data["vpn_type"] = detect_vpn_type()

    # Security stats from relay1
    try:
        v1_ip = servers.get("v1", FALLBACK_SERVERS.get("v1"))
        if v1_ip:
            result = vssh_exec(v1_ip, "grep -c 'Failed' /var/log/auth.log 2>/dev/null || echo 0", timeout=3)
            if result and result.strip().isdigit():
                attacks = int(result.strip())
                data["security"]["ssh_attacks"] = attacks
                if attacks > 500:
                    data["issues"].append(f"{attacks:,} SSH attacks")
            result = vssh_exec(v1_ip, "fail2ban-client status sshd 2>/dev/null | grep 'Currently banned' | awk '{print $NF}'", timeout=3)
            if result and result.strip().isdigit():
                data["security"]["blocked_ips"] = int(result.strip())
    except Exception:
        pass

    return data

def _voice_generate_script(data, lang="en"):
    """Generate news script using local LLM"""
    # Build server details string
    server_details = []
    for srv in data.get("servers", []):
        name = srv.get("name", "?")
        status = "online" if srv.get("online") else "offline"
        detail = srv.get("detail", {})
        load = detail.get("load", "N/A")
        mem = detail.get("memory", "N/A")
        disk = detail.get("disk", "N/A")
        server_details.append(f"  {name}: {status}, load={load}, mem={mem}%, disk={disk}%")
    server_report = "\n".join(server_details) if server_details else "No details"

    # Build prompt
    prompt = f"""You are the MeshPOP network news anchor.
Write a detailed ~2-minute network status briefing based on the server data below.
Report by server. Speak professionally, like a network operations broadcast.

Timestamp: {data["timestamp"]}
VPN type: {data["vpn_type"]}
Total servers: {data["stats"]["total_servers"]}
Online: {data["stats"]["online_servers"]}

Per-server status:
{chr(10).join(f"  {s}" for s in data.get("servers_summary", []))}

Issues: {", ".join(data["issues"]) if data["issues"] else "none"}

Output the spoken script only:"""
    # Call Ollama on node1
    try:
        llm_ip = get_servers().get(VOICE_LLM_HOST, FALLBACK_SERVERS.get(VOICE_LLM_HOST))
        if not llm_ip:
            return None

        # Use vssh to call ollama
        escaped_prompt = prompt.replace("'", "'\"'\"'").replace("\n", "\\n")
        cmd = f"curl -s http://localhost:11434/api/generate -d '{{\"model\":\"{VOICE_LLM_MODEL}\",\"prompt\":\"{escaped_prompt}\",\"stream\":false}}' | jq -r '.response'"

        result = vssh_exec(llm_ip, cmd, timeout=30)
        if result:
            # Clean up the response
            script = result.strip()
            # Check for null or empty
            if script and script != "null" and len(script) > 10:
                # Remove any markdown or quotes
                script = script.replace('```', '').replace('"', '').strip()
                return script
    except Exception as e:
        pass

    # If LLM failed, fall through to template

    # Fallback: simple template
    return f"""Hello, this is MeshPOP Network News.
Current time: {data["timestamp"]}.
{data["stats"]["total_servers"]} servers total -- {data["stats"]["online_servers"]} online.
{("Issues requiring attention: " + ", ".join(data["issues"])) if data["issues"] else "All systems operating normally."}
This concludes the MeshPOP network news."""

def _voice_generate_audio(script, lang="en", save_path=None):
    """Generate audio using Edge TTS"""
    try:
        import asyncio
        import edge_tts

        # Select voice (news anchor style)
        voice = "en-US-GuyNeural"  # Male news anchor

        # Output path
        if save_path:
            output_path = save_path
        else:
            output_path = f"/tmp/mpop_voice_{int(time.time())}.mp3"

        # Generate audio (faster reading speed)
        async def generate():
            communicate = edge_tts.Communicate(script, voice, rate="+25%")
            await communicate.save(output_path)

        asyncio.run(generate())
        return output_path

    except ImportError:
        print("\n  ❌ edge-tts not installed. Run: pip install edge-tts")
        return None
    except Exception as e:
        print(f"\n  ❌ TTS error: {e}")
        return None

def _voice_play_audio(audio_path):
    """Play audio file"""
    import platform

    system = platform.system()

    try:
        if system == "Darwin":
            # macOS
            subprocess.run(["afplay", audio_path], check=True, capture_output=True)
        else:
            # Linux - try mpv, ffplay, or aplay
            for player in ["mpv", "ffplay", "cvlc"]:
                try:
                    if player == "ffplay":
                        subprocess.run([player, "-nodisp", "-autoexit", audio_path],
                                      check=True, capture_output=True)
                    elif player == "cvlc":
                        subprocess.run([player, "--play-and-exit", audio_path],
                                      check=True, capture_output=True)
                    else:
                        subprocess.run([player, audio_path], check=True, capture_output=True)
                    break
                except FileNotFoundError:
                    continue
    except Exception as e:
        print(f"\n  ⚠️ Could not play audio: {e}")
        print(f"  Audio saved at: {audio_path}")


# ============================================================================
# NEWS BRIEFING (RSS Feed + LLM Summary + TTS)
# ============================================================================

NEWS_FEEDS = {
    "tech": [
        ("Hacker News", "https://hnrss.org/frontpage"),
        ("TechCrunch", "https://techcrunch.com/feed/"),
    ],
    "korea": [
        ("Yonhap News", "https://www.yonhapnewstv.co.kr/browse/feed/"),
        ("Chosun IT", "https://www.chosun.com/arc/outboundfeeds/rss/category/it-science/?outputType=xml"),
    ],
    "world": [
        ("BBC", "http://feeds.bbci.co.uk/news/world/rss.xml"),
        ("Reuters", "https://www.reutersagency.com/feed/"),
    ],
    "dev": [
        ("Dev.to", "https://dev.to/feed"),
        ("GitHub Trending", "https://rsshub.app/github/trending/daily/all"),
    ],
}

def _fetch_rss(url, limit=5):
    """Fetch news from RSS feeds"""
    import urllib.request
    import xml.etree.ElementTree as ET

    try:
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            content = resp.read()

        root = ET.fromstring(content)
        items = []

        # RSS 2.0 format
        for item in root.findall(".//item")[:limit]:
            title = item.find("title")
            link = item.find("link")
            desc = item.find("description")
            items.append({
                "title": title.text if title is not None else "",
                "link": link.text if link is not None else "",
                "description": (desc.text or "")[:200] if desc is not None else ""
            })

        # Atom format fallback
        if not items:
            ns = {"atom": "http://www.w3.org/2005/Atom"}
            for entry in root.findall(".//atom:entry", ns)[:limit]:
                title = entry.find("atom:title", ns)
                link = entry.find("atom:link", ns)
                summary = entry.find("atom:summary", ns)
                items.append({
                    "title": title.text if title is not None else "",
                    "link": link.get("href") if link is not None else "",
                    "description": (summary.text or "")[:200] if summary is not None else ""
                })

        return items
    except Exception as e:
        return []

def cmd_news(args):
    """News briefing from RSS feeds with LLM summary and TTS"""

    # Parse options
    category = "tech"  # Default category
    lang = "en"
    play = True
    limit = 5

    for i, arg in enumerate(args):
        if arg in NEWS_FEEDS:
            category = arg
        elif arg in ("--ko", "--korean", "ko"):
            lang = "ko"
        elif arg in ("--en", "--english", "en"):
            lang = "en"
        elif arg == "--no-play":
            play = False
        elif arg.isdigit():
            limit = int(arg)

    if args and args[0] == "help":
        cats = ", ".join(NEWS_FEEDS.keys())
        print(f"""
  📰 NEWS - RSS News Briefing

  USAGE:
    mpop news              Tech news (default)
    mpop news korea        Korean news
    mpop news world        World news
    mpop news dev          Developer news

  CATEGORIES: {cats}

  OPTIONS:
    --ko          Korean briefing
    --en          English briefing (default)
    --no-play     Don't play audio
    5             Number of articles (default: 5)

  EXAMPLES:
    mpop news tech 10       10 tech articles
    mpop news korea --ko    Korean news in Korean
""")
        return

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   📰 {t('news')} - {category.upper()}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    # Fetch news from feeds
    print(f"  [1/4] Fetching {category} news...", end=" ", flush=True)
    all_news = []
    feeds = NEWS_FEEDS.get(category, NEWS_FEEDS["tech"])

    for name, url in feeds:
        items = _fetch_rss(url, limit=limit)
        for item in items:
            item["source"] = name
        all_news.extend(items)

    if not all_news:
        print(f"{C.RED}✗ No news found{C.R}")
        return

    print(f"{C.GREEN}✓ {len(all_news)} articles{C.R}")

    # Display headlines
    print(f"\n  {C.BOLD}Headlines:{C.R}")
    for i, news in enumerate(all_news[:limit], 1):
        print(f"  {i}. [{news['source']}] {news['title'][:60]}")
    print()

# ==================== Radio MCP Integration ====================

def _venv_bin_dir(env_dir):
    """Return the bin/Scripts directory path for a venv."""
    return os.path.join(env_dir, "Scripts" if os.name == "nt" else "bin")


def _radiomcp_exec_path(env_dir):
    """Path to the radiomcp console script inside a venv."""
    exe = "radiomcp.exe" if os.name == "nt" else "radiomcp"
    return os.path.join(_venv_bin_dir(env_dir), exe)


def _radiomcp_python_path(env_dir):
    """Return Python executable path inside a venv."""
    bin_dir = _venv_bin_dir(env_dir)
    for name in ("python3", "python"):
        exe = f"{name}.exe" if os.name == "nt" else name
        path = os.path.join(bin_dir, exe)
        if os.path.exists(path):
            return path
    # Fallback guess
    return os.path.join(bin_dir, "python.exe" if os.name == "nt" else "python3")


def _radiomcp_repo_binary():
    """Developer convenience: use ~/RadioCli/.venv_radiomcp."""
    repo_env = os.path.expanduser("~/RadioCli/.venv_radiomcp")
    if not os.path.isdir(repo_env):
        return None
    candidate = _radiomcp_exec_path(repo_env)
    return candidate if os.path.exists(candidate) else None


def _radiomcp_locate_binary():
    """Locate an existing radiomcp executable."""
    override = os.environ.get("RADIOMCP_BIN")
    candidates = []
    if override:
        candidates.append(os.path.expanduser(override))
    which_bin = shutil.which("radiomcp")
    if which_bin:
        candidates.append(which_bin)
    managed = _radiomcp_exec_path(RADIOMCP_VENV)
    if os.path.exists(managed):
        candidates.append(managed)
    repo_bin = _radiomcp_repo_binary()
    if repo_bin:
        candidates.append(repo_bin)
    for candidate in candidates:
        if candidate and os.path.exists(candidate):
            return candidate
    return None


def _radiomcp_latest_dist(dist_dir):
    """Return the newest artifact from a dist directory."""
    if not os.path.isdir(dist_dir):
        return None
    candidates = []
    for pattern in ("radiomcp-*.whl", "radiomcp-*.tar.gz"):
        candidates.extend(glob.glob(os.path.join(dist_dir, pattern)))
    if not candidates:
        return None
    candidates.sort()
    return candidates[-1]


def _radiomcp_resolve_source():
    """Decide how to install radiomcp (local repo, dist, or PyPI)."""
    override = os.environ.get("RADIOMCP_SOURCE") or os.environ.get("RADIO_MCP_SOURCE")
    if override:
        expanded = os.path.expanduser(override)
        if os.path.exists(expanded):
            return expanded
        return override

    repo_root = os.path.expanduser("~/RadioCli")
    if os.path.isdir(repo_root):
        dist_pkg = _radiomcp_latest_dist(os.path.join(repo_root, "dist"))
        if dist_pkg:
            return dist_pkg
        return repo_root

    return "radiomcp"


def _radiomcp_install_env(force=False):
    """Create or refresh the managed radiomcp virtualenv."""
    env_dir = RADIOMCP_VENV
    target = _radiomcp_exec_path(env_dir)

    if not force and os.path.exists(target):
        return target

    if force and os.path.isdir(env_dir):
        shutil.rmtree(env_dir, ignore_errors=True)

    os.makedirs(CONFIG_DIR, exist_ok=True)

    print(f"\n  Setting up RadioMCP environment at {env_dir}")
    try:
        venv.EnvBuilder(with_pip=True).create(env_dir)
    except Exception as e:
        print(f"  {C.RED}Failed to create virtualenv: {e}{C.R}")
        return None

    python_bin = _radiomcp_python_path(env_dir)
    source = _radiomcp_resolve_source()
    print(f"  Installing radiomcp from {source} ...")

    try:
        result = subprocess.run(
            [python_bin, "-m", "pip", "install", source],
            capture_output=True,
            text=True,
            timeout=180
        )
    except Exception as e:
        print(f"  {C.RED}pip install failed: {e}{C.R}")
        return None

    if result.returncode != 0:
        error_msg = (result.stderr or result.stdout).strip()
        print(f"  {C.RED}pip install error:{C.R} {error_msg or 'unknown error'}")
        return None

    print(f"  {C.GREEN}radiomcp installed{C.R}")
    return target if os.path.exists(target) else None


def cmd_radiomcp(args):
    """Launch/manage the Radio MCP server locally."""
    install_requested = False
    reinstall = False
    install_only = False
    show_path = False
    passthrough = []
    passthrough_mode = False

    for arg in args:
        if not passthrough_mode:
            if arg == "--":
                passthrough_mode = True
                continue
            if arg in ("--install", "-i"):
                install_requested = True
                continue
            if arg in ("--reinstall", "-r"):
                install_requested = True
                reinstall = True
                continue
            if arg == "--install-only":
                install_requested = True
                install_only = True
                continue
            if arg == "--show-path":
                show_path = True
                continue
        passthrough.append(arg)

    if install_requested:
        binary = _radiomcp_install_env(force=reinstall)
    else:
        binary = _radiomcp_locate_binary()

    if not binary:
        binary = _radiomcp_install_env(force=False)
        if not binary:
            print(f"  {C.RED}radiomcp not available{C.R}")
            print("  Install manually: pip install radiomcp")
            print("  Or set RADIOMCP_SOURCE to a local path.")
            return

    if show_path or install_only:
        print(f"  RadioMCP binary: {binary}")
        if install_only:
            print("  Installation complete.")
        return

    print(f"\n  🎧 Radio MCP ready: {binary}")
    print("  Ctrl+C to stop. Use '--' to pass arguments to radiomcp.\n")

    try:
        subprocess.run([binary] + passthrough, check=False)
    except KeyboardInterrupt:
        print(f"\n  {C.YELLOW}Radio MCP stopped{C.R}")


# ============================================================================
# SERVICE LAYER - Cluster service management
# ============================================================================

SERVICE_DIR = os.path.expanduser("~/.mpop/services")

def _load_services():
    """Load service definitions from ~/.mpop/services/"""
    services = {}
    if not os.path.isdir(SERVICE_DIR):
        os.makedirs(SERVICE_DIR, exist_ok=True)
    for f in glob.glob(os.path.join(SERVICE_DIR, "*.json")):
        try:
            with open(f) as fp:
                name = os.path.basename(f).replace(".json", "")
                services[name] = json.load(fp)
        except Exception:
            pass
    return services


def _save_service(name, config):
    """Save service definition"""
    os.makedirs(SERVICE_DIR, exist_ok=True)
    path = os.path.join(SERVICE_DIR, f"{name}.json")
    with open(path, "w") as f:
        json.dump(config, f, indent=2)
    return path


def cmd_service(args):
    """Cluster service management

    Usage:
        mpop service                     List all services
        mpop service status <name>       Show service status
        mpop service start <name>        Start service on all nodes
        mpop service stop <name>         Stop service on all nodes
        mpop service restart <name>      Restart service
        mpop service logs <name>         View logs from all nodes
        mpop service add <name>          Add new service interactively
        mpop service rm <name>           Remove service definition
    """
    services = _load_services()

    if not args:
        # List all services
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}SERVICES{C.R} - Cluster Service Management
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")
        if not services:
            print(f"  {C.DIM}No services defined yet.{C.R}")
            print(f"  Create one: mpop service add <name>")
            print(f"\n  Example service config (~/.mpop/services/radiomcp.json):")
            print(f'''  {{
    "nodes": ["g3", "v3"],
    "start": "cd ~/radiomcp && python3 server.py",
    "stop": "pkill -f radiomcp",
    "check": "pgrep -f radiomcp",
    "logs": "tail -100 ~/radiomcp/logs/*.log"
  }}''')
            return

        print(f"  {'SERVICE':<15} {'NODES':<20} {'STATUS'}")
        print(f"  {'-'*15} {'-'*20} {'-'*10}")

        for name, cfg in services.items():
            nodes = ", ".join(cfg.get("nodes", []))
            print(f"  {name:<15} {nodes:<20} {C.DIM}use 'status'{C.R}")

        print(f"\n  Commands: status | start | stop | restart | logs | add | rm")
        return

    subcmd = args[0]

    if subcmd == "add":
        # Interactive service creation
        if len(args) < 2:
            print("  Usage: mpop service add <name>")
            return
        name = args[1]
        print(f"\n  Creating service: {name}")
        print(f"  Enter configuration (or press Enter for defaults):\n")

        nodes_input = input(f"  Nodes (comma-separated, e.g., g3,v3): ").strip()
        nodes = [n.strip() for n in nodes_input.split(",")] if nodes_input else ["g3"]

        start_cmd = input(f"  Start command: ").strip() or f"python3 ~/{name}/main.py"
        stop_cmd = input(f"  Stop command: ").strip() or f"pkill -f {name}"
        check_cmd = input(f"  Check command (for status): ").strip() or f"pgrep -f {name}"
        logs_cmd = input(f"  Logs command: ").strip() or f"tail -50 ~/{name}/*.log 2>/dev/null"

        config = {
            "nodes": nodes,
            "start": start_cmd,
            "stop": stop_cmd,
            "check": check_cmd,
            "logs": logs_cmd
        }

        path = _save_service(name, config)
        print(f"\n  {C.GREEN}Service saved:{C.R} {path}")
        return

    if subcmd == "rm":
        if len(args) < 2:
            print("  Usage: mpop service rm <name>")
            return
        name = args[1]
        path = os.path.join(SERVICE_DIR, f"{name}.json")
        if os.path.exists(path):
            os.remove(path)
            print(f"  {C.GREEN}Removed:{C.R} {name}")
        else:
            print(f"  {C.RED}Not found:{C.R} {name}")
        return

    # Commands that need service name
    if len(args) < 2:
        print(f"  Usage: mpop service {subcmd} <name>")
        return

    name = args[1]
    if name not in services:
        print(f"  {C.RED}Unknown service:{C.R} {name}")
        print(f"  Available: {', '.join(services.keys()) or '(none)'}")
        return

    cfg = services[name]
    nodes = cfg.get("nodes", [])

    if subcmd == "status":
        print(f"\n  {C.BOLD}Service: {name}{C.R}")
        print(f"  {'-'*50}")
        check_cmd = cfg.get("check", f"pgrep -f {name}")

        for node in nodes:
            if node not in SERVERS:
                print(f"  {node:<8} {C.RED}unknown node{C.R}")
                continue
            ip = SERVERS[node]
            result = vssh_exec(ip, check_cmd, timeout=5)
            if result and result.strip():
                pids = result.strip().replace('\n', ', ')
                print(f"  {node:<8} {C.GREEN}running{C.R} (PID: {pids})")
            else:
                print(f"  {node:<8} {C.RED}stopped{C.R}")

    elif subcmd == "start":
        print(f"\n  Starting {name}...")
        start_cmd = cfg.get("start", "")
        if not start_cmd:
            print(f"  {C.RED}No start command defined{C.R}")
            return

        # Wrap in nohup for background
        bg_cmd = f"nohup {start_cmd} > /tmp/{name}.log 2>&1 &"

        for node in nodes:
            if node not in SERVERS:
                continue
            ip = SERVERS[node]
            result = vssh_exec(ip, bg_cmd, timeout=10)
            print(f"  {node:<8} {C.GREEN}started{C.R}")

        _log_history(f"service:start:{name}", "OK")

    elif subcmd == "stop":
        print(f"\n  Stopping {name}...")
        stop_cmd = cfg.get("stop", f"pkill -f {name}")

        for node in nodes:
            if node not in SERVERS:
                continue
            ip = SERVERS[node]
            vssh_exec(ip, stop_cmd, timeout=10)
            print(f"  {node:<8} {C.YELLOW}stopped{C.R}")

        _log_history(f"service:stop:{name}", "OK")

    elif subcmd == "restart":
        print(f"\n  Restarting {name}...")
        stop_cmd = cfg.get("stop", f"pkill -f {name}")
        start_cmd = cfg.get("start", "")
        bg_cmd = f"nohup {start_cmd} > /tmp/{name}.log 2>&1 &"

        for node in nodes:
            if node not in SERVERS:
                continue
            ip = SERVERS[node]
            vssh_exec(ip, stop_cmd, timeout=5)
            time.sleep(1)
            vssh_exec(ip, bg_cmd, timeout=10)
            print(f"  {node:<8} {C.GREEN}restarted{C.R}")

        _log_history(f"service:restart:{name}", "OK")

    elif subcmd == "logs":
        logs_cmd = cfg.get("logs", f"tail -50 /tmp/{name}.log")

        for node in nodes:
            if node not in SERVERS:
                continue
            ip = SERVERS[node]
            print(f"\n  {C.BOLD}=== {node} ==={C.R}")
            result = vssh_exec(ip, logs_cmd, timeout=10)
            if result:
                print(result)
            else:
                print(f"  {C.DIM}(no logs){C.R}")

    else:
        print(f"  Unknown subcommand: {subcmd}")
        print(f"  Available: status | start | stop | restart | logs | add | rm")


# ============================================================================
# RETRY - Retry failed commands from history
# ============================================================================

def cmd_retry(args):
    """Retry failed commands from history

    Usage:
        mpop retry              Show recent failures
        mpop retry last         Retry the last failed command
        mpop retry <n>          Retry the nth failure from list
        mpop retry --all        Retry all recent failures
    """
    # Fetch recent history
    cmd = f"tail -n 100 {CMDLOG_PATH} 2>/dev/null || echo ''"
    result = vssh_exec(SECRETS_MASTERS[0], cmd, timeout=5)

    if not result or not result.strip():
        print("  No command history found.")
        return

    # Parse and filter failures
    failures = []
    for line in result.strip().split('\n'):
        parts = line.split('|')
        if len(parts) >= 4:
            ts, node, command, status = parts[0], parts[1], parts[2], parts[3]
            if status == "FAIL":
                failures.append({
                    "time": ts,
                    "node": node,
                    "command": command,
                    "raw": line
                })

    failures = failures[-20:]  # Last 20 failures

    if not failures:
        print(f"  {C.GREEN}No recent failures!{C.R}")
        return

    if not args:
        # Show failures
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.RED}RECENT FAILURES{C.R} - Commands that failed
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")
        print(f"  {'#':<3} {'TIME':<20} {'COMMAND':<40}")
        print(f"  {'-'*3} {'-'*20} {'-'*40}")

        for i, f in enumerate(failures[-10:], 1):
            ts = f["time"][11:19] if len(f["time"]) > 19 else f["time"]
            cmd_short = f["command"][:38] + ".." if len(f["command"]) > 40 else f["command"]
            print(f"  {i:<3} {ts:<20} {cmd_short}")

        print(f"\n  Usage: mpop retry last | mpop retry <n>")
        return

    # Determine which command to retry
    to_retry = []

    if args[0] == "last":
        to_retry = [failures[-1]]
    elif args[0] == "--all":
        to_retry = failures[-5:]  # Last 5 failures
    elif args[0].isdigit():
        idx = int(args[0]) - 1
        if 0 <= idx < len(failures):
            to_retry = [failures[-(10-idx)]]  # Map to displayed index
        else:
            print(f"  Invalid index: {args[0]}")
            return
    else:
        print(f"  Unknown option: {args[0]}")
        return

    # Execute retries
    for f in to_retry:
        cmd_str = f["command"]
        print(f"\n  {C.YELLOW}Retrying:{C.R} {cmd_str}")

        # Parse command (format: "node:command" or "all:command")
        if ":" in cmd_str:
            target, actual_cmd = cmd_str.split(":", 1)

            if target == "all":
                # Broadcast to all
                for name, ip in SERVERS.items():
                    result = vssh_exec(ip, actual_cmd, timeout=30)
                    status = "OK" if result is not None else "FAIL"
                    print(f"    {name}: {status}")
            elif target in SERVERS:
                ip = SERVERS[target]
                result = vssh_exec(ip, actual_cmd, timeout=30)
                if result is not None:
                    print(f"  {C.GREEN}Success!{C.R}")
                    if result.strip():
                        print(f"  Output: {result[:200]}")
                    _log_history(cmd_str, "OK")
                else:
                    print(f"  {C.RED}Failed again{C.R}")
                    _log_history(cmd_str, "FAIL")
            else:
                print(f"  {C.RED}Unknown target:{C.R} {target}")
        else:
            print(f"  {C.RED}Invalid command format{C.R}")


# ============================================================================
# LOG ANALYZER - LLM-powered log analysis
# ============================================================================

def cmd_logai(args):
    """AI-powered log analysis

    Usage:
        mpop logai                   Analyze all servers
        mpop logai <node>            Analyze specific server
        mpop logai --errors          Focus on errors only
        mpop logai --service <name>  Analyze specific service
        mpop logai --fix             Suggest fixes with LLM
    """
    target_node = None
    errors_only = "--errors" in args or "-e" in args
    suggest_fix = "--fix" in args or "-f" in args
    service = None

    for i, arg in enumerate(args):
        if arg in SERVERS:
            target_node = arg
        elif arg == "--service" and i + 1 < len(args):
            service = args[i + 1]

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}LOG ANALYZER{C.R} - AI-powered log analysis
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    targets = {target_node: SERVERS[target_node]} if target_node else SERVERS
    all_logs = {}

    # Collect logs from targets
    print(f"  {C.DIM}Collecting logs...{C.R}")

    for name, ip in targets.items():
        if name == get_my_name():
            continue  # Skip local

        logs = {}

        # System logs (errors/warnings)
        if errors_only:
            log_cmd = "journalctl -p err -n 50 --no-pager 2>/dev/null || tail -50 /var/log/syslog 2>/dev/null | grep -iE 'error|fail|warn'"
        else:
            log_cmd = "journalctl -n 30 --no-pager 2>/dev/null || tail -30 /var/log/syslog 2>/dev/null"

        result = vssh_exec(ip, log_cmd, timeout=10)
        if result:
            logs["system"] = result[:2000]  # Limit size

        # Auth logs (SSH failures)
        auth_cmd = "grep -iE 'failed|invalid|error' /var/log/auth.log 2>/dev/null | tail -20"
        result = vssh_exec(ip, auth_cmd, timeout=10)
        if result and result.strip():
            logs["auth"] = result[:1000]

        # Service-specific logs
        if service:
            svc_cmd = f"journalctl -u {service} -n 30 --no-pager 2>/dev/null || tail -30 /tmp/{service}.log 2>/dev/null"
            result = vssh_exec(ip, svc_cmd, timeout=10)
            if result:
                logs[service] = result[:1500]

        if logs:
            all_logs[name] = logs

    if not all_logs:
        print(f"  {C.GREEN}No logs collected (or all clean){C.R}")
        return

    # Display summary
    for node, logs in all_logs.items():
        print(f"\n  {C.BOLD}[{node}]{C.R}")

        for log_type, content in logs.items():
            lines = content.strip().split('\n')
            error_count = sum(1 for l in lines if any(w in l.lower() for w in ['error', 'fail', 'warn', 'crit']))

            if error_count > 0:
                print(f"    {log_type}: {C.RED}{error_count} issues{C.R}")
                # Show first few errors
                for line in lines[:3]:
                    if any(w in line.lower() for w in ['error', 'fail', 'warn', 'crit']):
                        print(f"      {C.DIM}{line[:70]}{C.R}")
            else:
                print(f"    {log_type}: {C.GREEN}clean{C.R}")

    # LLM analysis if requested
    if suggest_fix:
        print(f"\n  {C.CYAN}Analyzing with LLM...{C.R}")

        # Build prompt
        log_summary = ""
        for node, logs in all_logs.items():
            log_summary += f"\n=== {node} ===\n"
            for log_type, content in logs.items():
                log_summary += f"[{log_type}]\n{content[:500]}\n"

        prompt = f"""Analyze these server logs and provide:
1. Summary of issues found
2. Severity (critical/warning/info)
3. Suggested fixes (specific commands)

Logs:
{log_summary[:3000]}

Respond in Korean. Be concise."""

        # Try to use existing LLM infrastructure
        try:
            ollama_host = _get_ollama_host()
            model = _get_config("ollama_model", "gpt-oss:20b")

            import urllib.request
            req = urllib.request.Request(
                f"http://{ollama_host}:11434/api/generate",
                data=json.dumps({"model": model, "prompt": prompt, "stream": False}).encode(),
                headers={"Content-Type": "application/json"}
            )

            with urllib.request.urlopen(req, timeout=60) as resp:
                data = json.loads(resp.read())
                analysis = data.get("response", "")

                print(f"\n  {C.BOLD}AI Analysis:{C.R}")
                print(f"  {'-'*50}")
                for line in analysis.split('\n'):
                    print(f"  {line}")

        except Exception as e:
            print(f"  {C.RED}LLM analysis failed:{C.R} {e}")
            print(f"  Configure with: mpop config set ollama_host <ip>")


# ============================================================================
# AI AGENT SYSTEM (replaces LangChain + n8n + Open Interpreter)
# ============================================================================

# Workflow storage path
WORKFLOW_DIR = os.path.expanduser("~/.mpop/workflows")

def _detect_lang(text):
    """Detect language from text (ko/en)"""
    # Simple heuristic: if contains Korean characters, it's Korean
    import re
    if lang != 'en' and re.search(r'[\u0300-\uFFFF]', text):
        return 'ko'
    return 'en'

def _load_prompt(name, lang='en'):
    """Load prompt template from file"""
    path = os.path.join(WORKFLOW_DIR, 'prompts', lang, f'{name}.txt')
    if os.path.exists(path):
        with open(path) as f:
            return f.read()
    # Fallback to English
    path_en = os.path.join(WORKFLOW_DIR, 'prompts', 'en', f'{name}.txt')
    if os.path.exists(path_en):
        with open(path_en) as f:
            return f.read()
    return None

def _run_workflow(workflow_name, query, execute=True):
    """Run a workflow with the given query"""
    import yaml
    import urllib.request

    workflow_path = os.path.join(WORKFLOW_DIR, f'{workflow_name}.yaml')
    if not os.path.exists(workflow_path):
        return None, f"Workflow not found: {workflow_name}"

    with open(workflow_path) as f:
        workflow = yaml.safe_load(f)

    lang = _detect_lang(query)
    ollama_host = _get_ollama_host()
    # Model priority: workflow YAML > config.json > default
    default_model = _get_config("ollama_model", "gpt-oss:20b")
    model = workflow.get('model', default_model)
    context = {'query': query, 'lang': lang}

    for step in workflow.get('steps', []):
        step_id = step['id']
        step_type = step['type']

        if step_type == 'llm':
            # Load prompt template
            prompt_name = step.get('prompt', '').replace('prompts/{lang}/', '').replace('.txt', '')
            prompt_template = _load_prompt(prompt_name, lang)
            if not prompt_template:
                continue

            # Fill template (simple replace to avoid {} conflicts with awk)
            prompt = prompt_template
            for key, val in context.items():
                prompt = prompt.replace('{' + key + '}', str(val))

            # Call LLM
            try:
                req_data = json.dumps({
                    "model": model,
                    "prompt": prompt,
                    "stream": False
                }).encode()
                req = urllib.request.Request(
                    f"http://{ollama_host}/api/generate",
                    data=req_data,
                    headers={"Content-Type": "application/json"}
                )
                with urllib.request.urlopen(req, timeout=30) as resp:
                    result = json.loads(resp.read())
                    output = result.get("response", "").strip()

                # For small models: extract only mpop exec '...' command
                if step_id == 'generate':
                    import re
                    # mpop exec '...' or mpop exec -t ... '...' pattern
                    cmd_match = re.search(r"(mpop exec(?:\s+-t\s+\S+)?\s+'[^']*')", output)
                    if cmd_match:
                        output = cmd_match.group(1).strip()

                context[step_id] = output
                context[f'{step_id}_output'] = output
            except Exception as e:
                context[step_id] = f"Error: {e}"

        elif step_type == 'shell':
            if not execute:
                context[step_id] = "(dry run - not executed)"
                continue

            # Get command from context
            cmd = context.get('generate', '')
            if cmd.startswith('mpop '):
                cmd = cmd[5:]

            try:
                mpop_path = os.path.expanduser("~/bin/mpop")
                if not os.path.exists(mpop_path):
                    mpop_path = "mpop"

                result = subprocess.run(
                    f"{mpop_path} {cmd}",
                    shell=True, capture_output=True, text=True, timeout=120
                )
                output = result.stdout
                if result.stderr:
                    output += f"\n[stderr] {result.stderr}"
                context[step_id] = output
                context['output'] = output  # For interpret step
            except Exception as e:
                context[step_id] = f"Error: {e}"

    return context, None

def cmd_ask(args):
    """Natural language → server command (Open Interpreter style)

    Execute server management commands using natural language.
    e.g. mpop ask "check v1 disk usage"
    """
    if not args:
        print(f"""
  {C.BOLD}mpop ask{C.R} - Natural language server management

  {C.BOLD}USAGE:{C.R}
    mpop ask "query"              # Dry-run (show command only)
    mpop ask "query" -x           # Execute generated command
    mpop ask "query" -x -y        # Execute without confirmation

  {C.BOLD}OPTIONS:{C.R}
    -x, --execute    Execute generated command (default: dry-run)
    -y, --yes        Skip confirmation prompt

  {C.BOLD}EXAMPLES:{C.R}
    mpop ask "g1 memory usage"
    mpop ask "all servers disk"
    mpop ask "g1,g2,g3 uptime" -x
    mpop ask "restart nginx on v1" -x -y
""")
        return

    query = " ".join([a for a in args if not a.startswith("-")])
    execute = "-x" in args or "--execute" in args
    auto_yes = "-y" in args or "--yes" in args

    lang = _detect_lang(query)
    query_lower = query.lower()

    # Load pattern file (~/.mpop/patterns.yaml)
    patterns_file = os.path.expanduser("~/.mpop/patterns.yaml")
    patterns = {}
    aliases = {}
    if os.path.exists(patterns_file):
        try:
            import yaml
            with open(patterns_file) as f:
                data = yaml.safe_load(f) or {}
                aliases = data.pop('_aliases', {})
                patterns = data
        except Exception:
            pass

    # alias substitution (Korean -> English)
    query_normalized = query_lower
    for ko, en in aliases.items():
        if ko.lower() in query_normalized:
            query_normalized = query_normalized.replace(ko.lower(), en.lower())

    # Pattern matching (longer patterns first)
    matched_cmd = None
    matched_pattern = None
    for pattern in sorted(patterns.keys(), key=len, reverse=True):
        if pattern.lower() in query_normalized:
            matched_cmd = patterns[pattern]
            matched_pattern = pattern
            break

    if matched_cmd:
        # Extract args: substitute words after pattern with {1}, {2}, ...
        # Ex: "docker restart nginx" -> pattern="docker restart", args=["nginx"]
        pattern_idx = query_normalized.find(matched_pattern.lower())
        after_pattern = query_normalized[pattern_idx + len(matched_pattern):].strip()
        cmd_args = after_pattern.split() if after_pattern else []

        # Replace {1}, {2}, {*}
        for i, arg in enumerate(cmd_args, 1):
            matched_cmd = matched_cmd.replace(f"{{{i}}}", arg)
        matched_cmd = matched_cmd.replace("{*}", " ".join(cmd_args))

        # Remove unused placeholders
        import re
        matched_cmd = re.sub(r'\s*\{[0-9*]+\}', '', matched_cmd)

        # Fast path: execute directly without AI
        print(f"  {C.BOLD}Query:{C.R} {query}")
        print(f"  {C.BOLD}Command:{C.R} {C.CYAN}mpop {matched_cmd}{C.R}")
        if execute:
            os.system(f"mpop {matched_cmd}")
        else:
            print(f"  {C.YELLOW}Add -x to execute{C.R}")
        return

    # AI path
    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🤖 AI Agent{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Query:{C.R} {query}
""")

    print("  [1/2] Generating command...", end=" ", flush=True)
    context, err = _run_workflow('server-query', query, execute=execute)

    if err:
        print(f"{C.RED}✗{C.R}")
        print(f"  Error: {err}")
        return

    # Output result (skip interpretation - CLI must be fast)
    cmd = context.get('generate', '')
    if cmd:
        print(f"{C.GREEN}✓{C.R}")
        print(f"\n  {C.BOLD}Command:{C.R} {C.CYAN}{cmd}{C.R}")

        if execute:
            print(f"\n  [2/2] Executing...")
            output = context.get('execute', '')
            if output:
                print(output)
        else:
            print(f"\n  {C.YELLOW}Dry run mode.{C.R} Add -x to execute.")

    return


    # === Previous hardcoded logic (backup) ===
    print("  [1/3] Analyzing request...", end=" ", flush=True)

    # Ollama call
    ollama_host = _get_ollama_host()

    try:
        import urllib.request
        req_data = json.dumps({
            "model": "qwen2.5:7b",
            "prompt": f"{system_prompt}\n\nUser request: {query}",
            "stream": False,
            "format": "json"
        }).encode()

        req = urllib.request.Request(
            f"http://{ollama_host}/api/generate",
            data=req_data,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read())
            response_text = result.get("response", "{}")

        # JSON parsing
        try:
            ai_response = json.loads(response_text)
        except Exception:
            # If not JSON, try extracting from text
            import re
            json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
            if json_match:
                ai_response = json.loads(json_match.group())
            else:
                ai_response = {"understanding": response_text, "commands": [], "explanation": "Could not parse response"}

        print(f"{C.GREEN}✓{C.R}")

    except Exception as e:
        print(f"{C.RED}✗{C.R}")
        print(f"  Error: {e}")
        return

    understanding = ai_response.get("understanding", "")
    commands = ai_response.get("commands", [])
    explanation = ai_response.get("explanation", "")

    # Flatten nested lists (AI sometimes returns [[cmd]] instead of [cmd])
    flat_commands = []
    for c in commands:
        if isinstance(c, list):
            flat_commands.extend(c)
        elif isinstance(c, str):
            flat_commands.append(c)
    commands = flat_commands

    print(f"  [2/3] Understanding: {understanding}")
    print()

    if not commands:
        print(f"  {C.YELLOW}No commands generated.{C.R}")
        if explanation:
            print(f"  Reason: {explanation}")
        return

    print(f"  {C.BOLD}Generated commands:{C.R}")
    for i, cmd in enumerate(commands, 1):
        print(f"    {i}. {C.CYAN}{cmd}{C.R}")
    print()
    print(f"  {C.DIM}Explanation: {explanation}{C.R}")
    print()

    if not execute:
        print(f"  {C.YELLOW}Dry run mode.{C.R} Add -x to execute.")
        return

    # Confirm execution
    if not auto_yes:
        print(f"  {C.BOLD}Execute these commands? [y/N]{C.R} ", end="", flush=True)
        try:
            confirm = input().strip().lower()
        except Exception:
            confirm = ""
        if confirm != "y":
            print("  Cancelled.")
            return

    # Execute command and collect results
    print(f"  [3/3] Executing...")
    print()

    all_output = []
    for cmd in commands:
        if cmd.startswith("mpop "):
            cmd = cmd[5:]

        print(f"  {C.BOLD}> {cmd}{C.R}")
        print("  " + "-" * 60)

        try:
            mpop_path = os.path.expanduser("~/bin/mpop")
            if not os.path.exists(mpop_path):
                mpop_path = "mpop"

            result = subprocess.run(
                f"{mpop_path} {cmd}",
                shell=True, capture_output=True, text=True, timeout=120
            )

            output = result.stdout
            if output:
                print(output)
                all_output.append(output)
            if result.stderr:
                print(f"  {C.RED}{result.stderr[:300]}{C.R}")
        except Exception as e:
            print(f"  {C.RED}Error: {e}{C.R}")
        print()

    # Second AI call: interpret results
    if all_output:
        combined_output = "\n".join(all_output)
        interpret_prompt = f"""User question: {query}

Command output:
{combined_output[:3000]}

Answer the user's question based on the output.
If filtering is requested (e.g., "over 50%", "> 80", "over"), list only matching servers.
Be concise. Use same language as user."""

        print(f"  {C.BOLD}[4/4] Interpreting...{C.R}", end=" ", flush=True)
        try:
            req_data = json.dumps({
                "model": "qwen2.5:7b",
                "prompt": interpret_prompt,
                "stream": False
            }).encode()
            req = urllib.request.Request(
                f"http://{ollama_host}/api/generate",
                data=req_data,
                headers={"Content-Type": "application/json"}
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
                answer = result.get("response", "").strip()
            print(f"{C.GREEN}✓{C.R}")
            print()
            print(f"  {C.CYAN}{answer}{C.R}")
        except Exception as e:
            print(f"{C.YELLOW}skipped{C.R}")


# ========== AGENT SYSTEM ==========

AGENTS_DIR = os.path.expanduser("~/.mpop/agents")

def _load_agents():
    """Load all agents from ~/.mpop/agents/"""
    agents = {}
    if os.path.exists(AGENTS_DIR):
        for f in os.listdir(AGENTS_DIR):
            if f.endswith(".json"):
                try:
                    with open(os.path.join(AGENTS_DIR, f)) as fp:
                        agent = json.load(fp)
                        agents[agent.get("name", f[:-5])] = agent
                except Exception:
                    pass
    return agents


def _save_agent(agent):
    """Save agent to ~/.mpop/agents/<name>.json"""
    os.makedirs(AGENTS_DIR, exist_ok=True)
    path = os.path.join(AGENTS_DIR, f"{agent['name']}.json")
    with open(path, "w") as f:
        json.dump(agent, f, indent=2)
    return path


def _generate_agent_code(agent):
    """Generate Python code for agent"""
    config = _load_config() or {}
    servers = list(config.get("servers", {}).keys())
    servers_str = str(servers)

    template = '''#!/usr/bin/env python3
"""
{name} - {description}
Generated by mpop agent make
"""

import subprocess
import json
import time
import sys
import os

# Available servers from mpop config
SERVERS = {servers}

def run_cmd(cmd, timeout=30):
    """Run shell command and return output"""
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        return result.stdout.strip()
    except Exception as e:
        return f"Error: {{e}}"

def mpop_exec(server, cmd, timeout=30):
    """Run command on server via mpop"""
    try:
        result = subprocess.run(
            ['mpop', 'exec', '-t', server, cmd],
            capture_output=True, text=True, timeout=timeout
        )
        return result.stdout.strip()
    except Exception as e:
        return f"Error: {{e}}"

def get_input():
    """Get JSON input from previous agent in chain"""
    raw = os.environ.get('AGENT_INPUT', '')
    if not raw:
        return {{}}
    try:
        return json.loads(raw)
    except Exception:
        return {{"raw": raw}}

def output(data):
    """Output JSON for next agent in chain"""
    if isinstance(data, dict):
        print(json.dumps(data))
    else:
        print(json.dumps({{"result": data}}))

def main():
    """Main agent logic"""
{logic}

if __name__ == "__main__":
    main()
'''
    logic = agent.get("logic", "    print('Agent running...')")
    # Clean markdown artifacts
    logic = logic.strip()
    if logic.startswith("```"):
        logic = "\n".join(logic.split("\n")[1:])
    if logic.endswith("```"):
        logic = "\n".join(logic.split("\n")[:-1])
    logic = logic.strip()

    # Find minimum indent of non-empty lines
    lines = logic.split("\n")
    min_indent = 999
    for line in lines:
        if line.strip():
            indent = len(line) - len(line.lstrip())
            min_indent = min(min_indent, indent)
    if min_indent == 999:
        min_indent = 0

    # Normalize: remove base indent, add 4-space function indent
    normalized = []
    for line in lines:
        if line.strip():
            # Remove base indent, add function indent
            content = line[min_indent:] if len(line) > min_indent else line.lstrip()
            normalized.append("    " + content)
        else:
            normalized.append("")
    logic = "\n".join(normalized)

    return template.format(
        name=agent["name"],
        description=agent.get("description", "Custom agent"),
        servers=servers_str,
        logic=logic
    )


def cmd_agent(args):
    """AI-driven agent creation and management

    Create, deploy, and orchestrate agents across servers.
    """
    if not args:
        # Show agent list and help
        agents = _load_agents()
        print(f"""
  {C.BOLD}mpop agent{C.R} - AI Agent Management

  {C.CYAN}COMMANDS:{C.R}
    mpop agent                    List all agents
    mpop agent make               Create agent via AI conversation
    mpop agent make <name>        Create agent with name
    mpop agent show <name>        Show agent details
    mpop agent edit <name>        Edit agent
    mpop agent deploy <name> <servers>  Deploy to servers
    mpop agent run <name>         Run agent locally
    mpop agent delete <name>      Delete agent
    mpop agent workflow <name>    Create workflow with agents
    mpop agent chain <a1> <a2>    Chain agents together

  {C.CYAN}AGENTS ({len(agents)}):{C.R}""")

        if agents:
            for name, agent in sorted(agents.items()):
                desc = agent.get("description", "")[:40]
                deployed = ", ".join(agent.get("deployed_to", [])) or "-"
                print(f"    {C.GREEN}{name:20}{C.R} {desc:40} [{deployed}]")
        else:
            print(f"    (no agents yet - use 'mpop agent make' to create)")

        print()
        return

    action = args[0]

    # === LIST ===
    if action == "list":
        agents = _load_agents()
        if not agents:
            print(f"  No agents found. Use 'mpop agent make' to create one.")
            return
        print(f"\n  {C.BOLD}Agents:{C.R}")
        for name, agent in sorted(agents.items()):
            desc = agent.get("description", "")[:50]
            print(f"    {C.GREEN}{name}{C.R}: {desc}")
        print()
        return

    # === MAKE ===
    if action == "make":
        agent_name = args[1] if len(args) > 1 else None
        # Non-interactive: mpop agent make <name> -d "desc" -b "behavior"
        description = None
        behavior = None
        i = 2
        while i < len(args):
            if args[i] in ["-d", "--desc"] and i + 1 < len(args):
                description = args[i + 1]
                i += 2
            elif args[i] in ["-b", "--behavior"] and i + 1 < len(args):
                behavior = args[i + 1]
                i += 2
            else:
                i += 1
        if agent_name and description and behavior:
            _agent_make_noninteractive(agent_name, description, behavior)
        else:
            _agent_make_interactive(agent_name)
        return

    # === SHOW ===
    if action == "show" and len(args) > 1:
        name = args[1]
        agents = _load_agents()
        if name not in agents:
            print(f"  Agent '{name}' not found.")
            return
        agent = agents[name]
        print(f"""
  {C.BOLD}Agent: {name}{C.R}
  Description: {agent.get('description', '-')}
  Created: {agent.get('created', '-')}
  Deployed to: {', '.join(agent.get('deployed_to', [])) or '-'}

  {C.CYAN}Logic:{C.R}
{agent.get('logic', '(no logic defined)')}

  {C.CYAN}Code:{C.R}
  {os.path.join(AGENTS_DIR, name + '.py')}
""")
        return

    # === DEPLOY ===
    if action == "deploy" and len(args) >= 2:
        name = args[1]
        servers = args[2:] if len(args) > 2 else []
        _agent_deploy(name, servers)
        return

    # === RUN ===
    if action == "run" and len(args) > 1:
        name = args[1]
        _agent_run(name)
        return

    # === DELETE ===
    if action == "delete" and len(args) > 1:
        name = args[1]
        path = os.path.join(AGENTS_DIR, f"{name}.json")
        code_path = os.path.join(AGENTS_DIR, f"{name}.py")
        if os.path.exists(path):
            os.remove(path)
            if os.path.exists(code_path):
                os.remove(code_path)
            print(f"  Deleted agent '{name}'")
        else:
            print(f"  Agent '{name}' not found.")
        return

    # === WORKFLOW ===
    if action == "workflow":
        _agent_workflow(args[1:])
        return

    # === CHAIN ===
    if action == "chain":
        _agent_chain(args[1:])
        return

    print(f"  Unknown action: {action}")


def _validate_agent_code(code_path):
    """Validate agent code syntax and run test"""
    # Syntax check
    try:
        result = subprocess.run(
            f"python3 -m py_compile {code_path}",
            shell=True, capture_output=True, text=True, timeout=10
        )
        if result.returncode != 0:
            return False, f"Syntax error: {result.stderr}"
    except Exception as e:
        return False, f"Compile check failed: {e}"

    # Quick test run (timeout 5s, limited servers)
    try:
        env = os.environ.copy()
        env["AGENT_TEST"] = "1"  # Agent can check this to run minimal test
        result = subprocess.run(
            f"python3 {code_path}",
            shell=True, capture_output=True, text=True, timeout=10, env=env
        )
        if result.returncode != 0 and "Error" in result.stderr:
            return False, f"Runtime error: {result.stderr[:200]}"
        return True, result.stdout[:100]
    except subprocess.TimeoutExpired:
        return True, "(timeout - likely working)"
    except Exception as e:
        return False, f"Test failed: {e}"


def _agent_make_noninteractive(name, description, behavior):
    """Non-interactive agent creation with validation and retry"""
    config = _load_config() or {}
    servers = list(config.get("servers", {}).keys())

    print(f"\n  Creating: {name}")
    print(f"  Desc: {description}")
    print(f"  Behavior: {behavior}")

    prompt = f"""Create a Python agent script.
Name: {name}
Description: {description}
Behavior: {behavior}
Servers: {servers}

Available functions:
- SERVERS = {servers}
- mpop_exec(server, cmd) -> str
- run_cmd(cmd) -> str
- get_input() -> dict  # JSON input from previous agent in chain
- output(data) -> None  # JSON output for next agent

Return ONLY the main() function body, indented with 4 spaces.
Keep it simple. Use try/except for error handling.

Example:
    for server in SERVERS[:3]:  # limit for testing
        result = mpop_exec(server, 'uptime')
        print(f"{{server}}: {{result.split(',')[0]}}")
"""

    max_retries = 2
    last_error = None

    for attempt in range(max_retries + 1):
        if attempt > 0:
            print(f"  Retry {attempt}/{max_retries}...")
            # Add error feedback to prompt
            prompt += f"\n\nPrevious attempt failed with: {last_error}\nFix the error."

        print(f"  Generating...", end=" ", flush=True)
        logic = None
        try:
            logic = _ai_call_ollama_text(prompt)
        except Exception:
            pass
        if not logic or len(logic) < 20:
            try:
                logic = _ai_call(prompt, max_tokens=1000)
            except Exception:
                pass

        if not logic or len(logic) < 20:
            logic = f'''    for server in SERVERS[:3]:
        print(f"{{server}}: ", end="", flush=True)
        result = mpop_exec(server, "uptime")
        print(result.split(",")[0] if result else "-")
'''

        # Clean up AI output
        logic = logic.strip()
        if logic.startswith("```"):
            lines = logic.split("\n")
            logic = "\n".join(lines[1:])
        if logic.rstrip().endswith("```"):
            lines = logic.split("\n")
            logic = "\n".join(lines[:-1])
        logic = logic.strip()

        agent = {
            "name": name,
            "description": description,
            "behavior": behavior,
            "logic": logic,
            "created": time.strftime("%Y-%m-%d %H:%M"),
            "deployed_to": [],
        }

        code = _generate_agent_code(agent)
        code_path = os.path.join(AGENTS_DIR, f"{name}.py")
        with open(code_path, "w") as f:
            f.write(code)
        os.chmod(code_path, 0o755)

        # Validate
        valid, msg = _validate_agent_code(code_path)
        if valid:
            print(f"{C.GREEN}OK{C.R}")
            _save_agent(agent)
            print(f"  Test: {msg[:50]}")
            print(f"  Saved: {code_path}")
            return
        else:
            print(f"{C.RED}FAIL{C.R}")
            last_error = msg
            print(f"  Error: {msg[:80]}")

    print(f"  {C.YELLOW}Saved with warnings (may need manual fix){C.R}")
    _save_agent(agent)


def _agent_make_interactive(name=None):
    """Interactive agent creation with AI assistance"""
    # Get server list from config
    config = _load_config() or {}
    servers = list(config.get("servers", {}).keys())

    print(f"""
  {C.BOLD}Create New Agent{C.R}

  Describe what you want the agent to do, and AI will help create it.
  Available servers: {', '.join(servers)}
""")

    if not name:
        name = input("  Agent name: ").strip()
        if not name:
            print("  Cancelled.")
            return

    print(f"\n  Creating agent: {C.GREEN}{name}{C.R}")
    description = input("  Description (what does it do?): ").strip()

    print(f"""
  {C.CYAN}Describe the agent's behavior:{C.R}
  (e.g., "Check disk usage on all servers and alert if > 90%")
  (e.g., "Monitor log files for errors and send summary")
""")

    behavior = input("  Behavior: ").strip()

    if not behavior:
        print("  Cancelled.")
        return

    print(f"\n  {C.YELLOW}Generating agent code with AI...{C.R}")

    prompt = f"""Create a Python agent script for:
Name: {name}
Description: {description}
Behavior: {behavior}
Available servers: {servers}

The script will have these available:
- SERVERS = {servers}  # list of server names
- mpop_exec(server, cmd) -> str  # run command on server via mpop
- run_cmd(cmd) -> str  # run local shell command

Requirements:
- Use mpop_exec(server, cmd) for remote commands
- Use SERVERS list to iterate over servers
- Include error handling
- Print status updates
- Keep it simple and focused

Return ONLY the main() function body (without def main():), indented with 4 spaces.
"""

    logic = None
    try:
        logic = _ai_call_ollama_text(prompt)
    except Exception:
        pass

    if not logic or len(logic) < 20:
        try:
            logic = _ai_call(prompt, max_tokens=1000)
        except Exception:
            pass

    if not logic or len(logic) < 20:
        # Fallback: simple working template
        logic = f'''    print("Running {name}...")
    for server in SERVERS:
        print(f"  {{server}}: ", end="", flush=True)
        result = mpop_exec(server, "uptime")
        print(result.split(",")[0] if result else "error")
'''

    # Clean up logic
    logic = logic.strip()
    if logic.startswith("```"):
        logic = "\n".join(logic.split("\n")[1:-1])

    # Create agent
    agent = {
        "name": name,
        "description": description,
        "behavior": behavior,
        "logic": logic,
        "created": time.strftime("%Y-%m-%d %H:%M"),
        "deployed_to": [],
    }

    # Save agent JSON
    path = _save_agent(agent)

    # Generate and save Python code
    code = _generate_agent_code(agent)
    code_path = os.path.join(AGENTS_DIR, f"{name}.py")
    with open(code_path, "w") as f:
        f.write(code)
    os.chmod(code_path, 0o755)

    print(f"""
  {C.GREEN}Agent created!{C.R}

  Config: {path}
  Code:   {code_path}

  {C.CYAN}Next steps:{C.R}
    mpop agent show {name}        # View details
    mpop agent run {name}         # Test locally
    mpop agent deploy {name} node1 node2  # Deploy to servers
""")


def _agent_deploy(name, servers):
    """Deploy agent to servers"""
    agents = _load_agents()
    if name not in agents:
        print(f"  Agent '{name}' not found.")
        return

    agent = agents[name]
    code_path = os.path.join(AGENTS_DIR, f"{name}.py")

    if not os.path.exists(code_path):
        print(f"  Agent code not found: {code_path}")
        return

    if not servers:
        # Get servers from config
        config = _load_config() or {}
        servers = list(config.get("servers", {}).keys())
        if not servers:
            print("  No servers specified and none in config.")
            return
        print(f"  Deploying to all servers: {', '.join(servers)}")

    print(f"\n  {C.BOLD}Deploying {name} to {len(servers)} servers...{C.R}\n")

    deployed = []
    for srv in servers:
        print(f"  {srv}: ", end="", flush=True)
        try:
            # Copy agent to server
            result = subprocess.run(
                f"scp {code_path} {srv}:/tmp/{name}.py",
                shell=True, capture_output=True, text=True, timeout=30
            )
            if result.returncode == 0:
                # Make executable
                subprocess.run(
                    f"ssh {srv} 'chmod +x /tmp/{name}.py'",
                    shell=True, capture_output=True, timeout=10
                )
                print(f"{C.GREEN}OK{C.R}")
                deployed.append(srv)
            else:
                print(f"{C.RED}FAIL{C.R} - {result.stderr[:50]}")
        except Exception as e:
            print(f"{C.RED}FAIL{C.R} - {e}")

    # Update agent with deployment info
    agent["deployed_to"] = list(set(agent.get("deployed_to", []) + deployed))
    _save_agent(agent)

    print(f"\n  Deployed to {len(deployed)}/{len(servers)} servers.")
    print(f"  Run with: ssh <server> 'python3 /tmp/{name}.py'")


def _agent_run(name):
    """Run agent locally"""
    code_path = os.path.join(AGENTS_DIR, f"{name}.py")
    if not os.path.exists(code_path):
        print(f"  Agent '{name}' not found.")
        return

    print(f"  {C.CYAN}Running {name}...{C.R}\n")
    try:
        result = subprocess.run(
            f"python3 {code_path}",
            shell=True, text=True, timeout=300
        )
    except KeyboardInterrupt:
        print(f"\n  {C.YELLOW}Interrupted.{C.R}")
    except Exception as e:
        print(f"  {C.RED}Error: {e}{C.R}")


def _agent_workflow(args):
    """Create workflow with multiple agents"""
    wf_dir = os.path.expanduser("~/.mpop/workflows")
    os.makedirs(wf_dir, exist_ok=True)

    if not args:
        workflows = glob.glob(os.path.join(wf_dir, "*.json"))
        print(f"\n  WORKFLOWS")
        print(f"  {'─' * 60}")
        print(f"  Name            │ Agents")
        print(f"  {'─' * 60}")
        if workflows:
            for wf in workflows:
                with open(wf) as f:
                    data = json.load(f)
                name = os.path.basename(wf).replace(".json", "")
                agents = data.get("agents", [])
                flow = " -> ".join(agents[:4])
                if len(agents) > 4:
                    flow += " -> ..."
                print(f"  {name:15} │ {flow}")
        else:
            print(f"  (none)          │ use: mpop agent workflow create <name> <agents...>")
        print(f"  {'═' * 60}\n")
        return

    action = args[0]

    if action == "list":
        _agent_workflow([])
        return

    if action == "show" and len(args) > 1:
        wf_name = args[1]
        wf_path = os.path.join(wf_dir, f"{wf_name}.json")
        if not os.path.exists(wf_path):
            print(f"  Workflow '{wf_name}' not found.")
            return
        with open(wf_path) as f:
            wf = json.load(f)
        agents = wf.get("agents", [])
        print(f"\n  Workflow: {wf_name}")
        print(f"  Created:  {wf.get('created', '-')}")
        print(f"  Flow:     {' -> '.join(agents)}\n")
        return

    if action == "create" and len(args) > 1:
        wf_name = args[1]
        if len(args) > 2:
            agents_list = args[2:]
        else:
            print(f"  Usage: mpop agent workflow create <name> <agent1> <agent2> ...")
            return

        workflow = {
            "name": wf_name,
            "agents": agents_list,
            "created": time.strftime("%Y-%m-%d %H:%M"),
        }
        with open(os.path.join(wf_dir, f"{wf_name}.json"), "w") as f:
            json.dump(workflow, f, indent=2)
        print(f"  Created: {wf_name} ({' -> '.join(agents_list)})")
        return

    if action == "run" and len(args) > 1:
        wf_name = args[1]
        wf_path = os.path.join(wf_dir, f"{wf_name}.json")
        if not os.path.exists(wf_path):
            print(f"  Workflow '{wf_name}' not found.")
            return

        with open(wf_path) as f:
            workflow = json.load(f)

        agents = workflow.get("agents", [])
        print(f"\n  Workflow: {wf_name}")
        print(f"  {'─' * 50}")
        for i, agent in enumerate(agents, 1):
            print(f"  [{i}/{len(agents)}] {agent}: ", end="", flush=True)
            code_path = os.path.join(AGENTS_DIR, f"{agent}.py")
            if os.path.exists(code_path):
                try:
                    result = subprocess.run(f"python3 {code_path}", shell=True,
                                          capture_output=True, text=True, timeout=60)
                    print(f"{C.GREEN}OK{C.R}")
                except Exception:
                    print(f"{C.RED}error{C.R}")
            else:
                print(f"{C.RED}not found{C.R}")
        print(f"  {'═' * 50}\n")
        return

    if action == "delete" and len(args) > 1:
        wf_name = args[1]
        wf_path = os.path.join(wf_dir, f"{wf_name}.json")
        if os.path.exists(wf_path):
            os.remove(wf_path)
            print(f"  Deleted workflow: {wf_name}")
        else:
            print(f"  Workflow '{wf_name}' not found.")
        return

    print(f"  Unknown: {action}")


def _agent_chain(args):
    """Chain agents together (output of one -> input of next)"""
    chain_dir = os.path.expanduser("~/.mpop/chains")
    os.makedirs(chain_dir, exist_ok=True)

    if not args or len(args) < 2:
        # List saved chains
        chains = glob.glob(os.path.join(chain_dir, "*.json"))
        print(f"\n  CHAINS")
        print(f"  {'─' * 60}")
        print(f"  Name            │ Agents")
        print(f"  {'─' * 60}")
        if chains:
            for ch in chains:
                with open(ch) as f:
                    data = json.load(f)
                name = os.path.basename(ch).replace(".json", "")
                agents = data.get("agents", [])
                flow = " -> ".join(agents[:4])
                if len(agents) > 4:
                    flow += " -> ..."
                print(f"  {name:15} │ {flow}")
        else:
            print(f"  (none)          │ use: mpop agent chain save <name> <agents...>")
        print(f"  {'═' * 60}")
        print(f"\n  Usage:")
        print(f"    mpop agent chain <a1> <a2>        Run chain directly")
        print(f"    mpop agent chain save <n> <a1>..  Save chain")
        print(f"    mpop agent chain run <name>       Run saved chain")
        print(f"    mpop agent chain delete <name>    Delete chain\n")
        return

    # Save chain
    if args[0] == "save" and len(args) > 2:
        chain_name = args[1]
        agents_list = args[2:]
        chain = {
            "name": chain_name,
            "agents": agents_list,
            "created": time.strftime("%Y-%m-%d %H:%M"),
        }
        with open(os.path.join(chain_dir, f"{chain_name}.json"), "w") as f:
            json.dump(chain, f, indent=2)
        print(f"  Saved chain: {chain_name} ({' -> '.join(agents_list)})")
        return

    # Run saved chain
    if args[0] == "run" and len(args) > 1:
        chain_name = args[1]
        chain_path = os.path.join(chain_dir, f"{chain_name}.json")
        if not os.path.exists(chain_path):
            print(f"  Chain '{chain_name}' not found.")
            return
        with open(chain_path) as f:
            chain = json.load(f)
        args = chain.get("agents", [])
        # Fall through to run

    # Delete chain
    if args[0] == "delete" and len(args) > 1:
        chain_name = args[1]
        chain_path = os.path.join(chain_dir, f"{chain_name}.json")
        if os.path.exists(chain_path):
            os.remove(chain_path)
            print(f"  Deleted chain: {chain_name}")
        else:
            print(f"  Chain '{chain_name}' not found.")
        return

    print(f"\n  Chain: {' -> '.join(args)}\n")

    prev_output = ""
    for i, name in enumerate(args):
        code_path = os.path.join(AGENTS_DIR, f"{name}.py")
        print(f"  [{i+1}] {name}: ", end="", flush=True)

        if not os.path.exists(code_path):
            print(f"{C.RED}not found{C.R}")
            continue

        try:
            env = os.environ.copy()
            env["AGENT_INPUT"] = prev_output
            result = subprocess.run(
                f"python3 {code_path}",
                shell=True, capture_output=True, text=True,
                timeout=60, env=env
            )
            prev_output = result.stdout.strip()
            print(f"{C.GREEN}OK{C.R} ({len(prev_output)} bytes)")
        except Exception as e:
            print(f"{C.RED}error{C.R}")
            break

    if prev_output:
        print(f"\n  Output: {prev_output[:100]}{'...' if len(prev_output) > 100 else ''}")
    print()


def cmd_workflow(args):
    """Workflow automation (n8n style)

    Define and run automation workflows.
    """
    if not args:
        print(f"""
  {C.BOLD}mpop workflow{C.R} - workflow automation

  {C.BOLD}USAGE:{C.R}
    mpop workflow list              List saved workflows
    mpop workflow run <name>        Run workflow
    mpop workflow create <name>     Create new workflow
    mpop workflow show <name>       Show workflow details
    mpop workflow delete <name>     Delete workflow

  {C.BOLD}QUICK WORKFLOWS:{C.R}
    mpop workflow run health        Full server health check
    mpop workflow run security      Security check
    mpop workflow run morning       Morning report

  {C.BOLD}CREATE EXAMPLE:{C.R}
    mpop workflow create backup-check << 'EOF'
    - name: Check disk space
      run: mpop exec "df -h | grep -E '([89][0-9]|100)%'"
    - name: Alert if critical
      condition: "output contains '9'"
      run: mpop alert send "Disk space critical!"
    EOF
""")
        return

    action = args[0]

    # Built-in workflows
    BUILTIN_WORKFLOWS = {
        "health": {
            "name": "Health Check",
            "description": "Check all server status",
            "steps": [
                {"name": "Connection test", "cmd": "config test"},
                {"name": "Services check", "cmd": "services"},
                {"name": "Disk usage", "cmd": "exec 'df -h | head -5'"},
                {"name": "Load average", "cmd": "exec 'uptime'"},
            ]
        },
        "security": {
            "name": "Security Check",
            "description": "Check security status",
            "steps": [
                {"name": "Security overview", "cmd": "security"},
                {"name": "Open ports", "cmd": "exec 'ss -tlnp | head -20'"},
            ]
        },
        "morning": {
            "name": "Morning Report",
            "description": "Morning report",
            "steps": [
                {"name": "Server status", "cmd": ""},
                {"name": "Security summary", "cmd": "security"},
                {"name": "AI analysis", "cmd": "ai"},
            ]
        },
        "deploy-check": {
            "name": "Deploy Readiness",
            "description": "Pre-deploy check",
            "steps": [
                {"name": "Connection test", "cmd": "config test"},
                {"name": "Git status", "cmd": "exec -t relay1 'cd /app && git status'"},
                {"name": "Disk space", "cmd": "exec 'df -h /'"},
            ]
        }
    }

    if action == "list":
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}📋 Workflows{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  {C.BOLD}Built-in:{C.R}""")
        for name, wf in BUILTIN_WORKFLOWS.items():
            print(f"    {name:<15} {wf['description']}")

        # User-defined workflows
        os.makedirs(WORKFLOW_DIR, exist_ok=True)
        user_wfs = [f[:-5] for f in os.listdir(WORKFLOW_DIR) if f.endswith(".json")]
        if user_wfs:
            print(f"\n  {C.BOLD}User-defined:{C.R}")
            for name in user_wfs:
                print(f"    {name}")
        print()
        return

    if action == "run":
        if len(args) < 2:
            print("  Usage: mpop workflow run <name>")
            return

        wf_name = args[1]

        # Check built-in workflows
        if wf_name in BUILTIN_WORKFLOWS:
            wf = BUILTIN_WORKFLOWS[wf_name]
        else:
            # Load user-defined workflows
            wf_path = os.path.join(WORKFLOW_DIR, f"{wf_name}.json")
            if not os.path.exists(wf_path):
                print(f"  Workflow not found: {wf_name}")
                return
            with open(wf_path) as f:
                wf = json.load(f)

        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}▶ Running: {wf['name']}{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

        steps = wf.get("steps", [])
        for i, step in enumerate(steps, 1):
            name = step.get("name", f"Step {i}")
            cmd = step.get("cmd", "")

            print(f"  [{i}/{len(steps)}] {name}...", end=" ", flush=True)

            if not cmd:
                # Empty command is dashboard
                cmd = ""

            try:
                result = subprocess.run(
                    f"/usr/local/bin/mpop {cmd}",
                    shell=True, capture_output=True, text=True, timeout=120
                )
                print(f"{C.GREEN}✓{C.R}")

                # Output summary (first 5 lines)
                import re
                output = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout)
                lines = [l for l in output.split('\n') if l.strip()][:5]
                for line in lines:
                    print(f"       {C.DIM}{line[:70]}{C.R}")

            except subprocess.TimeoutExpired:
                print(f"{C.YELLOW}timeout{C.R}")
            except Exception as e:
                print(f"{C.RED}✗ {e}{C.R}")

            print()

        print(f"  {C.GREEN}Workflow completed!{C.R}")
        return

    if action == "show":
        if len(args) < 2:
            print("  Usage: mpop workflow show <name>")
            return

        wf_name = args[1]
        if wf_name in BUILTIN_WORKFLOWS:
            wf = BUILTIN_WORKFLOWS[wf_name]
            print(f"\n  {C.BOLD}{wf['name']}{C.R} (built-in)")
            print(f"  {wf['description']}\n")
            print(f"  {C.BOLD}Steps:{C.R}")
            for i, step in enumerate(wf['steps'], 1):
                print(f"    {i}. {step['name']}")
                if step.get('cmd'):
                    print(f"       {C.DIM}mpop {step['cmd']}{C.R}")
            print()
        return

    print(f"  Unknown action: {action}")


# ============================================================================
# PROJECT MEMORY (arcmemory - remove AI hallucination)
# ============================================================================

MEMORY_DIR = os.path.expanduser("~/.mpop/memory")

def _memory_scan_project(project_path: str) -> dict:
    """Scan project to build memory structure"""
    from pathlib import Path
    import re

    project_path = os.path.expanduser(project_path)
    if not os.path.isdir(project_path):
        return {"error": f"Not a directory: {project_path}"}

    project_name = os.path.basename(os.path.abspath(project_path))
    memory_path = os.path.join(MEMORY_DIR, project_name)
    os.makedirs(memory_path, exist_ok=True)

    # Scan settings
    SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", "chroma_db",
                 "logs", "dist", "build", ".cache", ".eggs", "*.egg-info"}
    SKIP_EXT = {".bin", ".pt", ".safetensors", ".npy", ".npz", ".pkl", ".tar",
                ".gz", ".zip", ".7z", ".mp4", ".jpg", ".png", ".gif", ".ico"}
    TEXT_EXT = {".py", ".md", ".txt", ".yaml", ".yml", ".json", ".sh", ".js",
                ".ts", ".tsx", ".jsx", ".html", ".css", ".toml", ".cfg", ".ini"}

    VERSION_RE = re.compile(r"\bv\d+(?:\.\d+)*\b", re.IGNORECASE)
    TODO_RE = re.compile(r"\b(TODO|FIXME|HACK|NOTE|XXX)\b:?\s*(.*)", re.IGNORECASE)
    PY_DEF_RE = re.compile(r"^\s*def\s+([a-zA-Z_][\w]*)\s*\(")
    PY_CLASS_RE = re.compile(r"^\s*class\s+([a-zA-Z_][\w]*)\b")

    files = []
    todos = []
    functions = []
    versions = []

    def should_skip(path):
        parts = Path(path).parts
        return any(d in SKIP_DIRS for d in parts)

    for root, dirs, filenames in os.walk(project_path):
        # Skip excluded directories
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith('.')]

        for fname in filenames:
            fpath = os.path.join(root, fname)
            rel_path = os.path.relpath(fpath, project_path)

            if should_skip(rel_path):
                continue

            ext = os.path.splitext(fname)[1].lower()
            if ext in SKIP_EXT:
                continue

            try:
                stat = os.stat(fpath)
                if stat.st_size > 10 * 1024 * 1024:  # Skip > 10MB
                    continue

                files.append({
                    "path": rel_path,
                    "size": stat.st_size,
                    "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                    "ext": ext
                })

                if ext not in TEXT_EXT:
                    continue

                # Read and analyze text files
                try:
                    with open(fpath, 'r', errors='ignore') as f:
                        content = f.read(200000)  # Max 200KB per file
                except Exception:
                    continue

                # Extract versions
                for m in VERSION_RE.finditer(content):
                    versions.append({"path": rel_path, "version": m.group(0)})

                # Extract TODOs
                for line_no, line in enumerate(content.splitlines(), 1):
                    m = TODO_RE.search(line)
                    if m:
                        todos.append({
                            "path": rel_path,
                            "line": line_no,
                            "tag": m.group(1).upper(),
                            "text": m.group(2).strip()[:200]
                        })

                # Extract Python functions/classes
                if ext == ".py":
                    for line_no, line in enumerate(content.splitlines(), 1):
                        local = PY_DEF_RE.match(line)
                        if local:
                            functions.append({
                                "path": rel_path,
                                "line": line_no,
                                "kind": "def",
                                "name": local.group(1)
                            })
                        m2 = PY_CLASS_RE.match(line)
                        if m2:
                            functions.append({
                                "path": rel_path,
                                "line": line_no,
                                "kind": "class",
                                "name": m2.group(1)
                            })
            except Exception:
                continue

    # Deduplicate versions
    seen_versions = set()
    unique_versions = []
    for v in versions:
        key = (v["path"], v["version"])
        if key not in seen_versions:
            seen_versions.add(key)
            unique_versions.append(v)

    # Save memory files
    result = {
        "project": project_name,
        "path": project_path,
        "scanned_at": datetime.now().isoformat(),
        "stats": {
            "files": len(files),
            "todos": len(todos),
            "functions": len(functions),
            "versions": len(unique_versions)
        }
    }

    # Write JSON files
    with open(os.path.join(memory_path, "files.json"), 'w') as f:
        json.dump({"project": project_name, "files": files}, f, ensure_ascii=False, indent=2)
    with open(os.path.join(memory_path, "todos.json"), 'w') as f:
        json.dump({"project": project_name, "todos": todos}, f, ensure_ascii=False, indent=2)
    with open(os.path.join(memory_path, "functions.json"), 'w') as f:
        json.dump({"project": project_name, "symbols": functions}, f, ensure_ascii=False, indent=2)
    with open(os.path.join(memory_path, "versions.json"), 'w') as f:
        json.dump({"project": project_name, "versions": unique_versions}, f, ensure_ascii=False, indent=2)
    with open(os.path.join(memory_path, "meta.json"), 'w') as f:
        json.dump(result, f, ensure_ascii=False, indent=2)

    return result


def _memory_ask(project_name: str, question: str) -> str:
    """2-Agent system query (Historian + Advisor)"""
    memory_path = os.path.join(MEMORY_DIR, project_name)

    if not os.path.isdir(memory_path):
        return f"Memory not found for project: {project_name}\nRun: mpop memory scan <path>"

    # Load memory files
    evidence = []

    for fname in ["functions.json", "todos.json", "files.json", "versions.json"]:
        fpath = os.path.join(memory_path, fname)
        if os.path.exists(fpath):
            try:
                with open(fpath, 'r') as f:
                    data = json.load(f)
                    # Summarize data
                    if fname == "functions.json":
                        symbols = data.get("symbols", [])[:50]
                        evidence.append(f"[FUNCTIONS ({len(data.get('symbols', []))} total)]\n" +
                            "\n".join([f"  {s['kind']} {s['name']} ({s['path']}:{s['line']})" for s in symbols]))
                    elif fname == "todos.json":
                        todos = data.get("todos", [])[:30]
                        evidence.append(f"[TODOS ({len(data.get('todos', []))} total)]\n" +
                            "\n".join([f"  {t['tag']}: {t['text'][:80]} ({t['path']}:{t['line']})" for t in todos]))
                    elif fname == "files.json":
                        files = data.get("files", [])[:30]
                        evidence.append(f"[FILES ({len(data.get('files', []))} total)]\n" +
                            "\n".join([f"  {f['path']} ({f['size']}B)" for f in files]))
                    elif fname == "versions.json":
                        versions = data.get("versions", [])[:20]
                        evidence.append(f"[VERSIONS]\n" +
                            "\n".join([f"  {v['version']} in {v['path']}" for v in versions]))
            except Exception:
                pass

    if not evidence:
        return "No evidence found in memory. Re-scan the project."

    evidence_text = "\n\n".join(evidence)

    # 2-Agent System
    ollama_host = _get_ollama_host()
    memory_model = _get_config("memory_model", _get_config("llm_model", "gpt-oss:20b"))

    # Check Korean
    has_korean = any('\uac00' <= ch <= '\ud7a3' for ch in question)

    # Historian prompt (past/review)
    if has_korean:
        historian_prompt = f"""You are the Historian (recall specialist).
Rules:
- Never speculate beyond provided EVIDENCE
- If no evidence: state "no evidence" first
- Answer only from code structure and history

EVIDENCE:
{evidence_text}

question: {question}

Output format:
1) Conclusion (one line)
2) Evidence (with citations)
3) Items needing verification"""
    else:
        historian_prompt = f"""You are the Historian. Use ONLY the EVIDENCE. No speculation.
If no evidence, say "No evidence found" first.

EVIDENCE:
{evidence_text}

Question: {question}

Output:
1) Conclusion (one line)
2) Evidence (with citations)
3) Follow-up needed"""

    # Advisor prompt (future/design)
    if has_korean:
        advisor_prompt = f"""You are the Advisor (design/refactoring specialist).
Rules:
- Respect the Historian's facts
- Mark recommendations with "Recommendation:"
- Provide actionable advice

EVIDENCE:
{evidence_text}

Historian analysis: {{historian_response}}

question: {question}

Output format:
Options A/B/C (pros, cons, first step each)"""
    else:
        advisor_prompt = f"""You are the Advisor. Respect facts from Historian.
Provide actionable options.

EVIDENCE:
{evidence_text}

Historian analysis: {{historian_response}}

Question: {question}

Output: Options A/B/C with pros, cons, and first step."""

    try:
        import urllib.request

        # Call Historian
        req_data = json.dumps({
            "model": memory_model,
            "prompt": historian_prompt,
            "stream": False
        }).encode()

        req = urllib.request.Request(
            f"http://{ollama_host}/api/generate",
            data=req_data,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req, timeout=60) as resp:
            result = json.loads(resp.read())
            historian_response = result.get("response", "").strip()

        # Call Advisor
        advisor_prompt_filled = advisor_prompt.replace("{historian_response}", historian_response[:1500])
        req_data2 = json.dumps({
            "model": memory_model,
            "prompt": advisor_prompt_filled,
            "stream": False
        }).encode()

        req2 = urllib.request.Request(
            f"http://{ollama_host}/api/generate",
            data=req_data2,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req2, timeout=60) as resp2:
            result2 = json.loads(resp2.read())
            advisor_response = result2.get("response", "").strip()

        return f"""## A) Historian (review/facts)
{historian_response}

---

## B) Advisor (advice/future)
{advisor_response}"""

    except Exception as e:
        return f"LLM Error: {e}\n\nRaw Evidence:\n{evidence_text[:2000]}"


def cmd_memory(args):
    """Project Memory - AI hallucination prevention

    Scans your project to build memory and runs evidence-based queries.
    Historian (past/recall) + Advisor (future/advice) 2-Agent system.
    """
    if not args:
        print(f"""
  {C.BOLD}mpop memory{C.R} - Project Memory (AI hallucination prevention)

  {C.BOLD}USAGE:{C.R}
    mpop memory scan <path>          scan project to build memory
    mpop memory ask <project> "question"  2-Agent query (Historian+Advisor)
    mpop memory list                 list saved memories
    mpop memory status <project>     check memory status
    mpop memory delete <project>     delete memory

  {C.BOLD}EXAMPLES:{C.R}
    mpop memory scan ~/MyProject
    mpop memory ask MyProject "why was this function created?"
    mpop memory ask mpop "show TODO status"

  {C.BOLD}2-AGENT SYSTEM:{C.R}
    Historian: past/recall -- answers only from code facts
    Advisor:   future/design -- refactoring and improvement suggestions

  {C.BOLD}WHY:{C.R}
    Instead of AI-hallucinated answers, extract from actual code
    Evidence (functions, TODOs, file structure) as the basis.
""")
        return

    action = args[0]

    if action == "scan":
        if len(args) < 2:
            print("  Usage: mpop memory scan <path>")
            return

        path = args[1]
        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🧠 Project Memory - Scan{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  Scanning: {path}
""")
        print("  [1/4] Scanning files...", end=" ", flush=True)
        result = _memory_scan_project(path)

        if "error" in result:
            print(f"{C.RED}✗{C.R}")
            print(f"  Error: {result['error']}")
            return

        print(f"{C.GREEN}✓{C.R}")
        print(f"  [2/4] Extracting functions...", end=" ", flush=True)
        print(f"{C.GREEN}✓{C.R} ({result['stats']['functions']} found)")
        print(f"  [3/4] Finding TODOs...", end=" ", flush=True)
        print(f"{C.GREEN}✓{C.R} ({result['stats']['todos']} found)")
        print(f"  [4/4] Saving memory...", end=" ", flush=True)
        print(f"{C.GREEN}✓{C.R}")

        print(f"""
  {C.BOLD}Results:{C.R}
    Project:   {result['project']}
    Files:     {result['stats']['files']}
    Functions: {result['stats']['functions']}
    TODOs:     {result['stats']['todos']}
    Versions:  {result['stats']['versions']}

  Memory saved to: ~/.mpop/memory/{result['project']}/

  {C.GREEN}Ready!{C.R} Try: mpop memory ask {result['project']} "explain project structure"
""")

    elif action == "ask":
        if len(args) < 3:
            print("  Usage: mpop memory ask <project> \"question\"")
            return

        project = args[1]
        question = " ".join(args[2:])

        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🧠 Project Memory - Ask{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  Project:  {project}
  Question: {question}
""")
        print("  Querying 2-Agent system...", end=" ", flush=True)

        response = _memory_ask(project, question)
        print(f"{C.GREEN}✓{C.R}")
        print()
        print(response)
        print()

    elif action == "list":
        os.makedirs(MEMORY_DIR, exist_ok=True)
        projects = []

        for name in os.listdir(MEMORY_DIR):
            meta_path = os.path.join(MEMORY_DIR, name, "meta.json")
            if os.path.exists(meta_path):
                try:
                    with open(meta_path, 'r') as f:
                        meta = json.load(f)
                        projects.append(meta)
                except Exception:
                    projects.append({"project": name, "stats": {}})

        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🧠 Project Memories{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")
        if not projects:
            print("  No memories found.")
            print("  Create one: mpop memory scan <path>")
        else:
            print(f"  {'Project':<20} {'Files':<8} {'Funcs':<8} {'TODOs':<8} {'Scanned':<20}")
            print(f"  {'-'*70}")
            for p in projects:
                stats = p.get("stats", {})
                scanned = p.get("scanned_at", "")[:16]
                print(f"  {p.get('project', '?'):<20} {stats.get('files', '?'):<8} {stats.get('functions', '?'):<8} {stats.get('todos', '?'):<8} {scanned:<20}")
        print()

    elif action == "status":
        if len(args) < 2:
            print("  Usage: mpop memory status <project>")
            return

        project = args[1]
        meta_path = os.path.join(MEMORY_DIR, project, "meta.json")

        if not os.path.exists(meta_path):
            print(f"  Memory not found: {project}")
            return

        with open(meta_path, 'r') as f:
            meta = json.load(f)

        print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🧠 Memory Status: {project}{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}

  Path:      {meta.get('path', '?')}
  Scanned:   {meta.get('scanned_at', '?')}

  Stats:
    Files:     {meta.get('stats', {}).get('files', '?')}
    Functions: {meta.get('stats', {}).get('functions', '?')}
    TODOs:     {meta.get('stats', {}).get('todos', '?')}
    Versions:  {meta.get('stats', {}).get('versions', '?')}

  Memory files:
""")
        memory_path = os.path.join(MEMORY_DIR, project)
        for fname in os.listdir(memory_path):
            fpath = os.path.join(memory_path, fname)
            size = os.path.getsize(fpath)
            print(f"    {fname:<20} {size:>10,} bytes")
        print()

    elif action == "delete":
        if len(args) < 2:
            print("  Usage: mpop memory delete <project>")
            return

        project = args[1]
        memory_path = os.path.join(MEMORY_DIR, project)

        if not os.path.isdir(memory_path):
            print(f"  Memory not found: {project}")
            return

        import shutil
        shutil.rmtree(memory_path)
        print(f"  Deleted memory: {project}")

    else:
        print(f"  Unknown action: {action}")
        print("  Try: mpop memory --help")


def cmd_chain(args):
    """LLM chaining (LangChain style)

    Chain multiple analysis steps to perform complex tasks.
    """
    if not args:
        print(f"""
  {C.BOLD}mpop chain{C.R} - AI chaining (multi-step analysis)

  {C.BOLD}USAGE:{C.R}
    mpop chain analyze              Full infrastructure analysis
    mpop chain optimize             Optimization suggestions
    mpop chain troubleshoot <issue>  resolve issue chain
    mpop chain "<natural language task>"       Run chain with natural language

  {C.BOLD}EXAMPLES:{C.R}
    mpop chain analyze
    mpop chain "find ways to reduce server costs"
    mpop chain troubleshoot "node2 is slow"
""")
        return

    action = args[0] if args else "analyze"
    query = " ".join(args) if not args[0] in ["analyze", "optimize", "troubleshoot"] else " ".join(args[1:])

    print(f"""
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
   {C.CYAN}🔗 AI Chain{C.R}
  {C.BOLD}═══════════════════════════════════════════════════════════════{C.R}
""")

    if action == "analyze":
        # 4-step analysis chain
        chain_steps = [
            ("Collecting server metrics", "exec 'uptime && df -h / && free -h'"),
            ("Checking services", "services"),
            ("Security scan", "security"),
            ("Generate analysis", None)  # LLM analysis
        ]
    elif action == "optimize":
        chain_steps = [
            ("Resource usage scan", "exec 'ps aux --sort=-%mem | head -10'"),
            ("Disk analysis", "exec 'du -sh /* 2>/dev/null | sort -hr | head -10'"),
            ("Docker containers", "exec 'docker ps --format \"{{.Names}}: {{.Status}}\" 2>/dev/null'"),
            ("Generate recommendations", None)
        ]
    elif action == "troubleshoot":
        chain_steps = [
            ("Check connectivity", "config test"),
            ("Recent logs", f"logs {query.split()[0] if query else 'relay1'} auth"),
            ("System status", f"info {query.split()[0] if query else 'relay1'}"),
            ("Diagnose problem", None)
        ]
    else:
        # Natural language query
        chain_steps = [
            ("Understanding request", None),
            ("Gathering data", ""),
            ("Analyzing", None),
            ("Generating response", None)
        ]

    collected_data = []

    for i, (step_name, cmd) in enumerate(chain_steps, 1):
        print(f"  [{i}/{len(chain_steps)}] {step_name}...", end=" ", flush=True)

        if cmd is None:
            # LLM step
            print(f"{C.CYAN}AI{C.R}")
            continue
        elif cmd == "":
            print(f"{C.GREEN}✓{C.R}")
            continue

        try:
            result = subprocess.run(
                f"/usr/local/bin/mpop {cmd}",
                shell=True, capture_output=True, text=True, timeout=60
            )
            import re
            output = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout)
            collected_data.append(f"=== {step_name} ===\n{output[:2000]}")
            print(f"{C.GREEN}✓{C.R}")
        except Exception as e:
            print(f"{C.RED}✗{C.R}")
            collected_data.append(f"=== {step_name} ===\nError: {e}")

    # Final LLM analysis
    print()
    print(f"  {C.BOLD}Generating AI analysis...{C.R}")

    ollama_host = _get_ollama_host()

    prompt = f"""Analyze this server infrastructure data and provide insights:

{chr(10).join(collected_data)}

Provide:
1. Summary of current state
2. Any issues or warnings
3. Recommendations for improvement

Be concise and actionable. Use Korean."""

    try:
        import urllib.request
        req_data = json.dumps({
            "model": "qwen2.5:7b",
            "prompt": prompt,
            "stream": False
        }).encode()

        req = urllib.request.Request(
            f"http://{ollama_host}/api/generate",
            data=req_data,
            headers={"Content-Type": "application/json"}
        )

        with urllib.request.urlopen(req, timeout=60) as resp:
            result = json.loads(resp.read())
            analysis = result.get("response", "No analysis generated")

        print()
        print(f"  {C.BOLD}Analysis:{C.R}")
        print()
        for line in analysis.split('\n'):
            print(f"  {line}")
        print()

    except Exception as e:
        print(f"  {C.RED}Error generating analysis: {e}{C.R}")




def cmd_webhook(args):
    """Webhook management (mpop webhook list|create|delete|test)"""
    import json as _j, os as _o, subprocess as _sp
    wdir = _o.path.expanduser("~/.mpop/webhooks")
    _o.makedirs(wdir, exist_ok=True)
    wfile = _o.path.join(wdir, "webhooks.json")
    load = lambda: _j.load(open(wfile)) if _o.path.exists(wfile) else {}
    save = lambda d: _j.dump(d, open(wfile, "w"), indent=2)

    if not args or args[0] == "list":
        wh = load()
        if not wh:
            print("  No webhooks registered.")
            print("  Create: mpop webhook create <name> <command>")
            return
        print(f"  {'NAME':<20} COMMAND")
        print(f"  {'-'*20} {'-'*40}")
        for n, c in wh.items():
            print(f"  {n:<20} {c}")
        return

    sub = args[0]
    if sub == "create":
        if len(args) < 3:
            print("  Usage: mpop webhook create <name> <command>")
            return
        n, c = args[1], " ".join(args[2:])
        wh = load(); wh[n] = c; save(wh)
        print(f"  \u2713 Webhook '{n}' \u2192 {c}")
    elif sub == "delete":
        if len(args) < 2:
            print("  Usage: mpop webhook delete <name>"); return
        n = args[1]; wh = load()
        if n not in wh:
            print(f"  \u2717 Not found: {n}  (known: {', '.join(wh) or 'none'})"); return
        del wh[n]; save(wh)
        print(f"  \u2713 Deleted: {n}")
    elif sub == "test":
        if len(args) < 2:
            print("  Usage: mpop webhook test <name>"); return
        n = args[1]; wh = load()
        if n not in wh:
            print(f"  \u2717 Not found: {n}"); return
        c = wh[n]
        print(f"  Triggering '{n}': {c}")
        r = _sp.run(c, shell=True, capture_output=True, text=True, timeout=30)
        print(f"  \u2192 {(r.stdout or r.stderr or '(no output)').strip()[:300]}")
    else:
        print(f"  Unknown subcommand: {sub}")
        print("  Usage: mpop webhook list|create|delete|test")


def _send_notify(platform, message, cfg):
    """Send notification to a platform"""
    import urllib.request, json as _j
    try:
        if platform == "slack":
            url = cfg["slack"]
            d = _j.dumps({"text": message}).encode()
            urllib.request.urlopen(urllib.request.Request(url, d, {"Content-Type": "application/json"}), timeout=10)
            return True
        if platform == "discord":
            url = cfg["discord"]
            d = _j.dumps({"content": message}).encode()
            urllib.request.urlopen(urllib.request.Request(url, d, {"Content-Type": "application/json"}), timeout=10)
            return True
        if platform == "telegram":
            token = cfg.get("telegram", "")
            chat_id = cfg.get("telegram_chat_id", "")
            if not token or not chat_id:
                return False
            url = f"https://api.telegram.org/bot{token}/sendMessage"
            d = _j.dumps({"chat_id": chat_id, "text": message}).encode()
            urllib.request.urlopen(urllib.request.Request(url, d, {"Content-Type": "application/json"}), timeout=10)
            return True
        return False
    except Exception:
        return False


def cmd_notify(args):
    """Send notifications (mpop notify slack|discord|telegram <msg>)"""
    import os as _o, json as _j
    secret_map = {"slack": "notify_slack_webhook", "discord": "notify_discord_webhook",
                  "telegram": "notify_telegram_token", "email": "notify_email_smtp"}
    cfg_file = _o.path.expanduser("~/.mpop/notify.json")
    cfg = _j.load(open(cfg_file)) if _o.path.exists(cfg_file) else {}

    if not args:
        print("  mpop notify \u2014 multi-channel notifications")
        for ch in ("slack", "discord", "telegram", "email"):
            st = "\u2713 configured" if ch in cfg else "\u2717 not set"
            print(f"    {ch:<12} {st}")
        print("  Configure: mpop secret set notify_slack_webhook")
        return

    platform = args[0].lower()
    message = " ".join(args[1:]) if len(args) > 1 else ""

    if not message:
        print(f"  Usage: mpop notify {platform} <message>")
        return

    if platform not in ("slack", "discord", "telegram", "email"):
        print(f"  Unknown platform: {platform}")
        print("  Platforms: slack, discord, telegram, email")
        return

    if platform not in cfg:
        print(f"  \u2717 {platform} not configured")
        key = secret_map.get(platform, f"notify_{platform}")
        print(f"  Configure: mpop secret set {key}")
        return

    ok = _send_notify(platform, message, cfg)
    if ok:
        print(f"  \u2713 Sent to {platform}: {message[:80]}")
    else:
        print(f"  \u2717 Failed to send to {platform}")


def main():
    # Silent first-run MCP auto-configuration (zero user interaction)
    _marker = os.path.join(os.path.expanduser('~'), '.mpop', '.mcp-configured')
    if not os.path.exists(_marker):
        try:
            _auto_configure_mcp_silent()
        except Exception:
            pass

    cmd = sys.argv[1] if len(sys.argv) > 1 else ""
    args = sys.argv[2:] if len(sys.argv) > 2 else []

    # Commands that work without config
    NO_CONFIG_CMDS = {"help", "-h", "--help", "--version", "-v", "version", "init", "completion", "config"}

    # Handle --version and -v first (no config needed)
    if cmd in ("--version", "-v", "version"):
        print(f"mpop v{VERSION}")
        return

    # Initialize VPN peer discovery (skip for no-config commands)
    if cmd not in NO_CONFIG_CMDS:
        # First-run from GitHub: auto-create config so dashboard shows immediately
        if _ensure_config_exists():
            global _config_cache
            _config_cache = None
            _init_config()
            print(f"  {C.CYAN}✓ Created {CONFIG_FILE}{C.R} with sample servers. Edit to add your infrastructure.\n")
        discover_peers()

        # Check if config exists for commands that need it
        if not SERVERS and not FALLBACK_SERVERS:
            print(f"\n  ⚠️ No configuration found!")
            print(f"\n  Run 'mpop init' to create ~/.mpop/config.json")
            print(f"  Or 'mpop help' to see available commands\n")
            return

        # Auto-deploy server-agent: re-runs whenever meshpop version changes (upgrade = re-deploy)
        _setup_marker = os.path.join(os.path.expanduser("~"), ".mpop", ".auto_setup_done")
        try:
            import importlib.metadata as _imeta
            _current_ver = _imeta.version("meshpop")
        except Exception:
            _current_ver = "unknown"
        _marker_ver = ""
        if os.path.exists(_setup_marker):
            try:
                _marker_ver = open(_setup_marker).read().strip().split("|")[0].strip()
            except Exception:
                pass
        # Only trigger auto-setup on UPGRADE (current > marker), never on downgrade.
        # Prevents flip-flop when multiple Python versions coexist (e.g. pyenv + homebrew on Mac):
        # if an older Python runs mpop and sees a newer marker, it should silently skip migration.
        def _ver_tuple(v):
            try: return tuple(int(x) for x in v.split("."))
            except Exception: return (0,)
        _is_upgrade = _ver_tuple(_current_ver) > _ver_tuple(_marker_ver)
        _need_setup = (_marker_ver != _current_ver) and _is_upgrade and SERVERS and cmd not in {"setup", "init", "doctor"}
        if _need_setup:
            import subprocess as _sp
            _mpop_bin = sys.argv[0]
            import shutil as _sh_auto
            if not os.path.isfile(_mpop_bin):
                _mpop_bin = _sh_auto.which("mpop") or sys.executable
            _log = os.path.join(os.path.expanduser("~"), ".mpop", "auto_setup.log")

            # ── Migration cleanup (runs on every upgrade) ─────────────────────
            def _run_migration_cleanup():
                """Fix known upgrade pitfalls — silent, best-effort."""
                import platform as _plat
                _fixed = []

                # 1) Mac: /usr/local/bin/mpop shebang pointing to Xcode/system python
                #    → update to use same python that's running now
                if _plat.system() == "Darwin":
                    for _bin in ["/usr/local/bin/mpop", "/usr/local/bin/mpop-agent"]:
                        try:
                            if not os.path.isfile(_bin):
                                continue
                            _first = open(_bin, "rb").read(200).decode("utf-8", errors="ignore")
                            # Only fix if shebang points somewhere OTHER than current python
                            if _first.startswith("#!") and sys.executable not in _first.split("\n")[0]:
                                _bad_py = _first.split("\n")[0][2:].strip()
                                # Check if that python has an outdated meshpop
                                try:
                                    _r = _sp.run(
                                        [_bad_py, "-c", "import importlib.metadata as m; print(m.version('meshpop'))"],
                                        capture_output=True, text=True, timeout=5
                                    )
                                    _old_v = _r.stdout.strip()
                                except Exception:
                                    _old_v = ""
                                if _old_v and _old_v != _current_ver:
                                    # Replace shebang with current python
                                    _content = open(_bin).read()
                                    _new_content = f"#!{sys.executable}\n" + "\n".join(_content.split("\n")[1:])
                                    try:
                                        with open(_bin, "w") as _f2:
                                            _f2.write(_new_content)
                                        _fixed.append(f"fixed {_bin} shebang ({_bad_py} → {sys.executable})")
                                    except PermissionError:
                                        # Can't write (root-owned) → create user-level override instead
                                        # ~/.local/bin/mpop takes precedence when PATH has it first
                                        _local_bin = os.path.expanduser("~/.local/bin")
                                        os.makedirs(_local_bin, exist_ok=True)
                                        _override = os.path.join(_local_bin, os.path.basename(_bin))
                                        with open(_override, "w") as _fo:
                                            _fo.write(f"#!{sys.executable}\nfrom mpop import main\nmain()\n")
                                        os.chmod(_override, 0o755)
                                        _fixed.append(f"created user-level override: {_override}")
                                        # Also ensure ~/.local/bin is first in PATH in shell configs
                                        _path_export = '\nexport PATH="$HOME/.local/bin:$PATH"  # meshpop\n'
                                        for _rc in ["~/.zshrc", "~/.bashrc", "~/.bash_profile"]:
                                            _rcp = os.path.expanduser(_rc)
                                            if os.path.isfile(_rcp):
                                                _rcc = open(_rcp).read()
                                                if "$HOME/.local/bin" not in _rcc and "~/.local/bin" not in _rcc:
                                                    with open(_rcp, "a") as _ff:
                                                        _ff.write(_path_export)
                        except Exception:
                            pass

                # 1b) If /usr/local/bin/mpop doesn't exist yet, still create user-level one
                #     (covers fresh install where no system-level mpop exists)
                if _plat.system() == "Darwin":
                    _local_bin = os.path.expanduser("~/.local/bin")
                    os.makedirs(_local_bin, exist_ok=True)
                    _user_mpop = os.path.join(_local_bin, "mpop")
                    if not os.path.isfile(_user_mpop):
                        with open(_user_mpop, "w") as _fo:
                            _fo.write(f"#!{sys.executable}\nfrom mpop import main\nmain()\n")
                        os.chmod(_user_mpop, 0o755)
                        _fixed.append("created ~/.local/bin/mpop (user-owned)")
                    # Also ensure PATH is set in shell configs
                    _path_export = '\nexport PATH="$HOME/.local/bin:$PATH"  # meshpop\n'
                    for _rc in ["~/.zshrc", "~/.bashrc", "~/.bash_profile"]:
                        _rcp = os.path.expanduser(_rc)
                        if os.path.isfile(_rcp):
                            try:
                                _rcc = open(_rcp).read()
                                if "$HOME/.local/bin" not in _rcc and "~/.local/bin" not in _rcc:
                                    with open(_rcp, "a") as _ff:
                                        _ff.write(_path_export)
                                    _fixed.append(f"added ~/.local/bin to PATH in {_rc}")
                            except Exception:
                                pass

                # 2) ~/.local/bin/mpop-agent.py: static copy → thin wrapper
                _agent_bin = os.path.expanduser("~/.local/bin/mpop-agent.py")
                if os.path.isfile(_agent_bin):
                    try:
                        _content = open(_agent_bin).read()
                        # Heuristic: static copy is large (>5KB); wrapper is 3 lines
                        if len(_content) > 2000 and "from agent_reporter import main" not in _content:
                            with open(_agent_bin, "w") as _f2:
                                _f2.write(f"#!{sys.executable}\nfrom agent_reporter import main\nmain()\n")
                            _fixed.append("replaced static agent copy with wrapper")
                    except Exception:
                        pass

                # 3) servers.json: remove stale "local: true" entries that aren't this machine
                _srv_json = os.path.join(os.path.expanduser("~"), ".mpop", "servers.json")
                if os.path.isfile(_srv_json):
                    try:
                        import json as _json
                        _sdata = _json.load(open(_srv_json))
                        _changed = False
                        for _sname, _sval in list(_sdata.items()):
                            if isinstance(_sval, dict) and _sval.get("local"):
                                # "local: true" only makes sense if this IS that server
                                # Check: if host is "localhost" but this machine doesn't match
                                _host = _sval.get("host", "")
                                if _host == "localhost":
                                    import socket as _sock
                                    _my_hostname = _sock.gethostname().lower()
                                    if _sname.lower() not in _my_hostname and _my_hostname not in _sname.lower():
                                        # Remove "local" flag and fix host to public/vpn IP
                                        _pub_ip = _sval.get("public_ip") or _sval.get("vpn_ip", "")
                                        if _pub_ip:
                                            _sdata[_sname]["host"] = f"root@{_pub_ip}"
                                        del _sdata[_sname]["local"]
                                        _changed = True
                                        _fixed.append(f"fixed servers.json: {_sname} 'local' entry")
                        if _changed:
                            with open(_srv_json, "w") as _f2:
                                _json.dump(_sdata, _f2, indent=2)
                    except Exception:
                        pass

                # 4) Kill duplicate agent processes (port conflict cleanup)
                #    If >1 mpop-agent/agent_reporter process is running, kill the oldest ones
                try:
                    _agent_procs = []
                    _ps = _sp.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
                    for _line in _ps.stdout.splitlines():
                        if ("agent_reporter" in _line or "mpop-agent" in _line) and "grep" not in _line:
                            _parts = _line.split()
                            if len(_parts) > 1:
                                try:
                                    _agent_procs.append(int(_parts[1]))
                                except ValueError:
                                    pass
                    if len(_agent_procs) > 1:
                        # Keep the newest (highest PID), kill the rest
                        for _old_pid in sorted(_agent_procs)[:-1]:
                            try:
                                _sp.run(["kill", str(_old_pid)], capture_output=True, timeout=3)
                                _fixed.append(f"killed duplicate agent PID {_old_pid}")
                            except Exception:
                                pass
                except Exception:
                    pass

                # 5) Restart agent service so upgraded code takes effect after reboot too
                #    Linux: systemctl restart server-agent
                #    macOS: launchctl kickstart -k
                try:
                    if _plat.system() == "Darwin":
                        import pwd as _pwd
                        _uid = _pwd.getpwnam(os.environ.get("USER", "")).pw_uid
                        _sp.run(
                            ["launchctl", "kickstart", "-k", f"gui/{_uid}/local.mpop-agent"],
                            capture_output=True, timeout=5
                        )
                        _fixed.append("restarted mpop-agent via launchctl")
                    else:
                        _r = _sp.run(
                            ["systemctl", "restart", "server-agent"],
                            capture_output=True, timeout=10
                        )
                        if _r.returncode == 0:
                            _fixed.append("restarted server-agent via systemctl")
                        # Also update the ExecStart python path in the service file if outdated
                        _svc = "/etc/systemd/system/server-agent.service"
                        if os.path.isfile(_svc):
                            _svc_content = open(_svc).read()
                            # If service uses /opt/server-agent/agent-reporter.py (old static path)
                            # add an After= dependency so it restarts cleanly on boot
                            if "Restart=always" not in _svc_content:
                                _new_svc = _svc_content.replace(
                                    "[Service]", "[Service]\nRestart=always\nRestartSec=30"
                                )
                                try:
                                    with open(_svc, "w") as _fsvc:
                                        _fsvc.write(_new_svc)
                                    _sp.run(["systemctl", "daemon-reload"], capture_output=True, timeout=5)
                                    _fixed.append("added Restart=always to server-agent.service")
                                except PermissionError:
                                    pass
                except Exception:
                    pass

                return _fixed

            try:
                _fixes = _run_migration_cleanup()
            except Exception:
                _fixes = []

            # Always re-run local agent setup on upgrade (refreshes PEER_IPS, service file)
            # Run as background subprocess so it never blocks the main process
            try:
                _sp.Popen(
                    [_mpop_bin, "setup", "agent", "local", "-x"],
                    stdout=open(_log, "a"), stderr=_sp.STDOUT,
                    start_new_session=True
                )
                _fixes.append("scheduled local agent config refresh (PEER_IPS)")
            except Exception:
                pass

            try:
                # 1) Start local dashboard server (receives agent reports)
                _sp.Popen(
                    [_mpop_bin, "setup", "dashboard", "-x"],
                    stdout=open(_log, "w"), stderr=_sp.STDOUT,
                    start_new_session=True
                )
                # 2) Deploy agent to all remote servers
                _logf = open(_log, "a")
                _proc = _sp.Popen(
                    [_mpop_bin, "setup", "agent", "all", "-x"],
                    stdout=_logf, stderr=_logf,
                    start_new_session=True  # detach: survives mpop exit
                )
                # Write current version into marker (re-runs on next upgrade)
                with open(_setup_marker, "w") as _f:
                    _f.write(_current_ver)
                if _marker_ver:
                    print(f"  ℹ️  meshpop upgraded ({_marker_ver} → {_current_ver}): re-deploying server-agent...")
                else:
                    print(f"  ℹ️  First run: deploying server-agent to all servers (background)...")
                print(f"     Log: {_log}  |  Status: mpop agent")
                if _fixes:
                    for _fix in _fixes:
                        print(f"     ✓ {_fix}")
                print()
            except Exception as _e:
                print(f"  ⚠️  Auto-deploy failed: {_e}")
                print(f"     Run manually: mpop setup agent all -x")

    # No command = dashboard
    if not cmd:
        cmd_dashboard([])
        return

    # Handle --live and -l as dashboard with live mode
    if cmd in ("--live", "-l"):
        if not SERVERS:
            discover_peers()
        cmd_dashboard(["--live"])
        return

    commands = {
        "dashboard": cmd_dashboard,
        "live": lambda args: cmd_dashboard(["--live"] + args),
        "matrix": cmd_matrix,
        "network": cmd_network,
        "vpn": cmd_vpn,
        "services": cmd_services,
        "security": cmd_security,
        "audit": cmd_audit,
        "watch": cmd_watch,
        "report": cmd_report,
        "roles": cmd_roles,
        "info": cmd_info,
        "exec": cmd_exec,
        "servers": cmd_servers,
        "trend": cmd_trend,
        "ai": cmd_ai,
        "agent": cmd_agent,
        "full": cmd_full,
        "temp": cmd_temp,
        "gpu": cmd_gpu,
        "hw": cmd_hw,
        "hardware": cmd_hw,  # alias
        "advise": cmd_advise,
        "advice": cmd_advise,  # alias
        "advisor": cmd_advise,  # alias
        "restart": cmd_restart,
        "secret": cmd_secret,
        "logs": cmd_logs,
        "alert": cmd_alert,
        "history": cmd_history,
        "backup": cmd_backup,
        "deploy": cmd_deploy,
        "self-update": cmd_self_update,
        "update-server": cmd_update_server,
        "setup": cmd_setup,
        "enforce": cmd_enforce,
        "heal": cmd_heal,
        "service": cmd_service,
        "svc": cmd_service,  # alias
        "retry": cmd_retry,
        "logai": cmd_logai,
        "analyze": cmd_logai,  # alias
        "export": cmd_export,
        "html": cmd_export,  # alias
        "diff": cmd_diff,
        "predict": cmd_predict,
        "forecast": cmd_predict,  # alias
        "map": cmd_map,
        "completion": cmd_completion,
        "benchmark": cmd_benchmark,
        "peers": cmd_peers,
        "dns": cmd_dns,
        "acl": cmd_acl,
        "tunnel": cmd_tunnel,
        "voice": cmd_voice,
        "news": cmd_news,
        "radiomcp": cmd_radiomcp,
        "radio": cmd_radiomcp,
        "ask": cmd_ask,
        "agent": cmd_agent,
        "workflow": cmd_workflow,
        "chain": cmd_chain,
        "brain": cmd_brain,
        "write": cmd_write,
        "scan": cmd_scan,
        "view": cmd_view,
        "project": cmd_project,
        "doc": cmd_doc,
        "docs": cmd_doc,  # alias
        "session": cmd_session,
        "memory": cmd_memory,
        "config": cmd_config,
        "init": cmd_init,
        "doctor": cmd_doctor,
        "join": cmd_join,
        "webhook": cmd_webhook,
        "notify": cmd_notify,
        "help": cmd_help,
        "-h": cmd_help,
        "--help": cmd_help,

        # Aliases
        "funnel": cmd_tunnel,       # Tailscale compatible
    }

    if cmd in commands:
        commands[cmd](args)
    else:
        print(f"  Unknown command: {cmd}")
        cmd_help([])

if __name__ == "__main__":
    main()
