#!/usr/bin/env python3
"""
agent_reporter.py — MeshPOP Server Monitoring Agent v2.0

Mesh architecture — no single hub, no DB needed:
  - Each node runs HTTP on 0.0.0.0:8721
  - Every 30s: collect own stats → push to ALL peers (they store in their dict)
  - Any node can answer full cluster status from its own in-memory dict
  - Peer IPs: PEER_IPS env (comma-sep) OR ~/.mpop/config.json servers[*].wire_ip
  - Fallback: VPN auto-discovery (Tailscale, Wire relay, config) — no hardcoding
  - To exclude nodes: add "mesh_exclude": ["1.2.3.4"] in ~/.mpop/config.json

Endpoints:
  GET  /api/status  → full cluster view (all known nodes)
  POST /api/report  → receive peer stats, store in local dict
  GET  /health      → 200 ok
"""
import os
import json
import time
import socket
import threading
import platform
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import subprocess as _subprocess
from urllib.request import urlopen, Request
import urllib.request

DASHBOARD_PORT  = int(os.environ.get("DASHBOARD_PORT", "8721"))
REPORT_INTERVAL = int(os.environ.get("REPORT_INTERVAL", "30"))
NODE_TTL        = int(os.environ.get("NODE_TTL", "120"))   # stale after N seconds

_cluster = {}   # node_name → stats dict
_lock    = threading.Lock()


# ─── VPN peer discovery (no hardcoding) ────────────────────────────────────────

def _discover_vpn_peer_ips():
    """Discover all VPN peer IPs. Tailscale, Wire relay, or config."""
    ips = set()
    cfg_path = os.path.expanduser("~/.mpop/config.json")

    # 1. Tailscale
    try:
        r = subprocess.run("tailscale status --json 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5)
        if r.returncode == 0 and r.stdout:
            data = json.loads(r.stdout)
            for peer in data.get("Peer", {}).values():
                for ip in peer.get("TailscaleIPs", []):
                    if ip and ":" not in ip:
                        ips.add(ip)
            for ip in data.get("Self", {}).get("TailscaleIPs", []):
                if ip and ":" not in ip:
                    ips.add(ip)
    except Exception:
        pass

    # 2. Wire: relay /peers
    if os.path.exists(cfg_path):
        try:
            with open(cfg_path) as f:
                cfg = json.load(f)
            relays = cfg.get("relays", [])
            if isinstance(relays, str):
                relays = [r.strip() for r in relays.split(",") if r.strip()]
            for relay in relays[:3]:
                host = str(relay).split(":")[0].strip()
                if not host or host.startswith("your-"):
                    continue
                try:
                    req = urllib.request.Request(f"http://{host}:8790/peers", headers={"User-Agent": "mpop-agent"})
                    with urllib.request.urlopen(req, timeout=5) as resp:
                        data = json.loads(resp.read().decode())
                        for p in data.get("peers", []):
                            ip = p.get("vpn_ip", "").strip()
                            if ip and ":" not in ip and (ip.startswith("10.") or ip.startswith("100.")):
                                ips.add(ip)
                except Exception:
                    pass
            # 3. Config servers
            for name, srv in cfg.get("servers", {}).items():
                if isinstance(srv, dict):
                    ip = srv.get("ip") or srv.get("tailscale_ip") or srv.get("wire_ip") or srv.get("vpn_ip", "")
                else:
                    ip = str(srv)
                if ip and ":" not in ip and (ip.startswith("10.") or ip.startswith("100.")):
                    ips.add(ip)
        except Exception:
            pass

    # 4. Self VPN IP
    try:
        vpn = subprocess.run(
            "ip addr show 2>/dev/null | grep -oE 'inet (10|100)\\.[0-9.]+' | awk '{print $2}' | head -1",
            shell=True, capture_output=True, text=True, timeout=2
        )
        if not vpn.stdout and platform.system() == "Darwin":
            vpn = subprocess.run(
                "ifconfig 2>/dev/null | grep -E 'inet (10\\.99|100\\.)' | awk '{print $2}' | head -1",
                shell=True, capture_output=True, text=True, timeout=2
            )
        if vpn.stdout and vpn.stdout.strip():
            ips.add(vpn.stdout.strip())
    except Exception:
        pass

    return ips


# ─── peer discovery ───────────────────────────────────────────────────────────

def _get_my_ip():
    # Prefer VPN IP looked up by NODE_NAME from servers.json
    # (prevents LAN IP collision when multiple nodes share the same subnet, e.g. m1 and d2 both 192.168.50.x)
    node = os.environ.get("NODE_NAME", "")
    if node:
        try:
            import json as _j
            sj_path = os.path.expanduser("~/.mpop/servers.json")
            if os.path.isfile(sj_path):
                sj = _j.load(open(sj_path))
                srv = sj.get(node, {})
                for _f in ("vpn_ip", "wire_ip", "ip"):
                    if srv.get(_f):
                        return srv[_f]
        except Exception:
            pass
    # Fall back to outbound interface IP
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
    except Exception:
        return "127.0.0.1"


def _load_config():
    p = os.path.expanduser("~/.mpop/config.json")
    try:
        with open(p) as f:
            return json.load(f)
    except Exception:
        return {}


def _get_peer_ips(my_ip=None):
    """Return peer IPs to push to — distributed mesh: ALWAYS use VPN discovery (all peers)."""
    ips = []
    cfg = _load_config()

    # 1) PEER_IPS env (optional seed)
    env = os.environ.get("PEER_IPS", "")
    if env:
        ips.extend(ip.strip() for ip in env.split(",") if ip.strip())

    # 2) config servers
    for srv in (cfg.get("servers") or {}).values() or []:
        ip = srv.get("wire_ip") or srv.get("tailscale_ip") or srv.get("ip") or srv.get("vpn_ip") or ""
        if ip and ":" not in str(ip) and ip not in ips:
            ips.append(ip)

    # 3) dashboard_url as fallback seed (not exclusive)
    dash = os.environ.get("DASHBOARD_URL", "") or cfg.get("dashboard_url", "")
    if dash:
        import re
        m = re.search(r"https?://([^:/]+)", dash)
        if m and m.group(1) not in ips:
            ips.append(m.group(1))

    # 4) ALWAYS add VPN discovery — distributed mesh (Tailscale, Wire, config)
    vpn_ips = _discover_vpn_peer_ips()
    for ip in vpn_ips:
        if ip and ip not in ips and ":" not in ip:
            ips.append(ip)

    # Exclusions
    exclude = set(cfg.get("mesh_exclude", []))
    if my_ip:
        exclude.add(my_ip)
    return [ip for ip in ips if ip not in exclude and ip not in ("127.0.0.1", "0.0.0.0")]


# ─── stats collection ─────────────────────────────────────────────────────────

def _run(cmd):
    try:
        return subprocess.check_output(
            cmd, shell=True, stderr=subprocess.DEVNULL, timeout=5
        ).decode().strip()
    except Exception:
        return ""


def get_node_name():
    # 1) Explicit env override (set by mpop setup agent via launchctl/systemd)
    name = os.environ.get("NODE_NAME") or os.environ.get("MPOP_NODE_NAME")
    if name:
        return name
    # 2) Look up this machine's IPs in servers.json → return canonical server name
    #    e.g. MacBook with VPN IP 10.99.215.87 → "m1"
    try:
        import json as _jn
        _srv_path = os.path.join(os.path.expanduser("~"), ".mpop", "servers.json")
        if os.path.isfile(_srv_path):
            _srv_j = _jn.load(open(_srv_path))
            my_ip = _get_my_ip()
            for _sn, _sv in _srv_j.items():
                for _f in ("vpn_ip", "wire_ip", "ip", "public_ip"):
                    if _sv.get(_f) and _sv[_f] == my_ip:
                        return _sn
    except Exception:
        pass
    # 3) Fallback: hostname (strip domain suffix)
    return socket.gethostname().split(".")[0]


def collect_stats(my_ip, node_name):
    """Collect this node's current stats."""
    # CPU
    cpu = 0.0
    try:
        import psutil; cpu = psutil.cpu_percent(interval=0.2)
    except ImportError:
        try: cpu = float(_run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'").replace(",", ".").replace("%",""))
        except Exception: pass

    # Memory
    mem_pct = mem_used_mb = mem_total_mb = 0
    try:
        import psutil; m = psutil.virtual_memory()
        mem_pct, mem_used_mb, mem_total_mb = m.percent, m.used >> 20, m.total >> 20
    except ImportError:
        parts = _run("free -m | awk '/Mem:/{print $3, $2}'").split()
        if len(parts) == 2:
            try:
                mem_used_mb, mem_total_mb = int(parts[0]), int(parts[1])
                mem_pct = round(mem_used_mb / mem_total_mb * 100, 1) if mem_total_mb else 0
            except Exception: pass

    # Disk
    disk_pct = disk_used_gb = disk_total_gb = 0
    try:
        import psutil; d = psutil.disk_usage("/")
        disk_pct, disk_used_gb, disk_total_gb = d.percent, d.used >> 30, d.total >> 30
    except ImportError:
        parts = _run("df -BG / | awk 'NR==2{print $3, $2, $5}'").split()
        if len(parts) >= 3:
            try:
                disk_used_gb = int(parts[0].replace("G",""))
                disk_total_gb = int(parts[1].replace("G",""))
                disk_pct = float(parts[2].replace("%",""))
            except Exception: pass

    # Load
    load = ""
    try:
        la = os.getloadavg(); load = f"{la[0]:.2f} {la[1]:.2f} {la[2]:.2f}"
    except Exception:
        load = _run("awk '{print $1,$2,$3}' /proc/loadavg 2>/dev/null")

    # Uptime
    uptime = ""
    try:
        import psutil; s = int(time.time() - psutil.boot_time())
        d2, s = divmod(s, 86400); h, s = divmod(s, 3600); mn = s // 60
        uptime = (f"{d2}d " if d2 else "") + f"{h}h {mn}m"
    except ImportError:
        uptime = _run("uptime -p 2>/dev/null || uptime")

    # Log errors (last 5 min in journal)
    errors = 0
    try:
        errors = int(_run(
            "journalctl --since '5 minutes ago' -p err -q --no-pager 2>/dev/null | wc -l"
        ))
    except Exception: pass

    return {
        "node":         node_name,
        "ip":           my_ip,
        "os":           platform.system(),
        "cpu_pct":      round(cpu, 1),
        "mem_pct":      round(mem_pct, 1),
        "mem_used_mb":  mem_used_mb,
        "mem_total_mb": mem_total_mb,
        "disk_pct":     round(disk_pct, 1),
        "disk_used_gb": disk_used_gb,
        "disk_total_gb":disk_total_gb,
        "load":         load,
        "uptime":       uptime,
        "log_errors":   errors,
        "ts":           int(time.time()),
        "agent_version":"2.0.0",
    }


# ─── HTTP server ──────────────────────────────────────────────────────────────

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass  # silence access log

    def do_GET(self):
        path = self.path.rstrip("/")
        if path in ("/api/status", "/status"):
            now = int(time.time())
            with _lock:
                nodes = [v for v in _cluster.values()
                         if now - v.get("ts", 0) <= NODE_TTL]
            body = json.dumps({"nodes": nodes, "count": len(nodes), "ts": now}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif path == "/health":
            self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
        else:
            self.send_response(404); self.end_headers()

    def do_POST(self):
        if self.path.rstrip("/") in ("/api/report", "/report"):
            try:
                length = int(self.headers.get("Content-Length", 0))
                data = json.loads(self.rfile.read(length).decode())
                node = data.get("node", "unknown")
                data.setdefault("ts", int(time.time()))
                with _lock:
                    # Dedup by IP: if a new canonical name reports with the same IP as an
                    # existing "node-XXX" alias, remove the stale alias so it doesn't persist
                    _node_ip = data.get("ip", "")
                    if _node_ip and not node.startswith("node-"):
                        _aliases = [k for k, v in _cluster.items()
                                    if k != node and k.startswith("node-") and v.get("ip") == _node_ip]
                        for _alias in _aliases:
                            del _cluster[_alias]
                    _cluster[node] = data
                self.send_response(200); self.end_headers()
                self.wfile.write(b'{"ok":true}')
            except Exception as e:
                self.send_response(400); self.end_headers()
                self.wfile.write(json.dumps({"error": str(e)}).encode())
        else:
            self.send_response(404); self.end_headers()


# ─── push to peers ────────────────────────────────────────────────────────────

def push_to_peers(status, peer_ips):
    body = json.dumps(status).encode()
    for ip in peer_ips:
        try:
            req = Request(
                f"http://{ip}:{DASHBOARD_PORT}/api/report",
                data=body,
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urlopen(req, timeout=5) as r:
                r.read()
        except Exception:
            pass  # peer offline — skip silently


# ─── Reusable HTTP server ──────────────────────────────────────────────────────

class _ReuseAddrServer(ThreadingHTTPServer):
    """ThreadingHTTPServer with SO_REUSEADDR so restart never hits EADDRINUSE."""
    allow_reuse_address = True


def _kill_old_agent(port: int) -> bool:
    """Kill a previous mpop-agent process holding the port. Returns True if killed."""
    try:
        import platform
        if platform.system() == "Darwin":
            r = _subprocess.run(["lsof", "-ti", f":{port}"], capture_output=True, text=True)
            pids = [p.strip() for p in r.stdout.strip().split() if p.strip()]
        else:
            r = _subprocess.run(["fuser", f"{port}/tcp"], capture_output=True, text=True)
            pids = r.stdout.split()
        for pid in pids:
            try:
                cmdline = open(f"/proc/{pid}/cmdline").read().replace('\x00', ' ') if platform.system() != "Darwin" else \
                          _subprocess.run(["ps", "-p", pid, "-o", "command="], capture_output=True, text=True).stdout
                if "mpop-agent" in cmdline or "agent_reporter" in cmdline:
                    _subprocess.run(["kill", pid])
                    time.sleep(0.5)
                    return True
            except Exception:
                pass
    except Exception:
        pass
    return False


# ─── main ─────────────────────────────────────────────────────────────────────

def main():
    my_ip     = _get_my_ip()
    node_name = get_node_name()

    # Try to start HTTP server; kill stale agents and retry if port is busy
    server = None
    for attempt in range(4):
        try:
            server = _ReuseAddrServer(("0.0.0.0", DASHBOARD_PORT), Handler)
            break
        except OSError as e:
            if e.errno != 48 and e.errno != 98:  # EADDRINUSE (macOS=48, Linux=98)
                raise
            _kill_old_agent(DASHBOARD_PORT)
            time.sleep(1.5)
    if server is None:
        raise RuntimeError(f"[agent] port {DASHBOARD_PORT} still in use after retries — aborting")

    threading.Thread(target=server.serve_forever, daemon=True).start()
    print(f"[agent] {node_name} ({my_ip}) :{DASHBOARD_PORT}", flush=True)

    while True:
        try:
            status    = collect_stats(my_ip, node_name)
            with _lock:
                _cluster[node_name] = status
            peer_ips  = _get_peer_ips(my_ip)       # re-read each cycle (VPN discovery)
            push_to_peers(status, peer_ips)
        except Exception as e:
            print(f"[agent] error: {e}", flush=True)
        time.sleep(REPORT_INTERVAL)


if __name__ == "__main__":
    main()
