#!/usr/bin/env python3
"""
NetTopo 3D AI Agent — Desktop GUI with Embedded Dashboard
Connects to NetTopo Cloud via WebSocket and syncs discovered devices.

Zero external dependencies — uses only Python standard library.
Copyright (c) 2026 SmartMindHead — nettopo.net
"""

import os
import re
import sys
import json
import time
import socket
import struct
import ctypes
import subprocess
import threading
import webbrowser
import csv
import io
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import hashlib
import ssl

# ═══ Configuration ═══
SERVER_HOST = "nettopo.net"
SERVER_PORT = 9000
import time
DASHBOARD_URL = f"http://{SERVER_HOST}:{SERVER_PORT}?v={int(time.time())}"
WS_URL = f"ws://{SERVER_HOST}:{SERVER_PORT}/ws/poller/"
LOCAL_PORT = 9876
SCAN_INTERVAL = 15
APP_VERSION = "1.0.0"

# ═══ Agent State ═══
agent_state = {
    "connected": False,
    "ws_connected": False,
    "service_key": "",
    "devices_found": 0,
    "scan_count": 0,
    "start_time": None,
    "latency": "---",
    "subnet": "---",
    "logs": [],
    "last_devices": [],
}
stop_event = threading.Event()
ws_socket = None
ws_lock = threading.Lock()
_prev_net_bytes = {"sent": 0, "recv": 0, "time": 0}
_prev_arp_table = {}          # ip → mac mapping from last scan
_prev_broadcast_pkts = 0      # previous broadcast packet count
_prev_storm_time = 0          # timestamp of previous storm sample
_terminal_sessions = {}       # session_id → {proc/sock, protocol, thread}


def log(msg, level="info"):
    ts = datetime.now().strftime("%H:%M:%S")
    agent_state["logs"].append({"time": ts, "msg": msg, "level": level})
    if len(agent_state["logs"]) > 200:
        agent_state["logs"] = agent_state["logs"][-200:]


# ═══ WebSocket Client (pure stdlib) ═══
def ws_connect(host, port, path):
    """Connect to WebSocket server using raw sockets (RFC 6455)"""
    sock = socket.create_connection((host, port), timeout=10)
    # Generate WebSocket key
    import base64, os as _os
    ws_key = base64.b64encode(_os.urandom(16)).decode()

    # Handshake
    request = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Upgrade: websocket\r\n"
        f"Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {ws_key}\r\n"
        f"Sec-WebSocket-Version: 13\r\n"
        f"\r\n"
    )
    sock.sendall(request.encode())

    # Read response
    response = b""
    while b"\r\n\r\n" not in response:
        chunk = sock.recv(1024)
        if not chunk:
            raise ConnectionError("WebSocket handshake failed")
        response += chunk

    if b"101" not in response.split(b"\r\n")[0]:
        raise ConnectionError(f"WebSocket rejected: {response.decode()[:200]}")

    return sock


def ws_send(sock, message):
    """Send a WebSocket text frame"""
    data = message.encode("utf-8")
    frame = bytearray()
    frame.append(0x81)  # FIN + text opcode

    length = len(data)
    if length <= 125:
        frame.append(0x80 | length)  # masked
    elif length <= 65535:
        frame.append(0x80 | 126)
        frame.extend(struct.pack(">H", length))
    else:
        frame.append(0x80 | 127)
        frame.extend(struct.pack(">Q", length))

    # Masking key
    import os as _os
    mask = _os.urandom(4)
    frame.extend(mask)

    # Masked data
    for i, b in enumerate(data):
        frame.append(b ^ mask[i % 4])

    sock.sendall(frame)


def ws_recv(sock, timeout=5):
    """Receive a WebSocket text frame"""
    sock.settimeout(timeout)
    try:
        header = sock.recv(2)
        if len(header) < 2:
            return None
        opcode = header[0] & 0x0f
        masked = (header[1] & 0x80) != 0
        length = header[1] & 0x7f

        if length == 126:
            length = struct.unpack(">H", sock.recv(2))[0]
        elif length == 127:
            length = struct.unpack(">Q", sock.recv(8))[0]

        if masked:
            mask = sock.recv(4)

        data = b""
        while len(data) < length:
            chunk = sock.recv(length - len(data))
            if not chunk:
                break
            data += chunk

        if masked:
            data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))

        if opcode == 0x08:  # close
            return None
        if opcode == 0x09:  # ping
            # RFC 6455 requires PONG to echo the exact same payload as the PING
            # We must mask it since it's client-to-server
            import os as _os
            mask_key = _os.urandom(4)
            pong = bytearray([0x8A])
            length = len(data)
            
            if length <= 125:
                pong.append(0x80 | length)
            elif length <= 65535:
                pong.append(0x80 | 126)
                pong.extend(struct.pack(">H", length))
            else:
                pong.append(0x80 | 127)
                pong.extend(struct.pack(">Q", length))
                
            pong.extend(mask_key)
            for i, b in enumerate(data):
                pong.append(b ^ mask_key[i % 4])
                
            sock.sendall(pong)
            return ws_recv(sock, timeout)

        return data.decode("utf-8")
    except socket.timeout:
        return None
    except Exception:
        return None


# ═══ WinRM Delegation ═══
def execute_winrm_tasks(tasks):
    """Executes delegated WinRM tasks via native PowerShell (zero-dependency)"""
    results = {}
    for task in tasks:
        tid = task.get("id", task.get("ip"))
        try:
            import base64, tempfile, socket
            ip = task.get("ip", "")
            port = task.get("port", 5985)
            username = task.get("username", "")
            password = task.get("password", "")

            # Detect if target is the local machine
            is_local = False
            try:
                local_ips = set()
                hostname = socket.gethostname()
                for info in socket.getaddrinfo(hostname, None):
                    local_ips.add(info[4][0])
                try:
                    import psutil
                    for iface, addrs in psutil.net_if_addrs().items():
                        for addr in addrs:
                            if addr.family == socket.AF_INET:
                                local_ips.add(addr.address)
                except ImportError:
                    pass
                local_ips.add("127.0.0.1")
                local_ips.add("localhost")
                is_local = ip in local_ips
            except Exception:
                pass

            if is_local and (not username or not password):
                ps_script = """$ErrorActionPreference = 'Stop'
try {
    $session = New-CimSession -ErrorAction Stop
    $cpuVal = 0
    try {
        $perf = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -CimSession $session -Filter "Name='_Total'" -ErrorAction Stop
        if ($perf -and $perf.PercentProcessorTime -ne $null) { $cpuVal = [double]$perf.PercentProcessorTime }
    } catch {
        $proc = Get-CimInstance Win32_Processor -CimSession $session -ErrorAction SilentlyContinue
        if ($proc) {
            $avg = ($proc | Measure-Object -Property LoadPercentage -Average).Average
            if ($avg -ne $null) { $cpuVal = [double]$avg }
        }
    }
    $ramVal = 0
    $os = Get-CimInstance Win32_OperatingSystem -CimSession $session -ErrorAction SilentlyContinue
    if ($os -and $os.TotalVisibleMemorySize -gt 0) {
        $ramVal = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
    }
    $diskVal = 0
    $logical_disks = Get-CimInstance Win32_LogicalDisk -CimSession $session -Filter "DriveType=3" -ErrorAction SilentlyContinue
    $ts = [double]0; $tf = [double]0
    if ($logical_disks) {
        foreach ($d in $logical_disks) {
            if ($d.Size) { $ts += $d.Size }
            if ($d.FreeSpace -ne $null) { $tf += $d.FreeSpace }
        }
        if ($ts -gt 0) { $diskVal = [math]::Round((($ts - $tf) / $ts) * 100, 1) }
    }
    Write-Output ('{"status":"ok","cpu_percent":' + [math]::Round($cpuVal,1).ToString() + ',"ram_percent":' + $ramVal.ToString() + ',"disk_percent":' + $diskVal.ToString() + ',"mode":"local"}')
    Remove-CimSession -CimSession $session
} catch {
    $errMsg = $_.Exception.Message
    if (-not $errMsg) { $errMsg = $_.ToString() }
    $errMsg = $errMsg -replace '"', "'"
    $errMsg = $errMsg.Substring(0, [Math]::Min($errMsg.Length, 200))
    Write-Output ('{"status":"error","error":"' + $errMsg + '"}')
}
"""
            else:
                b64_pass = base64.b64encode(password.encode("utf-8")).decode("ascii")
                b64_user = base64.b64encode(username.encode("utf-8")).decode("ascii")
                ps_script = f"""$ErrorActionPreference = 'Stop'
try {{
    try {{ $null = cmd /c "winrm set winrm/config/client @{{TrustedHosts=""{ip}""}}" 2>&1 }} catch {{}}
    $passPlain = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('{b64_pass}'))
    $userName = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('{b64_user}'))
    $pass = ConvertTo-SecureString $passPlain -AsPlainText -Force
    $cred = New-Object System.Management.Automation.PSCredential ($userName, $pass)
    $session = $null
    try {{
        $session = New-CimSession -ComputerName '{ip}' -Credential $cred -Authentication Negotiate -ErrorAction Stop
    }} catch {{
        $opt = New-CimSessionOption -Protocol Dcom
        $session = New-CimSession -ComputerName '{ip}' -Credential $cred -SessionOption $opt -ErrorAction Stop
    }}
    $cpuVal = 0
    try {{
        $perf = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -CimSession $session -Filter "Name='_Total'" -ErrorAction Stop
        if ($perf -and $perf.PercentProcessorTime -ne $null) {{ $cpuVal = [double]$perf.PercentProcessorTime }}
    }} catch {{
        $proc = Get-CimInstance Win32_Processor -CimSession $session -ErrorAction SilentlyContinue
        if ($proc) {{
            $avg = ($proc | Measure-Object -Property LoadPercentage -Average).Average
            if ($avg -ne $null) {{ $cpuVal = [double]$avg }}
        }}
    }}
    $ramVal = 0
    $os = Get-CimInstance Win32_OperatingSystem -CimSession $session -ErrorAction SilentlyContinue
    if ($os -and $os.TotalVisibleMemorySize -gt 0) {{
        $ramVal = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
    }}
    $diskVal = 0
    $logical_disks = Get-CimInstance Win32_LogicalDisk -CimSession $session -Filter "DriveType=3" -ErrorAction SilentlyContinue
    $ts = [double]0; $tf = [double]0
    if ($logical_disks) {{
        foreach ($d in $logical_disks) {{
            if ($d.Size) {{ $ts += $d.Size }}
            if ($d.FreeSpace -ne $null) {{ $tf += $d.FreeSpace }}
        }}
        if ($ts -gt 0) {{ $diskVal = [math]::Round((($ts - $tf) / $ts) * 100, 1) }}
    }}
    Write-Output ('{{"status":"ok","cpu_percent":' + [math]::Round($cpuVal,1).ToString() + ',"ram_percent":' + $ramVal.ToString() + ',"disk_percent":' + $diskVal.ToString() + ',"mode":"remote"}}')
    Remove-CimSession -CimSession $session
}} catch {{
    $errMsg = $_.Exception.Message
    if (-not $errMsg) {{ $errMsg = $_.ToString() }}
    $errMsg = $errMsg -replace '"', "'"
    $errMsg = $errMsg.Substring(0, [Math]::Min($errMsg.Length, 200))
    Write-Output ('{{"status":"error","error":"' + $errMsg + '"}}')
}}
"""
            # Write to temp .ps1 file and execute (avoids stdin parsing issues)
            tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.ps1', delete=False, encoding='utf-8')
            tmp.write(ps_script)
            tmp.close()
            try:
                proc = subprocess.run(
                    ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp.name],
                    capture_output=True, text=True, timeout=30
                )
            finally:
                os.unlink(tmp.name)

            stdout = proc.stdout.strip()
            stderr = proc.stderr.strip()
            if stdout:
                try:
                    results[tid] = json.loads(stdout)
                except json.JSONDecodeError:
                    results[tid] = {"status": "error", "error": f"Bad JSON: {stdout[:100]}"}
            elif stderr:
                results[tid] = {"status": "error", "error": stderr[:200]}
            else:
                results[tid] = {"status": "error", "error": f"No output. returncode={proc.returncode}"}
        except Exception as e:
            results[tid] = {"status": "error", "error": str(e)[:200]}

    return results


# ═══ Network Scanning ═══
def scan_network():
    """Perform ARP scan and return structured device data"""
    devices = []
    seen_ips = set()

    try:
        if sys.platform == "win32":
            out = subprocess.check_output("arp -a", shell=True, text=True, timeout=10)
        else:
            out = subprocess.check_output("arp -n", shell=True, text=True, timeout=10)

        subnet = get_local_subnet()

        for line in out.splitlines():
            # Windows: 192.168.1.1    00-11-22-33-44-55    dynamic
            match = re.match(
                r'\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]{17}|[0-9a-fA-F:]{17})\s+(\w+)',
                line
            )
            if match:
                ip = match.group(1)
                mac = match.group(2).replace("-", ":").lower()
                dtype = match.group(3)  # dynamic/static

                if ip in seen_ips or mac == "ff:ff:ff:ff:ff:ff":
                    continue
                seen_ips.add(ip)

                # Determine device type
                device_type = "device"
                if ip.endswith(".1"):
                    device_type = "router"

                # Ping to measure latency
                ping_ms = ping_host(ip)

                # Try to resolve hostname
                hostname = resolve_hostname(ip)

                devices.append({
                    "ip": ip,
                    "mac": mac,
                    "hostname": hostname,
                    "device_type": device_type,
                    "vendor": "Unknown",
                    "subnet": subnet,
                    "status": "UP" if ping_ms is not None else "DOWN",
                    "ping_ms": ping_ms,
                    "distance": "",
                })
    except Exception as e:
        log(f"Scan error: {e}", "warning")

    _resolve_duplicate_macs(devices)
    return devices


def _resolve_duplicate_macs(devices):
    """When proxy ARP / WiFi extenders cause multiple IPs to share one MAC,
    ping each IP to verify which are really alive. Responsive IPs stay UP,
    unreachable IPs are marked DOWN. MAC field kept as-is so the operator
    can see why those IPs got grouped (proxy ARP signature).
    """
    groups = {}
    for d in devices:
        mac = (d.get("mac") or "").lower()
        if mac and mac != "00:00:00:00:00:00":
            groups.setdefault(mac, []).append(d)
    for mac, group in groups.items():
        if len(group) <= 1:
            continue
        for d in group:
            if d.get("ping_ms") is None:
                ms = ping_host(d["ip"], timeout=1)
                d["ping_ms"] = ms
                d["status"] = "UP" if ms is not None else "DOWN"


def _is_real_device(ip, mac):
    """Filter out multicast, broadcast, link-local, and virtual IPs"""
    octets = [int(x) for x in ip.split(".")]
    # Multicast 224-239.x.x.x
    if octets[0] >= 224:
        return False
    # Link-local 169.254.x.x
    if octets[0] == 169 and octets[1] == 254:
        return False
    # Loopback 127.x.x.x
    if octets[0] == 127:
        return False
    # Broadcast
    if ip.endswith(".255") or mac == "ff:ff:ff:ff:ff:ff":
        return False
    # Multicast MACs (01:00:5e:xx)
    if mac.startswith("01:00:5e"):
        return False
    return True


def scan_network_fast():
    """Fast ARP scan — ping sweep first to discover all devices"""
    devices = []
    seen_ips = set()

    try:
        subnet = get_local_subnet()
        subnet_prefix = ".".join(subnet.split(".")[:3])  # e.g. "192.168.43"

        # Ping sweep: fire parallel pings to populate ARP table
        if sys.platform == "win32":
            # Launch parallel pings via for /L + start /b (all run simultaneously)
            sweep_cmd = (
                f'cmd /c "for /L %i in (1,1,254) do '
                f'@start /b ping -n 1 -w 500 {subnet_prefix}.%i >nul 2>&1"'
            )
            subprocess.Popen(
                sweep_cmd,
                shell=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            time.sleep(3)  # Wait for pings to populate ARP table

        # Now read ARP table (populated by ping sweep)
        if sys.platform == "win32":
            out = subprocess.check_output("arp -a", shell=True, text=True, timeout=10)
        else:
            out = subprocess.check_output("arp -n", shell=True, text=True, timeout=10)

        subnet = get_local_subnet()
        # Get local subnet prefix for filtering
        subnet_prefix = ".".join(subnet.split(".")[:3]) + "."  # e.g. "192.168.43."

        for line in out.splitlines():
            match = re.match(
                r'\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]{17}|[0-9a-fA-F:]{17})\s+(\w+)',
                line
            )
            if match:
                ip = match.group(1)
                mac = match.group(2).replace("-", ":").lower()

                # Skip duplicates and non-real devices
                if ip in seen_ips:
                    continue
                if not _is_real_device(ip, mac):
                    continue
                # Only include devices on our local subnet
                if not ip.startswith(subnet_prefix):
                    continue
                seen_ips.add(ip)

                device_type = "router" if ip.endswith(".1") else "device"

                # Quick hostname
                hostname = ip
                try:
                    hostname = socket.gethostbyaddr(ip)[0]
                except Exception:
                    pass

                devices.append({
                    "ip": ip,
                    "mac": mac,
                    "hostname": hostname if hostname != ip else ip,
                    "device_type": device_type,
                    "vendor": "Unknown",
                    "subnet": subnet,
                    "status": "UP",
                    "ping_ms": None,
                    "distance": "",
                })
    except Exception as e:
        log(f"Fast scan error: {e}", "warning")

    # Add local machine (ARP doesn't include self)
    try:
        local_ip = ".".join(get_local_subnet().split(".")[:3])
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        local_ip = s.getsockname()[0]
        s.close()

        if local_ip not in seen_ips:
            # Get local MAC
            local_mac = "00:00:00:00:00:00"
            try:
                if sys.platform == "win32":
                    ipconfig = subprocess.check_output("ipconfig /all", shell=True, text=True, timeout=5)
                    # Find the adapter section that contains our IP
                    sections = ipconfig.split("\n\n")
                    for section in sections:
                        if local_ip in section:
                            mac_match = re.search(r'([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}', section)
                            if mac_match:
                                local_mac = mac_match.group(0).replace("-", ":").lower()
                                break
            except Exception:
                pass

            local_hostname = socket.gethostname()
            devices.insert(0, {
                "ip": local_ip,
                "mac": local_mac,
                "hostname": local_hostname,
                "device_type": "device",
                "vendor": "Local Machine",
                "subnet": get_local_subnet(),
                "status": "UP",
                "ping_ms": 0,
                "distance": "",
            })
    except Exception:
        pass

    _resolve_duplicate_macs(devices)
    return devices


def ping_host(ip, timeout=1):
    """Ping a host, return latency in ms or None"""
    try:
        if sys.platform == "win32":
            out = subprocess.check_output(
                f"ping -n 1 -w {timeout*1000} {ip}",
                shell=True, text=True, timeout=timeout+2
            )
        else:
            out = subprocess.check_output(
                f"ping -c 1 -W {timeout} {ip}",
                shell=True, text=True, timeout=timeout+2
            )
        m = re.search(r'[=<](\d+\.?\d*)ms', out)
        if m:
            return float(m.group(1))
    except Exception:
        pass
    return None


def resolve_hostname(ip):
    try:
        name = socket.gethostbyaddr(ip)[0]
        if name and name != ip:
            return name
    except Exception:
        pass
    return ip


def measure_latency():
    try:
        t0 = time.time()
        s = socket.create_connection((SERVER_HOST, SERVER_PORT), timeout=3)
        s.close()
        return f"{int((time.time() - t0) * 1000)}ms"
    except Exception:
        return "---"


def get_local_subnet():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ".".join(ip.split(".")[:3]) + ".0/24"
    except Exception:
        return "unknown"


# ═══ System Monitoring (pure stdlib) ═══
class MEMORYSTATUSEX(ctypes.Structure):
    _fields_ = [
        ("dwLength", ctypes.c_ulong),
        ("dwMemoryLoad", ctypes.c_ulong),
        ("ullTotalPhys", ctypes.c_ulonglong),
        ("ullAvailPhys", ctypes.c_ulonglong),
        ("ullTotalPageFile", ctypes.c_ulonglong),
        ("ullAvailPageFile", ctypes.c_ulonglong),
        ("ullTotalVirtual", ctypes.c_ulonglong),
        ("ullAvailVirtual", ctypes.c_ulonglong),
        ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
    ]


def _get_memory():
    """Get RAM info via Windows API — instant"""
    stat = MEMORYSTATUSEX()
    stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
    ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
    return {
        "total": stat.ullTotalPhys,
        "used": stat.ullTotalPhys - stat.ullAvailPhys,
        "percent": stat.dwMemoryLoad,
    }


def _get_cpu():
    """Get CPU load via wmic — fast"""
    try:
        out = subprocess.check_output(
            'wmic cpu get loadpercentage /format:value',
            shell=True, text=True, timeout=5
        )
        for line in out.splitlines():
            if 'LoadPercentage=' in line:
                return float(line.split('=')[1].strip())
    except Exception:
        pass
    return 0.0


def _get_disk():
    """Get disk usage via Windows API — instant"""
    try:
        free = ctypes.c_ulonglong(0)
        total = ctypes.c_ulonglong(0)
        ctypes.windll.kernel32.GetDiskFreeSpaceExW(
            'C:\\', None, ctypes.byref(total), ctypes.byref(free)
        )
        used = total.value - free.value
        pct = (used / total.value * 100) if total.value else 0
        return {"total": total.value, "used": used, "percent": round(pct, 1)}
    except Exception:
        return {"total": 0, "used": 0, "percent": 0}


def _get_uptime():
    """Get system uptime via GetTickCount64 — instant"""
    try:
        return ctypes.windll.kernel32.GetTickCount64() // 1000
    except Exception:
        return 0


def _get_top_processes(limit=20):
    """Get top processes by memory via tasklist"""
    procs = []
    try:
        out = subprocess.check_output(
            'tasklist /FO CSV /NH', shell=True, text=True, timeout=5
        )
        reader = csv.reader(io.StringIO(out))
        for row in reader:
            if len(row) >= 5:
                name = row[0].strip('"')
                try:
                    pid = int(row[1].strip('"'))
                except ValueError:
                    continue
                mem_str = row[4].strip('"').replace(',', '').replace(' K', '').replace(' ', '')
                try:
                    mem_kb = int(mem_str)
                except ValueError:
                    mem_kb = 0
                procs.append({
                    "name": name,
                    "pid": pid,
                    "cpu": 0,
                    "memory": mem_kb * 1024,
                })
        procs.sort(key=lambda p: p["memory"], reverse=True)
    except Exception:
        pass
    return procs[:limit]


def _get_net_bytes():
    """Get total bytes sent/received via netstat -e"""
    try:
        out = subprocess.check_output('netstat -e', shell=True, text=True, timeout=5)
        for line in out.splitlines():
            if 'Bytes' in line and 'Bytes' == line.strip().split()[0]:
                parts = line.strip().split()
                return int(parts[1]), int(parts[2])  # recv, sent
    except Exception:
        pass
    return 0, 0


def _get_connections(limit=100):
    """Get active TCP connections via netstat -ano"""
    conns = []
    try:
        out = subprocess.check_output('netstat -ano', shell=True, text=True, timeout=5)
        for line in out.splitlines():
            parts = line.strip().split()
            if len(parts) >= 5 and parts[0] in ('TCP', 'UDP'):
                proto = parts[0]
                local = parts[1]
                remote = parts[2] if proto == 'TCP' else '0.0.0.0:0'
                status = parts[3] if proto == 'TCP' else ''
                pid_str = parts[4] if proto == 'TCP' else parts[3]
                try:
                    pid = int(pid_str)
                except ValueError:
                    continue
                lip, lport = local.rsplit(':', 1)
                rip, rport = remote.rsplit(':', 1)
                conns.append({
                    "local_ip": lip, "local_port": int(lport),
                    "remote_ip": rip, "remote_port": int(rport),
                    "status": status, "pid": pid,
                    "process": "", "type": proto,
                })
    except Exception:
        pass
    return conns[:limit]


def collect_traffic_data():
    """Collect all system metrics for Host Traffic page"""
    global _prev_net_bytes

    mem = _get_memory()
    disk = _get_disk()
    recv_bytes, sent_bytes = _get_net_bytes()
    now = time.time()

    # Calculate bandwidth (bytes per second)
    elapsed = now - _prev_net_bytes["time"] if _prev_net_bytes["time"] else SCAN_INTERVAL
    if elapsed <= 0:
        elapsed = 1
    upload_bps = max(0, (sent_bytes - _prev_net_bytes["sent"]) / elapsed) if _prev_net_bytes["sent"] else 0
    download_bps = max(0, (recv_bytes - _prev_net_bytes["recv"]) / elapsed) if _prev_net_bytes["recv"] else 0
    _prev_net_bytes = {"sent": sent_bytes, "recv": recv_bytes, "time": now}

    procs = _get_top_processes()

    return {
        "system": {
            "cpu_percent": _get_cpu(),
            "ram_percent": mem["percent"],
            "ram_used": mem["used"],
            "ram_total": mem["total"],
            "disk_percent": disk["percent"],
            "disk_used": disk["used"],
            "disk_total": disk["total"],
            "uptime_seconds": _get_uptime(),
            "process_count": len(procs),
            "top_processes": procs,
        },
        "bandwidth": {
            "upload_bps": upload_bps,
            "download_bps": download_bps,
            "total_sent": sent_bytes,
            "total_recv": recv_bytes,
        },
        "connections": _get_connections(),
    }


# ═══ Storm Detection Metrics ═══
def _get_broadcast_packet_count():
    """Get total broadcast/multicast packets received from 'netstat -s'"""
    try:
        out = subprocess.check_output('netstat -s', shell=True, text=True, timeout=5)
        # Look for UDP datagrams received (includes broadcast)
        total = 0
        in_udp = False
        for line in out.splitlines():
            stripped = line.strip()
            if 'UDP Statistics' in line or 'Statistiques UDP' in line:
                in_udp = True
            elif in_udp:
                # "Datagrams Received = 12345"
                m = re.match(r'\D*(\d+)', stripped)
                if m:
                    total = int(m.group(1))
                    break
        return total
    except Exception:
        return 0


def _get_interface_errors():
    """Get interface error/discard counters from 'netstat -e'"""
    try:
        out = subprocess.check_output('netstat -e', shell=True, text=True, timeout=5)
        errors = 0
        discards = 0
        for line in out.splitlines():
            stripped = line.strip()
            if stripped.startswith('Errors') or stripped.startswith('Erreurs'):
                parts = stripped.split()
                if len(parts) >= 3:
                    errors = int(parts[1]) + int(parts[2])
            elif stripped.startswith('Discards'):
                parts = stripped.split()
                if len(parts) >= 3:
                    discards = int(parts[1]) + int(parts[2])
        return errors, discards
    except Exception:
        return 0, 0


def _get_arp_table_dict():
    """Get current ARP table as {ip: mac} dict"""
    arp = {}
    try:
        out = subprocess.check_output('arp -a', shell=True, text=True, timeout=5)
        for line in out.splitlines():
            m = re.match(
                r'\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]{17}|[0-9a-fA-F:]{17})\s+',
                line
            )
            if m:
                ip = m.group(1)
                mac = m.group(2).replace('-', ':').lower()
                if mac != 'ff:ff:ff:ff:ff:ff' and not ip.startswith('224.'):
                    arp[ip] = mac
    except Exception:
        pass
    return arp


def collect_storm_metrics():
    """Collect broadcast/ARP/MAC metrics for storm detection"""
    global _prev_arp_table, _prev_broadcast_pkts, _prev_storm_time

    now = time.time()
    elapsed = now - _prev_storm_time if _prev_storm_time else SCAN_INTERVAL
    if elapsed <= 0:
        elapsed = 1

    # 1. Broadcast packet rate
    current_bcast = _get_broadcast_packet_count()
    bcast_delta = max(0, current_bcast - _prev_broadcast_pkts) if _prev_broadcast_pkts else 0
    bcast_per_sec = round(bcast_delta / elapsed, 1)

    # 2. ARP table + MAC flapping
    current_arp = _get_arp_table_dict()
    mac_flaps = []  # [{mac, old_ip, new_ip}]
    new_arp_entries = 0

    if _prev_arp_table:
        # Identify proxy-ARP MACs: same MAC on >1 IP within a single scan
        # is the signature of a WiFi extender / router answering ARP for
        # downstream clients, NOT a real MAC flap. Excluding these stops
        # the detector from raising STORM_LOOP every cycle when dict
        # iteration order shifts which IP is "remembered" per MAC.
        cur_mac_ip_count: dict[str, int] = {}
        for mac in current_arp.values():
            cur_mac_ip_count[mac] = cur_mac_ip_count.get(mac, 0) + 1
        prev_mac_ip_count: dict[str, int] = {}
        for mac in _prev_arp_table.values():
            prev_mac_ip_count[mac] = prev_mac_ip_count.get(mac, 0) + 1
        proxy_arp_macs = {
            m for m in cur_mac_ip_count
            if cur_mac_ip_count[m] > 1 or prev_mac_ip_count.get(m, 0) > 1
        }

        # Build reverse map: mac → ip (previous), excluding proxy-ARP MACs
        prev_mac_to_ip = {}
        for ip, mac in _prev_arp_table.items():
            if mac in proxy_arp_macs:
                continue
            prev_mac_to_ip[mac] = ip

        # Check for flapping: MAC now associated with different IP
        for ip, mac in current_arp.items():
            if mac in proxy_arp_macs:
                continue
            if mac in prev_mac_to_ip and prev_mac_to_ip[mac] != ip:
                mac_flaps.append({
                    "mac": mac,
                    "old_ip": prev_mac_to_ip[mac],
                    "new_ip": ip,
                })

        # Count new ARP entries (IPs not in previous table)
        new_arp_entries = len(set(current_arp.keys()) - set(_prev_arp_table.keys()))

    # 3. Interface errors
    errors, discards = _get_interface_errors()

    # Update state for next cycle
    _prev_arp_table = current_arp
    _prev_broadcast_pkts = current_bcast
    _prev_storm_time = now

    return {
        "broadcast_pkt_rate": bcast_per_sec,
        "broadcast_pkt_total": current_bcast,
        "arp_entries": len(current_arp),
        "arp_new_entries": new_arp_entries,
        "mac_flaps": mac_flaps,
        "mac_flap_count": len(mac_flaps),
        "interface_errors": errors,
        "interface_discards": discards,
        "elapsed_seconds": round(elapsed, 1),
    }


# ═══ Terminal Relay (SSH via paramiko, Telnet via raw socket) ═══
try:
    import paramiko
    HAS_PARAMIKO = True
except ImportError:
    HAS_PARAMIKO = False
    log("paramiko not installed — SSH terminal will use ssh.exe fallback", "warning")


def _terminal_reader(session_id, proc_or_sock_or_chan, protocol):
    """Background thread: read output from SSH/Telnet and send via WebSocket."""
    global ws_socket
    import base64
    try:
        if protocol == "ssh_paramiko":
            chan = proc_or_sock_or_chan
            import time as _t
            while not chan.closed:
                if chan.recv_ready():
                    data = chan.recv(4096)
                    if not data:
                        break
                    encoded = base64.b64encode(data).decode('ascii')
                    if ws_socket:
                        try:
                            ws_send(ws_socket, json.dumps({
                                "type": "terminal_output",
                                "session_id": session_id,
                                "data": encoded,
                            }))
                        except Exception:
                            break
                elif chan.recv_stderr_ready():
                    data = chan.recv_stderr(4096)
                    if data:
                        encoded = base64.b64encode(data).decode('ascii')
                        if ws_socket:
                            try:
                                ws_send(ws_socket, json.dumps({
                                    "type": "terminal_output",
                                    "session_id": session_id,
                                    "data": encoded,
                                }))
                            except Exception:
                                break
                elif chan.exit_status_ready():
                    break
                else:
                    _t.sleep(0.05)

        elif protocol == "ssh":
            proc = proc_or_sock_or_chan
            import os as _os
            fd = proc.stdout.fileno()
            while proc.poll() is None:
                try:
                    data = _os.read(fd, 4096)
                    if not data:
                        break
                    encoded = base64.b64encode(data).decode('ascii')
                    if ws_socket:
                        try:
                            ws_send(ws_socket, json.dumps({
                                "type": "terminal_output",
                                "session_id": session_id,
                                "data": encoded,
                            }))
                        except Exception:
                            break
                except OSError:
                    break
                except Exception:
                    break

        else:  # telnet
            sock = proc_or_sock_or_chan
            while True:
                try:
                    data = sock.recv(4096)
                    if not data:
                        break
                    encoded = base64.b64encode(data).decode('ascii')
                    if ws_socket:
                        try:
                            ws_send(ws_socket, json.dumps({
                                "type": "terminal_output",
                                "session_id": session_id,
                                "data": encoded,
                            }))
                        except Exception:
                            break
                except Exception:
                    break

    except Exception as e:
        log(f"Terminal reader error: {e}", "error")
    finally:
        # Notify server session ended
        if ws_socket:
            try:
                ws_send(ws_socket, json.dumps({
                    "type": "terminal_error",
                    "session_id": session_id,
                    "error": "Session ended",
                }))
            except Exception:
                pass
        _terminal_sessions.pop(session_id, None)
        log(f"Terminal session {session_id[:8]} closed", "info")


def handle_terminal_open(msg):
    """Open SSH or Telnet connection to target device."""
    # Run in thread to avoid blocking the WS receive loop
    t = threading.Thread(target=_do_terminal_open, args=(msg,), daemon=True)
    t.start()


def _do_terminal_open(msg):
    """Internal: spawns SSH/Telnet process in background thread."""
    session_id = msg.get("session_id", "")
    ip = msg.get("ip", "")
    protocol = msg.get("protocol", "ssh")
    port = msg.get("port", 22 if protocol == "ssh" else 23)
    username = msg.get("username", "")
    password = msg.get("password", "")

    log(f"Opening {protocol.upper()} terminal to {ip}:{port}", "info")

    try:
        if protocol == "ssh":
            if HAS_PARAMIKO:
                # === Paramiko SSH (reliable, handles password auth natively) ===
                client = paramiko.SSHClient()
                client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
                client.connect(
                    ip, port=port, username=username, password=password,
                    timeout=10, allow_agent=False, look_for_keys=False,
                )
                chan = client.invoke_shell(term='xterm', width=120, height=40)
                chan.settimeout(0.1)

                _terminal_sessions[session_id] = {
                    "client": client,
                    "channel": chan,
                    "protocol": "ssh_paramiko",
                }
                # Start reader thread
                rt = threading.Thread(
                    target=_terminal_reader,
                    args=(session_id, chan, "ssh_paramiko"),
                    daemon=True,
                )
                rt.start()
                _terminal_sessions[session_id]["thread"] = rt
                log(f"SSH (paramiko) connected to {ip}:{port}", "success")
            else:
                # === Fallback: ssh.exe subprocess ===
                cmd = [
                    "ssh",
                    "-o", "StrictHostKeyChecking=no",
                    "-o", "UserKnownHostsFile=NUL",
                    "-p", str(port),
                    f"{username}@{ip}",
                ]
                proc = subprocess.Popen(
                    cmd,
                    stdin=subprocess.PIPE,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    creationflags=subprocess.CREATE_NO_WINDOW,
                )
                _terminal_sessions[session_id] = {
                    "proc": proc,
                    "protocol": "ssh",
                }
                rt = threading.Thread(
                    target=_terminal_reader,
                    args=(session_id, proc, "ssh"),
                    daemon=True,
                )
                rt.start()
                _terminal_sessions[session_id]["thread"] = rt
                log(f"SSH (subprocess fallback) to {ip}:{port}", "info")

        else:  # telnet
            sock = socket.create_connection((ip, port), timeout=10)
            _terminal_sessions[session_id] = {
                "sock": sock,
                "protocol": "telnet",
            }
            # Start reader thread
            rt = threading.Thread(
                target=_terminal_reader,
                args=(session_id, sock, "telnet"),
                daemon=True,
            )
            rt.start()
            _terminal_sessions[session_id]["thread"] = rt

            # Auto-login for telnet: send username/password after prompts
            if username:
                import time as _t
                _t.sleep(1.5)  # Wait for login prompt
                try:
                    sock.sendall((username + "\r\n").encode())
                    if password:
                        _t.sleep(0.8)  # Wait for password prompt
                        sock.sendall((password + "\r\n").encode())
                except Exception:
                    pass

        # Notify server session is ready
        if ws_socket:
            ws_send(ws_socket, json.dumps({
                "type": "terminal_ready",
                "session_id": session_id,
            }))
        log(f"Terminal session {session_id[:8]} started ({protocol} → {ip}:{port})", "success")

    except paramiko.AuthenticationException if HAS_PARAMIKO else Exception:
        log(f"SSH auth failed for {username}@{ip}:{port}", "error")
        if ws_socket:
            try:
                ws_send(ws_socket, json.dumps({
                    "type": "terminal_error",
                    "session_id": session_id,
                    "error": f"Authentication failed for {username}@{ip}",
                }))
            except Exception:
                pass
    except Exception as e:
        log(f"Terminal open error: {e}", "error")
        if ws_socket:
            try:
                ws_send(ws_socket, json.dumps({
                    "type": "terminal_error",
                    "session_id": session_id,
                    "error": str(e),
                }))
            except Exception:
                pass


def handle_terminal_input(msg):
    """Forward user keystrokes to terminal session."""
    import base64
    session_id = msg.get("session_id", "")
    data = msg.get("data", "")
    session = _terminal_sessions.get(session_id)
    if not session:
        return

    try:
        raw = base64.b64decode(data)
        if session["protocol"] == "ssh_paramiko":
            session["channel"].send(raw)
        elif session["protocol"] == "ssh":
            session["proc"].stdin.write(raw)
            session["proc"].stdin.flush()
        else:
            session["sock"].sendall(raw)
    except Exception as e:
        log(f"Terminal input error: {e}", "error")


def handle_terminal_close(msg):
    """Close terminal session."""
    session_id = msg.get("session_id", "")
    session = _terminal_sessions.pop(session_id, None)
    if not session:
        return

    try:
        if session["protocol"] == "ssh_paramiko":
            session["channel"].close()
            session["client"].close()
        elif session["protocol"] == "ssh":
            session["proc"].terminate()
        else:
            session["sock"].close()
    except Exception:
        pass
    log(f"Terminal session {session_id[:8]} closed by user", "info")


def handle_ping_test(msg):
    """Execute ping from Agent and send result back to server."""
    # Run in thread to avoid blocking WS loop (ping takes ~3s)
    threading.Thread(target=_do_ping_test, args=(msg,), daemon=True).start()


def _do_ping_test(msg):
    """Internal: run ping subprocess in background thread."""
    task_id = msg.get("task_id", "")
    ip = msg.get("ip", "")
    log(f"Ping test for {ip}", "info")
    try:
        result = subprocess.run(
            ["ping", "-n", "3", "-w", "1000", ip],
            capture_output=True, text=True, timeout=10,
            creationflags=subprocess.CREATE_NO_WINDOW,
        )
        reachable = result.returncode == 0
        latency_ms = None
        if reachable:
            import re
            # Windows: Average = 1ms  or  Minimum = 0ms, Maximum = 1ms, Average = 0ms
            avg_match = re.search(r"Average\s*=\s*(\d+)", result.stdout)
            if avg_match:
                latency_ms = int(avg_match.group(1))

        if ws_socket:
            ws_send(ws_socket, json.dumps({
                "type": "ping_test_result",
                "task_id": task_id,
                "result": {
                    "reachable": reachable,
                    "latency_ms": latency_ms,
                    "ip": ip,
                },
            }))
        log(f"Ping {ip}: {'OK' if reachable else 'FAIL'} {latency_ms}ms", "success" if reachable else "error")
    except Exception as e:
        log(f"Ping test error: {e}", "error")
        if ws_socket:
            ws_send(ws_socket, json.dumps({
                "type": "ping_test_result",
                "task_id": task_id,
                "result": {"reachable": False, "latency_ms": None, "ip": ip, "error": str(e)},
            }))


# ═══ WebSocket sync loop ═══
def ws_loop():
    """Maintain WebSocket connection and send scan data"""
    global ws_socket
    while not stop_event.is_set():
        if not agent_state["connected"]:
            stop_event.wait(2)
            continue

        key = agent_state["service_key"]
        ws_path = f"/ws/poller/{key}"

        try:
            log("WS: Connecting to cloud...", "info")
            ws_socket = ws_connect(SERVER_HOST, SERVER_PORT, ws_path)
            agent_state["ws_connected"] = True
            log("WS: Connected to NetTopo Cloud!", "success")

            # Send heartbeat immediately to keep connection alive
            try:
                ws_send(ws_socket, json.dumps({"type": "heartbeat"}))
                resp = ws_recv(ws_socket, timeout=5)
                if resp:
                    log(f"WS: Heartbeat OK", "info")
            except Exception as e:
                log(f"WS: Heartbeat failed: {e}", "error")
                raise

            # Main sync loop
            while not stop_event.is_set() and agent_state["connected"]:
                # Collect all data FIRST (before any WS calls)
                agent_state["scan_count"] += 1
                devices = scan_network_fast()
                agent_state["devices_found"] = len(devices)
                agent_state["latency"] = measure_latency()
                agent_state["subnet"] = get_local_subnet()
                agent_state["last_devices"] = devices

                try:
                    traffic = collect_traffic_data()
                except Exception:
                    traffic = None

                log(f"Scan #{agent_state['scan_count']} -- {agent_state['subnet']} -- {len(devices)} devices -- {agent_state['latency']}", "scan")

                # --- Send all data quickly ---
                try:
                    # Set socket timeout to prevent infinite blocking
                    ws_socket.settimeout(20)

                    def ws_recv_ack(sock, timeout=15):
                        """Receive ack, but also handle any commands that arrive before ack.
                        Without this, a winrm_test_task that arrives during sync phase
                        gets consumed as the awaited ack and silently dropped — backend
                        then 25s-timeouts on the test response."""
                        deadline = time.time() + timeout
                        while time.time() < deadline:
                            remaining = max(0.5, deadline - time.time())
                            resp = ws_recv(sock, timeout=remaining)
                            if resp is None:
                                return None
                            try:
                                msg = json.loads(resp)
                                msg_type = msg.get("type", "")
                                if msg_type in ("ack", "pong"):
                                    return resp
                                elif msg_type == "winrm_test_task":
                                    task = msg.get("task", {})
                                    task_id = msg.get("task_id")
                                    log(f"Received WinRM Test request for {task.get('ip')}", "info")
                                    res_map = execute_winrm_tasks([task])
                                    res = res_map.get(task.get("id", task.get("ip")))
                                    ws_send(sock, json.dumps({
                                        "type": "winrm_test_result",
                                        "task_id": task_id,
                                        "result": res
                                    }))
                                    log(f"WinRM Test completed: {res.get('status') if res else 'None'}", "success" if res and res.get('status') == 'ok' else "error")
                                    # Reset deadline: PowerShell exec ate the original 5s
                                    # pong-window. Without reset, every test_task that
                                    # arrives during heartbeat-await raises a phantom
                                    # "Connection lost waiting for pong".
                                    deadline = time.time() + timeout
                                elif msg_type == "winrm_task":
                                    tasks = msg.get("tasks", [])
                                    log(f"Received {len(tasks)} WinRM monitoring tasks", "info")
                                    results = execute_winrm_tasks(tasks)
                                    ws_send(sock, json.dumps({"type": "winrm_result", "metrics": results}))
                                    deadline = time.time() + timeout
                                elif msg_type == "terminal_open":
                                    handle_terminal_open(msg)
                                    deadline = time.time() + timeout
                                elif msg_type == "terminal_input":
                                    handle_terminal_input(msg)
                                elif msg_type == "terminal_close":
                                    handle_terminal_close(msg)
                                elif msg_type == "ping_test":
                                    handle_ping_test(msg)
                                    deadline = time.time() + timeout
                                else:
                                    return resp
                            except json.JSONDecodeError:
                                return resp
                        return None

                    # 1) scan_result → server sends ack
                    ws_send(ws_socket, json.dumps({
                        "type": "scan_result",
                        "devices": devices,
                        "metrics": {},
                    }))
                    if ws_recv_ack(ws_socket, timeout=15) is None:
                        raise ConnectionError("Connection lost waiting for scan ack")

                    # 2) traffic_update + storm_metrics → server sends ack
                    if traffic:
                        traffic_msg = {
                            "type": "traffic_update",
                            "traffic": traffic,
                            "timestamp": time.time(),
                        }
                        try:
                            storm = collect_storm_metrics()
                            traffic_msg["storm_metrics"] = storm
                        except Exception as e:
                            log(f"Storm metrics error: {e}", "warning")
                        ws_send(ws_socket, json.dumps(traffic_msg))
                        if ws_recv_ack(ws_socket, timeout=10) is None:
                            raise ConnectionError("Connection lost waiting for traffic ack")

                    # 3) heartbeat — REMOVED.
                    # The wait_interval loop below already sends a binary PING
                    # (RFC 6455 0x09) + JSON heartbeat every 5s and uses
                    # select() to reply to server PINGs in real time, so this
                    # extra heartbeat in the sync phase is redundant. With the
                    # backend's FIFO message queue (scan_result + traffic_update
                    # + previous wait_loop heartbeats), the 5s pong-await here
                    # raced under load, producing constant phantom
                    # "Connection lost waiting for pong" disconnects.
                    # Connection liveness is still covered: scan_ack (15s) and
                    # traffic_ack (10s) above will catch any real socket break.

                    cpu_str = f" | CPU:{traffic['system']['cpu_percent']:.0f}% RAM:{traffic['system']['ram_percent']}%" if traffic else ""
                    log(f"Cloud sync OK ({len(devices)} devices){cpu_str}", "success")

                except Exception as e:
                    log(f"WS sync error: {e}", "error")
                    break

                # Wait interval: Use select() for non-blocking read + accurate sleep
                # This guarantees we reply to server PINGs immediately and keep the connection alive
                import select
                wait_time = 0
                last_heartbeat = time.time()
                while wait_time < SCAN_INTERVAL and not stop_event.is_set():
                    try:
                        r, _, _ = select.select([ws_socket], [], [], 1.0)
                        if r:
                            resp = ws_recv(ws_socket, timeout=1)
                            if resp is None:
                                raise ConnectionError("Socket closed")
                            
                            try:
                                msg = json.loads(resp)
                                if msg.get("type") == "winrm_task":
                                    tasks = msg.get("tasks", [])
                                    log(f"Received {len(tasks)} WinRM monitoring tasks from Cloud", "info")
                                    results = execute_winrm_tasks(tasks)
                                    ws_send(ws_socket, json.dumps({
                                        "type": "winrm_result",
                                        "metrics": results
                                    }))
                                    log(f"Completed {len(tasks)} WinRM tasks", "success")
                                    
                                elif msg.get("type") == "winrm_test_task":
                                    task = msg.get("task", {})
                                    task_id = msg.get("task_id")
                                    log(f"Received WinRM Test request for {task.get('ip')}", "info")
                                    
                                    # Execute single task
                                    res_map = execute_winrm_tasks([task])
                                    res = res_map.get(task.get("id", task.get("ip")))
                                    
                                    ws_send(ws_socket, json.dumps({
                                        "type": "winrm_test_result",
                                        "task_id": task_id,
                                        "result": res
                                    }))
                                    log(f"WinRM Test for {task.get('ip')} completed: {res.get('status')}", "success" if res.get('status') == 'ok' else "error")

                                elif msg.get("type") == "terminal_open":
                                    handle_terminal_open(msg)
                                elif msg.get("type") == "terminal_input":
                                    handle_terminal_input(msg)
                                elif msg.get("type") == "terminal_close":
                                    handle_terminal_close(msg)
                                elif msg.get("type") == "ping_test":
                                    handle_ping_test(msg)
                            except json.JSONDecodeError:
                                pass
                            except Exception as e:
                                log(f"Error handling WS message: {e}", "error")
                        else:
                            wait_time += 1
                            
                        # Send heartbeat every 5 seconds using real time to prevent tight loop
                        if time.time() - last_heartbeat >= 5.0:
                            # 1. Protocol-level PING (Opcode 0x09) for Cloudflare/Nginx proxy
                            import os as _os
                            mask = _os.urandom(4)
                            frame = bytearray([0x89, 0x80])
                            frame.extend(mask)
                            ws_socket.sendall(frame)
                            
                            # 2. Software heartbeat for FastAPI backend
                            ws_send(ws_socket, json.dumps({"type": "heartbeat"}))
                            last_heartbeat = time.time()
                            
                    except Exception as e:
                        break

        except Exception as e:
            log(f"WS error: {e}", "error")
            agent_state["ws_connected"] = False

        # Cleanup
        if ws_socket:
            try:
                ws_socket.close()
            except Exception:
                pass
            ws_socket = None
        agent_state["ws_connected"] = False

        if agent_state["connected"]:
            # Reconnect almost instantly to prevent dashboard "offline" flicker
            stop_event.wait(1)


# ═══ HTML UI ═══
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NetTopo 3D AI Agent</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#020617;color:#e2e8f0;height:100vh;display:flex;flex-direction:column;overflow:hidden}
.header{background:linear-gradient(135deg,#0f172a,#1e293b);border-bottom:1px solid #1e293b;padding:8px 20px;display:flex;align-items:center;justify-content:space-between;min-height:48px}
.h-left{display:flex;align-items:center;gap:10px}
.logo{width:30px;height:30px;background:linear-gradient(135deg,#0ea5e9,#38bdf8);border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;box-shadow:0 2px 10px rgba(14,165,233,0.3)}
.h-title{font-size:14px;font-weight:700;background:linear-gradient(135deg,#38bdf8,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.h-sub{font-size:8px;color:#64748b;letter-spacing:1.5px;text-transform:uppercase}
.badge{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:20px;font-size:10px;font-weight:600}
.badge.off{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:#f87171}
.badge.on{background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);color:#34d399}
.badge .dot{width:7px;height:7px;border-radius:50%}
.badge.off .dot{background:#ef4444}
.badge.on .dot{background:#10b981;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(16,185,129,0.4)}50%{box-shadow:0 0 0 5px rgba(16,185,129,0)}}
.controls{background:#0f172a;border-bottom:1px solid #1e293b;padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.key-input{flex:1;min-width:200px;padding:8px 12px;background:#020617;border:1px solid #334155;border-radius:7px;color:#e2e8f0;font-family:'Fira Code',monospace;font-size:11px;outline:none;transition:border 0.2s}
.key-input:focus{border-color:#0ea5e9;box-shadow:0 0 0 2px rgba(14,165,233,0.1)}
.key-input::placeholder{color:#475569}
.btn{padding:8px 18px;border:none;border-radius:7px;font-family:'Inter',sans-serif;font-size:11px;font-weight:600;cursor:pointer;transition:all 0.2s;display:flex;align-items:center;gap:5px}
.btn-connect{background:linear-gradient(135deg,#0ea5e9,#0284c7);color:#fff;box-shadow:0 2px 10px rgba(14,165,233,0.3)}
.btn-connect:hover{box-shadow:0 4px 16px rgba(14,165,233,0.4)}
.btn-disconnect{background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff}
.btn:disabled{opacity:0.5;cursor:not-allowed}
.stats{display:flex;gap:6px;margin-left:auto}
.stat{background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:4px 12px;text-align:center;min-width:65px}
.stat-val{font-size:16px;font-weight:700;color:#38bdf8}
.stat-lbl{font-size:7px;color:#64748b;text-transform:uppercase;letter-spacing:1px;font-weight:600}
.main{display:flex;flex:1;overflow:hidden;position:relative}
.console-wrap{width:300px;background:#0a0f1a;border-right:1px solid #1e293b;display:flex;flex-direction:column}
.console-head{padding:8px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #1e293b}
.console-title{font-size:9px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:1.2px}
.clear-btn{padding:3px 8px;background:#1e293b;border:none;border-radius:4px;color:#64748b;font-size:8px;cursor:pointer;font-family:'Inter',sans-serif}
.clear-btn:hover{background:#334155;color:#94a3b8}
.console{flex:1;padding:8px;font-family:'Fira Code','Consolas',monospace;font-size:10px;line-height:1.7;overflow-y:auto;color:#94a3b8}
.console::-webkit-scrollbar{width:3px}
.console::-webkit-scrollbar-thumb{background:#334155;border-radius:2px}
.log-time{color:#475569}.log-info{color:#38bdf8}.log-success{color:#10b981}.log-warning{color:#f59e0b}.log-error{color:#ef4444}.log-scan{color:#8b5cf6}
.browser{flex:1;display:flex;flex-direction:column}
.browser-bar{display:flex;align-items:center;gap:6px;padding:6px 10px;background:#0f172a;border-bottom:1px solid #1e293b}
.dots{display:flex;gap:4px}
.dots span{width:9px;height:9px;border-radius:50%}
.dots span:nth-child(1){background:#ef4444}
.dots span:nth-child(2){background:#f59e0b}
.dots span:nth-child(3){background:#10b981}
.url-bar{flex:1;padding:5px 10px;background:#020617;border:1px solid #1e293b;border-radius:5px;color:#64748b;font-size:10px}
.refresh{width:24px;height:24px;border-radius:5px;background:#1e293b;border:none;color:#94a3b8;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center}
.refresh:hover{background:#334155;color:#e2e8f0}
iframe{flex:1;border:none;background:#020617}
.footer{background:#0f172a;border-top:1px solid #1e293b;padding:4px 16px;text-align:center;font-size:9px;color:#475569}
.footer a{color:#0ea5e9;text-decoration:none}
</style>
</head>
<body>
<div class="header">
  <div class="h-left">
    <div class="logo">N</div>
    <div><div class="h-title">NetTopo 3D AI Agent</div><div class="h-sub">Remote Network Monitor</div></div>
  </div>
  <div id="badge" class="badge off"><span class="dot"></span><span id="badgeText">Disconnected</span></div>
</div>
<div class="controls">
  <span style="color:#64748b;font-size:11px;font-weight:600;white-space:nowrap">Poller Key:</span>
  <input type="text" id="key" class="key-input" placeholder="Enter Poller Key to connect..." />
  <button id="connectBtn" class="btn btn-connect" onclick="toggleConnect()">Connect</button>
  <div class="stats">
    <div class="stat"><div class="stat-val" id="sDevices">0</div><div class="stat-lbl">Devices</div></div>
    <div class="stat"><div class="stat-val" id="sScans">0</div><div class="stat-lbl">Scans</div></div>
    <div class="stat"><div class="stat-val" id="sUptime">0m</div><div class="stat-lbl">Uptime</div></div>
    <div class="stat"><div class="stat-val" id="sLatency">---</div><div class="stat-lbl">Latency</div></div>
  </div>
</div>
<div class="main">
  <div class="console-wrap">
    <div class="console-head">
      <span class="console-title">Activity Log</span>
      <button class="clear-btn" onclick="clearLog()">Clear</button>
    </div>
    <div class="console" id="console"></div>
  </div>
  <div class="browser">
    <div class="browser-bar">
      <div class="dots"><span></span><span></span><span></span></div>
      <div class="url-bar">""" + DASHBOARD_URL + """</div>
      <button class="refresh" onclick="refreshDash()" title="Refresh">R</button>
    </div>
    <iframe id="dash" src=\"""" + DASHBOARD_URL + """\" allow="clipboard-read; clipboard-write"></iframe>
  </div>
</div>
<div class="footer">NetTopo 3D AI Agent v""" + APP_VERSION + """ -- SmartMindHead -- <a href="http://nettopo.net" target="_blank">nettopo.net</a></div>
<script>
let connected=false,pollTimer=null,uptimeStart=null,uptimeTimer=null,lastLogLen=0;
function toggleConnect(){if(connected)doDisconnect();else doConnect();}
function doConnect(){
  const key=document.getElementById('key').value.trim();
  if(!key){addLog('Enter a valid Poller Key','warning');return}
  const btn=document.getElementById('connectBtn');
  btn.disabled=true;btn.textContent='Connecting...';
  fetch('/api/connect?key='+encodeURIComponent(key))
    .then(r=>r.json()).then(d=>{
      if(d.ok){
        connected=true;uptimeStart=Date.now();
        btn.disabled=false;btn.textContent='Disconnect';btn.className='btn btn-disconnect';
        document.getElementById('key').disabled=true;
        document.getElementById('badge').className='badge on';
        document.getElementById('badgeText').textContent='Connected';
        pollTimer=setInterval(pollStatus,3000);
        uptimeTimer=setInterval(updateUptime,30000);
        pollStatus();
      }else{btn.disabled=false;btn.textContent='Connect';addLog(d.error,'error');}
    }).catch(e=>{btn.disabled=false;btn.textContent='Connect';addLog(''+e,'error');});
}
function doDisconnect(){
  fetch('/api/disconnect').then(()=>{
    connected=false;
    const btn=document.getElementById('connectBtn');
    btn.textContent='Connect';btn.className='btn btn-connect';
    document.getElementById('key').disabled=false;
    document.getElementById('badge').className='badge off';
    document.getElementById('badgeText').textContent='Disconnected';
    clearInterval(pollTimer);clearInterval(uptimeTimer);
  });
}
function pollStatus(){
  fetch('/api/status').then(r=>r.json()).then(d=>{
    document.getElementById('sDevices').textContent=d.devices_found;
    document.getElementById('sScans').textContent=d.scan_count;
    document.getElementById('sLatency').textContent=d.latency;
    const logs=d.logs||[];
    if(logs.length>lastLogLen){
      for(let i=lastLogLen;i<logs.length;i++) addLogRaw(logs[i].time,logs[i].msg,logs[i].level);
      lastLogLen=logs.length;
    }
  }).catch(()=>{});
}
function updateUptime(){
  if(!uptimeStart)return;
  const m=Math.floor((Date.now()-uptimeStart)/60000);
  document.getElementById('sUptime').textContent=m<60?m+'m':m<1440?Math.floor(m/60)+'h':Math.floor(m/1440)+'d';
}
function addLog(msg,level){
  const c=document.getElementById('console');
  const t=new Date().toLocaleTimeString('en',{hour12:false});
  c.innerHTML+='<div><span class="log-time">['+t+']</span> <span class="log-'+level+'">'+msg+'</span></div>';
  c.scrollTop=c.scrollHeight;
}
function addLogRaw(time,msg,level){
  const c=document.getElementById('console');
  c.innerHTML+='<div><span class="log-time">['+time+']</span> <span class="log-'+level+'">'+msg+'</span></div>';
  c.scrollTop=c.scrollHeight;
}
function clearLog(){document.getElementById('console').innerHTML='';lastLogLen=999;}
function refreshDash(){document.getElementById('dash').src='""" + DASHBOARD_URL + """';}
addLog('NetTopo 3D AI Agent v""" + APP_VERSION + """','info');
addLog('Enter your Poller Key to connect.','info');
</script>
</body>
</html>"""


class AgentHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path

        if path == "/" or path == "/index.html":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(HTML_PAGE.encode("utf-8"))

        elif path == "/api/connect":
            params = parse_qs(parsed.query)
            key = params.get("key", [""])[0]
            if not key:
                self._json({"ok": False, "error": "No key provided"})
                return

            agent_state["service_key"] = key
            log("Connecting to cloud server...", "info")
            log(f"   Server: {SERVER_HOST}:{SERVER_PORT}", "info")
            log(f"   Key: {key[:8]}...{key[-8:] if len(key) > 8 else key}", "info")

            # Test connectivity
            try:
                sock = socket.create_connection((SERVER_HOST, SERVER_PORT), timeout=5)
                sock.close()
            except Exception as e:
                log(f"Cannot reach server: {e}", "error")
                self._json({"ok": False, "error": str(e)})
                return

            agent_state["connected"] = True
            agent_state["start_time"] = time.time()
            stop_event.clear()
            log("Connected to NetTopo Cloud!", "success")
            log("Starting network discovery + cloud sync...", "scan")
            self._json({"ok": True})

        elif path == "/api/disconnect":
            agent_state["connected"] = False
            stop_event.set()
            log("Disconnected from cloud.", "warning")
            self._json({"ok": True})

        elif path == "/api/status":
            self._json({
                "connected": agent_state["connected"],
                "ws_connected": agent_state["ws_connected"],
                "devices_found": agent_state["devices_found"],
                "scan_count": agent_state["scan_count"],
                "latency": agent_state["latency"],
                "logs": agent_state["logs"],
            })

        else:
            self.send_response(404)
            self.end_headers()

    def _json(self, data):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())


def main():
    log("Agent starting...", "info")

    # Start WebSocket sync thread
    ws_thread = threading.Thread(target=ws_loop, daemon=True)
    ws_thread.start()

    # Start HTTP server
    server = HTTPServer(("127.0.0.1", LOCAL_PORT), AgentHandler)
    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
    server_thread.start()

    url = f"http://127.0.0.1:{LOCAL_PORT}"
    print(f"\n  NetTopo 3D AI Agent v{APP_VERSION}")
    print(f"  Dashboard: {DASHBOARD_URL}")
    print(f"  Agent UI:  {url}\n")

    webbrowser.open(url)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\n[Agent] Shutting down...")
        stop_event.set()
        server.shutdown()


if __name__ == "__main__":
    main()
