#!/usr/bin/env python3
"""
OpsMonitor Tool v1.0 -- Herramienta unificada
Modos:
  Sin argumentos  -> Menu interactivo (scanner, auditoria, latencia)
  --heartbeat     -> Modo agente en background (para Tarea Programada de Windows)
Requisitos: Python 3.8+ y nmap instalados con "Add to PATH"
Ejecutar como Administrador para resultados completos.
"""
import argparse
import ipaddress
import json
import platform
import re
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET

# ── Configuracion ──────────────────────────────────────────────────────────────
API_BASE = "https://opsmonitor.emprendistore.com/api/v1"
VERSION  = "1.0"
IS_WIN   = platform.system().lower() == "windows"
CFG_FILE = Path(__file__).parent / "agente_config.json"
LOG_FILE = Path(__file__).parent / "agente.log"

try:
    import requests
except ImportError:
    print("  Instalando modulo 'requests'...")
    subprocess.run([sys.executable, "-m", "pip", "install", "requests", "-q"], check=True)
    import requests


# ── Logging ────────────────────────────────────────────────────────────────────

def log(msg: str, to_file: bool = True):
    ts   = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line)
    if not to_file:
        return
    try:
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            f.write(line + "\n")
        if LOG_FILE.stat().st_size > 500_000:
            lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
            LOG_FILE.write_text("\n".join(lines[-200:]) + "\n", encoding="utf-8")
    except Exception:
        pass


# ── Tablas de clasificacion ────────────────────────────────────────────────────

VENDOR_RULES = [
    (["cisco", "juniper", "extreme network"],               "switch_router",      "Switch/Router Empresarial"),
    (["mikrotik"],                                          "switch_router",      "Router MikroTik"),
    (["ubiquiti", "ruckus", "cambium", "aruba"],            "router_ap",          "AP/Router Empresarial"),
    (["tp-link", "tplink", "netgear", "asus", "linksys"],  "router_ap",          "Router/AP"),
    (["d-link"],                                            "router_ap",          "Router D-Link"),
    (["mitrastar"],                                         "router_ap",          "Router/ONT ISP (MitraStar)"),
    (["technicolor", "sagemcom", "calix", "zhone",
      "alcatel-lucent", "nokia bell", "humax",
      "actiontec", "arris", "motorola solutions"],          "router_ap",          "Modem/ONT ISP"),
    (["mercusys"],                                          "residential_router", "Router Residencial (Mercusys)"),
    (["tenda"],                                             "residential_router", "Router Residencial (Tenda)"),
    (["zte"],                                               "residential_router", "Router ISP/Residencial (ZTE)"),
    (["brother", "canon", "epson", "xerox", "lexmark",
      "ricoh", "konica", "sharp", "kyocera"],               "printer",            "Impresora"),
    (["dell"],                                              "pc",                 "PC/Servidor Dell"),
    (["lenovo"],                                            "pc",                 "PC/Laptop Lenovo"),
    (["acer"],                                              "pc",                 "PC/Laptop Acer"),
    (["intel corp", "intel(r)"],                            "pc",                 "PC (Intel NIC)"),
    (["realtek"],                                           "pc",                 "PC (Realtek NIC)"),
    (["samsung"],                                           "mobile",             "Celular/Tablet Samsung"),
    (["xiaomi", "redmi", "poco"],                           "mobile",             "Celular Xiaomi"),
    (["motorola", "motorola mobility"],                     "mobile",             "Celular Motorola"),
    (["oneplus"],                                           "mobile",             "Celular OnePlus"),
    (["hmd global", "hmd mobile"],                          "mobile",             "Celular Nokia"),
    (["oppo"],                                              "mobile",             "Celular OPPO"),
    (["vivo mobile"],                                       "mobile",             "Celular Vivo"),
    (["realme"],                                            "mobile",             "Celular Realme"),
    (["lg electronics"],                                    "mobile",             "Celular/TV LG"),
    (["sony mobile", "sony interactive"],                   "mobile",             "Celular Sony"),
    (["wiko"],                                              "mobile",             "Celular Wiko"),
    (["azurewave", "espressif", "raspberry", "arduino"],    "iot",                "Dispositivo IoT"),
    (["apple"],                                             "apple",              "Apple (Mac/iPhone/iPad)"),
    (["vmware", "virtualbox", "parallels"],                 "virtual",            "Maquina Virtual"),
]

RESIDENTIAL_VENDOR_KEYWORDS = ["mercusys", "tenda", "huawei", "zte"]
SMB_PORTS   = {139, 445}
PRINT_PORTS = {9100, 515, 631, 9220, 9500}
SCAN_PORTS  = "22,23,80,139,443,445,515,631,3389,5985,8080,8443,9100,9220,9500"

PORT_RULES = [
    ([9100, 515, 631, 9220, 9500], "printer",   "Impresora"),
    ([3389, 5985],                 "pc_win",    "PC Windows"),
    ([22],                         "server",    "Servidor Linux"),
    ([8080, 8443],                 "router_ap", "Router/AP"),
    ([23],                         "managed",   "Dispositivo Administrable (Telnet)"),
]

DEVICE_ICONS = {
    "switch_router":     "(*)",
    "router_ap":         "(~)",
    "residential_router":"(!)",
    "printer":           "(P)",
    "pc":                "(C)",
    "pc_win":            "(C)",
    "file_server":       "(S)",
    "server":            "(S)",
    "iot":               "(I)",
    "apple":             "(A)",
    "mobile":            "(M)",
    "possible_mobile":   "(M)",
    "virtual":           "(V)",
    "managed":           "(G)",
    "unknown":           "(?)",
}


# ── Clasificacion de dispositivos ──────────────────────────────────────────────

def is_randomized_mac(mac: str) -> bool:
    if not mac or mac in ("N/A", ""):
        return False
    first_byte = mac.replace(":", "").replace("-", "")[:2]
    try:
        return (int(first_byte, 16) & 0x02) == 0x02
    except ValueError:
        return False


def classify_device(vendor="", open_ports=None, hostname=""):
    if open_ports is None:
        open_ports = []
    vendor_l   = vendor.lower()
    hostname_l = hostname.lower()

    if any(x in vendor_l for x in ["hewlett", "hp inc", "hewlett packard"]):
        if any(p in open_ports for p in [9100, 515, 631]):
            return "printer", "Impresora HP"
        return "pc", "PC/Laptop HP"

    if "huawei" in vendor_l:
        if any(p in open_ports for p in {23, 80, 443, 8080, 8443}):
            return "residential_router", "Router ISP/Residencial (Huawei)"
        return "mobile", "Celular/Tablet Huawei"

    for keywords, dtype, label in VENDOR_RULES:
        if any(kw in vendor_l for kw in keywords):
            if dtype in ("pc", "pc_win") and SMB_PORTS.intersection(open_ports):
                return "file_server", "Servidor de Archivos (PC compartida)"
            return dtype, label

    for ports, dtype, label in PORT_RULES:
        if any(p in open_ports for p in ports):
            return dtype, label

    if SMB_PORTS.intersection(open_ports):
        if PRINT_PORTS.intersection(open_ports):
            return "printer", "Impresora (con carpetas compartidas)"
        return "file_server", "Servidor de Archivos (PC compartida)"

    for kw in ["printer", "print", "mfp", "copier"]:
        if kw in hostname_l:
            return "printer", "Impresora"
    for kw in ["router", "gateway", "gw", "fw", "firewall", "ap-"]:
        if kw in hostname_l:
            return "router_ap", "Router/Firewall"
    for kw in ["server", "srv", "nas", "dc-", "dc1"]:
        if kw in hostname_l:
            return "server", "Servidor"

    return "unknown", "Desconocido"


def is_residential_router(vendor, device_type):
    if device_type in ("mobile", "possible_mobile"):
        return False
    if device_type == "residential_router":
        return True
    if device_type in ("router_ap", "switch_router"):
        return any(kw in vendor.lower() for kw in RESIDENTIAL_VENDOR_KEYWORDS)
    return False


# ── Velocidades ────────────────────────────────────────────────────────────────

def parse_speed_bps(s):
    s = str(s).lower().strip()
    m = re.match(r"(\d+\.?\d*)\s*(g|m|k)?bps?", s)
    if not m:
        return 0
    val  = float(m.group(1))
    unit = m.group(2) or ""
    return int(val * {"g": 1e9, "m": 1e6, "k": 1e3}.get(unit, 1))


def classify_speed(bps):
    if bps >= 1_000_000_000:
        return "gigabit", "1 Gbps"
    if bps >= 100_000_000:
        return "100m", "100 Mbps"
    if bps >= 10_000_000:
        return "10m", "10 Mbps"
    if bps > 0:
        return "other", f"{bps // 1_000_000} Mbps"
    return "unknown", "Desconocido"


# ── Red ────────────────────────────────────────────────────────────────────────

def get_local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "0.0.0.0"


def get_own_nics():
    if not IS_WIN:
        return []
    try:
        cmd = [
            "powershell", "-NoProfile", "-Command",
            "Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | "
            "Select-Object Name, LinkSpeed, MacAddress | ConvertTo-Json -Compress",
        ]
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        data = json.loads(r.stdout.strip())
        if isinstance(data, dict):
            data = [data]
        nics = []
        for a in data:
            spd = parse_speed_bps(a.get("LinkSpeed", "0"))
            sc, sl = classify_speed(spd)
            nics.append({
                "name":        a.get("Name", ""),
                "mac":         a.get("MacAddress", "").replace("-", ":"),
                "speed_bps":   spd,
                "speed_label": sl,
                "speed_class": sc,
            })
        return nics
    except Exception:
        return []


# ── nmap ───────────────────────────────────────────────────────────────────────

def run_nmap(args, timeout=180):
    cmd = ["nmap", "-oX", "-"] + args
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return r.stdout
    except subprocess.TimeoutExpired:
        print("  [!] Timeout -- resultados parciales.")
        return None
    except FileNotFoundError:
        print("\n  [ERROR] nmap no encontrado. Instalar desde https://nmap.org")
        sys.exit(1)


def get_elem(table, key):
    for el in table.findall("elem"):
        if el.get("key") == key:
            return (el.text or "").strip()
    return ""


# ── Parsers XML ────────────────────────────────────────────────────────────────

def parse_arp_scan(xml_str):
    devices = []
    try:
        root = ET.fromstring(xml_str)
    except ET.ParseError:
        return devices
    for host in root.findall("host"):
        if host.find("status").get("state") != "up":
            continue
        ip = mac = vendor = hostname = None
        for addr in host.findall("address"):
            if addr.get("addrtype") == "ipv4":
                ip = addr.get("addr")
            elif addr.get("addrtype") == "mac":
                mac    = addr.get("addr")
                vendor = addr.get("vendor", "")
        hn_el = host.find("hostnames")
        if hn_el is not None:
            first = hn_el.find("hostname")
            if first is not None:
                hostname = first.get("name", "")
        if ip:
            dtype, dlabel = classify_device(vendor or "", [], hostname or "")
            if dtype == "unknown" and is_randomized_mac(mac or ""):
                dtype, dlabel = "possible_mobile", "Posible Celular (MAC aleatoria)"
            devices.append({
                "ip":                    ip,
                "mac":                   mac or "N/A",
                "vendor":                vendor or "Desconocido",
                "hostname":              hostname or "",
                "device_type":           dtype,
                "device_label":          dlabel,
                "open_ports":            [],
                "interfaces":            [],
                "vlans":                 [],
                "snmp":                  False,
                "speed_class":           "unknown",
                "speed_label":           "Desconocido",
                "speed_bps":             0,
                "is_residential_router": is_residential_router(vendor or "", dtype),
                "has_smb":               False,
            })
    return devices


def parse_port_scan(xml_str, devices_by_ip):
    try:
        root = ET.fromstring(xml_str)
    except ET.ParseError:
        return
    for host in root.findall("host"):
        ip = None
        for addr in host.findall("address"):
            if addr.get("addrtype") == "ipv4":
                ip = addr.get("addr")
                break
        if not ip or ip not in devices_by_ip:
            continue
        dev      = devices_by_ip[ip]
        ports_el = host.find("ports")
        if ports_el is None:
            continue
        open_ports = []
        for port in ports_el.findall("port"):
            state = port.find("state")
            if state is not None and state.get("state") == "open":
                try:
                    open_ports.append(int(port.get("portid", 0)))
                except ValueError:
                    pass
        dev["open_ports"] = open_ports
        dev["has_smb"]    = bool(SMB_PORTS.intersection(open_ports))
        dtype, dlabel     = classify_device(dev["vendor"], open_ports, dev["hostname"])
        if dev["device_type"] == "possible_mobile" and dtype == "unknown" and not open_ports:
            dtype, dlabel = "possible_mobile", dev["device_label"]
        dev["device_type"]           = dtype
        dev["device_label"]          = dlabel
        dev["is_residential_router"] = is_residential_router(dev["vendor"], dtype)


def _parse_interfaces_text(text):
    ifaces, cur = [], None
    for raw in text.split("\n"):
        line = raw.strip()
        if not line:
            if cur:
                ifaces.append(cur)
                cur = None
            continue
        if not raw.startswith("  ") and not raw.startswith("\t"):
            if cur:
                ifaces.append(cur)
            cur = {"name": line, "speed_bps": 0, "speed_label": "N/A", "type": "", "status": ""}
            continue
        if cur is None:
            cur = {"name": "", "speed_bps": 0, "speed_label": "N/A", "type": "", "status": ""}
        if "Speed:" in line:
            v = line.split("Speed:", 1)[1].strip()
            cur["speed_label"] = v
            cur["speed_bps"]   = parse_speed_bps(v)
        elif "Type:" in line:
            cur["type"] = line.split("Type:", 1)[1].strip()
        elif "Status:" in line:
            cur["status"] = line.split("Status:", 1)[1].strip()
    if cur:
        ifaces.append(cur)
    return ifaces


def _parse_vlans_text(text):
    vlans, seen = [], set()
    for line in text.split("\n"):
        m = re.search(r"VLAN\s+(\d+)[:\s]+(.+)", line, re.IGNORECASE)
        if m:
            vid = int(m.group(1))
            if vid not in seen:
                vlans.append({"id": vid, "name": m.group(2).strip()})
                seen.add(vid)
    return vlans


def parse_snmp_scan(xml_str, devices_by_ip):
    try:
        root = ET.fromstring(xml_str)
    except ET.ParseError:
        return
    for host in root.findall("host"):
        ip = None
        for addr in host.findall("address"):
            if addr.get("addrtype") == "ipv4":
                ip = addr.get("addr")
                break
        if not ip or ip not in devices_by_ip:
            continue
        dev      = devices_by_ip[ip]
        ports_el = host.find("ports")
        if ports_el is None:
            continue
        for port in ports_el.findall("port"):
            for script in port.findall("script"):
                sid    = script.get("id", "")
                output = script.get("output", "")
                if sid == "snmp-interfaces":
                    dev["snmp"] = True
                    ifaces      = []
                    outer       = script.find("table")
                    if outer is not None:
                        for iface_tbl in outer.findall("table"):
                            name      = iface_tbl.get("key", "if")
                            speed_str = get_elem(iface_tbl, "Speed")
                            itype     = get_elem(iface_tbl, "Type")
                            status    = get_elem(iface_tbl, "Status")
                            spd_bps   = parse_speed_bps(speed_str)
                            ifaces.append({
                                "name": name, "speed_label": speed_str or "N/A",
                                "speed_bps": spd_bps, "type": itype, "status": status,
                            })
                    if not ifaces:
                        ifaces = _parse_interfaces_text(output)
                    dev["interfaces"] = ifaces
                    eth = [i["speed_bps"] for i in ifaces
                           if "ethernet" in i.get("type", "").lower() and i["speed_bps"] > 0]
                    if not eth:
                        eth = [i["speed_bps"] for i in ifaces if i["speed_bps"] > 0]
                    if eth:
                        bps = max(eth)
                        dev["speed_bps"]   = bps
                        dev["speed_class"], dev["speed_label"] = classify_speed(bps)
                elif sid == "snmp-vlans":
                    dev["snmp"] = True
                    vlans       = []
                    outer       = script.find("table")
                    if outer is not None:
                        for vtbl in outer.findall("table"):
                            vid   = get_elem(vtbl, "vlanId") or vtbl.get("key", "")
                            vname = get_elem(vtbl, "vlanName")
                            try:
                                vlans.append({"id": int(vid), "name": vname})
                            except ValueError:
                                pass
                    if not vlans:
                        vlans = _parse_vlans_text(output)
                    dev["vlans"] = vlans


def build_subnets(devices):
    subnets = {}
    for dev in devices:
        parts = dev["ip"].split(".")
        key   = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
        if key not in subnets:
            subnets[key] = []
        subnets[key].append(dev["ip"])
    return [{"subnet": k, "ips": v, "count": len(v)} for k, v in sorted(subnets.items())]


# ── Traceroute ─────────────────────────────────────────────────────────────────

def run_traceroute(target="8.8.8.8"):
    hops = []
    cmd  = (["tracert", "-d", "-h", "15", "-w", "1000", target] if IS_WIN
            else ["traceroute", "-n", "-m", "15", "-w", "2", target])
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=90, errors="replace")
        for line in r.stdout.splitlines():
            m = re.match(r'^\s*(\d+)', line)
            if not m:
                continue
            hop_num = int(m.group(1))
            m_ip    = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$', line)
            ip      = m_ip.group(1) if m_ip else "*"
            m_lat   = re.search(r'[<]?(\d+)\s+ms', line)
            latency = float(m_lat.group(1)) if m_lat else None
            hops.append({"hop": hop_num, "ip": ip, "latency_ms": latency})
    except Exception:
        pass
    first_ip   = hops[0]["ip"] if hops else None
    first_priv = False
    if first_ip and first_ip != "*":
        try:
            first_priv = ipaddress.ip_address(first_ip).is_private
        except Exception:
            pass
    return {
        "target": target, "hops": hops, "total_hops": len(hops),
        "first_hop_ip": first_ip, "first_hop_private": first_priv,
    }


# ── Ping paralelo ──────────────────────────────────────────────────────────────

def ping_ip(ip: str) -> bool:
    cmd = (["ping", "-n", "1", "-w", "800", ip] if IS_WIN
           else ["ping", "-c", "1", "-W", "1", ip])
    try:
        r = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2)
        return r.returncode == 0
    except Exception:
        return False


def ping_all(ips: list) -> list:
    results: dict = {}
    lock = threading.Lock()

    def worker(ip):
        ok = ping_ip(ip)
        with lock:
            results[ip] = ok

    threads = [threading.Thread(target=worker, args=(ip,), daemon=True) for ip in ips]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=3)
    return [ip for ip in ips if results.get(ip, False)]


# ── API helpers ────────────────────────────────────────────────────────────────

def api_post(url, data, headers=None):
    h = {"Content-Type": "application/json"}
    if headers:
        h.update(headers)
    try:
        resp = requests.post(url, json=data, headers=h, timeout=30)
        return resp.json()
    except Exception as e:
        log(f"Error POST {url}: {e}")
        return None


def api_get(url, headers=None):
    try:
        resp = requests.get(url, headers=headers or {}, timeout=30)
        return resp.json()
    except Exception as e:
        log(f"Error GET {url}: {e}")
        return None


# ── Instalacion del monitoreo continuo ─────────────────────────────────────────

def _install_scheduled_task():
    if getattr(sys, "frozen", False):
        exe_path = str(Path(sys.executable).resolve())
        tr_cmd   = f'"{exe_path}" --heartbeat'
    else:
        script = str(Path(__file__).resolve())
        exe    = str(Path(sys.executable).resolve())
        tr_cmd = f'"{exe}" "{script}" --heartbeat'

    subprocess.run(["schtasks", "/delete", "/tn", "OpsMonitorAgent", "/f"],
                   capture_output=True)
    r = subprocess.run(
        ["schtasks", "/create",
         "/tn", "OpsMonitorAgent",
         "/tr", tr_cmd,
         "/sc", "minute",
         "/mo", "30",
         "/rl", "highest",
         "/f"],
        capture_output=True, text=True,
    )
    return r.returncode == 0


def install_monitor(nombre_cliente: str):
    local_ip = get_local_ip()
    hostname = socket.gethostname()

    # Reutilizar config existente del mismo cliente
    if CFG_FILE.exists():
        try:
            cfg = json.loads(CFG_FILE.read_text(encoding="utf-8"))
            if cfg.get("nombre_cliente") == nombre_cliente and cfg.get("api_key"):
                print(f"  Agente ya configurado para: {nombre_cliente}")
                if IS_WIN:
                    ok = _install_scheduled_task()
                    print("  Tarea programada actualizada (cada 30 min)." if ok
                          else "  [!] No se pudo actualizar la tarea. Ejecutar como Administrador.")
                return
        except Exception:
            pass

    print(f"\n  Registrando agente para: {nombre_cliente}...")
    data = api_post(
        f"{API_BASE}/agente/register",
        {"nombre_cliente": nombre_cliente, "hostname": hostname,
         "ip": local_ip, "version": VERSION},
    )
    if not data or not data.get("api_key"):
        print(f"  [!] Error al registrar con el servidor: {data}")
        return

    api_key = data["api_key"]
    cfg     = {"nombre_cliente": nombre_cliente,
               "api_url": "https://opsmonitor.emprendistore.com",
               "api_key": api_key}
    CFG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
    print(f"  Registro exitoso. Agente ID: {data.get('agente_id', '?')}")

    if IS_WIN:
        ok = _install_scheduled_task()
        if ok:
            print("  Tarea programada creada: monitorea cada 30 min en background.")
        else:
            print("  [!] No se pudo crear la tarea. Ejecutar como Administrador.")
    else:
        print(f"  Para correr manualmente: python {Path(__file__).name} --heartbeat")


# ── Background: scans ──────────────────────────────────────────────────────────

def _bg_scan_monitor(nombre, api_url, local_ip, network):
    log(f"  [monitor] Escaneando {network}...")
    xml_arp = run_nmap(["-sn", "-T4", "--max-retries", "1", network], timeout=180)
    if not xml_arp:
        log("  [monitor] ARP fallo")
        return {"ok": False, "error": "ARP fallo", "total_devices": 0, "n_cambios": 0}

    devices = parse_arp_scan(xml_arp)
    log(f"  [monitor] {len(devices)} dispositivos")

    ips = [d["ip"] for d in devices]
    if ips:
        xml_ports = run_nmap(
            ["-p", SCAN_PORTS, "--open", "-T4", "--max-retries", "1",
             "--host-timeout", "20s"] + ips,
            timeout=180,
        )
        if xml_ports:
            parse_port_scan(xml_ports, {d["ip"]: d for d in devices})

    payload = {
        "timestamp": datetime.now().isoformat(), "network": network,
        "scanner_ip": local_ip, "nombre_cliente": nombre,
        "total": len(devices), "devices": devices, "modo": "monitor",
    }
    result = api_post(f"{api_url}/api/v1/monitor/scan", payload)
    if not result:
        return {"ok": False, "error": "Sin respuesta", "total_devices": len(devices), "n_cambios": 0}
    return {"ok": True, "total_devices": len(devices), "n_cambios": result.get("cambios", 0)}


def _bg_scan_full(nombre, api_url, local_ip, network):
    log(f"  [scan_full] Escaneando {network}...")
    xml_arp = run_nmap(["-sn", "-T4", "--max-retries", "1", network], timeout=300)
    if not xml_arp:
        return {"ok": False, "error": "ARP fallo"}

    devices = parse_arp_scan(xml_arp)

    xml_ports = run_nmap(
        ["-p", SCAN_PORTS, "--open", "-T4", "--max-retries", "1", network], timeout=180)
    if xml_ports:
        parse_port_scan(xml_ports, {d["ip"]: d for d in devices})

    xml_snmp = run_nmap(
        ["-sU", "-p", "161", "--open", "--script", "snmp-interfaces,snmp-vlans", network],
        timeout=480)
    if xml_snmp:
        parse_snmp_scan(xml_snmp, {d["ip"]: d for d in devices})

    traceroute = run_traceroute()

    gigabit   = sum(1 for d in devices if d["speed_class"] == "gigabit")
    fast_eth  = sum(1 for d in devices if d["speed_class"] == "100m")
    slow_eth  = sum(1 for d in devices if d["speed_class"] == "10m")
    unknown_c = sum(1 for d in devices if d["speed_class"] == "unknown")
    file_srvs = sum(1 for d in devices if d["device_type"] == "file_server")
    res_rtrs  = sum(1 for d in devices if d.get("is_residential_router"))

    payload = {
        "timestamp": datetime.now().isoformat(), "network": network,
        "scanner_ip": local_ip, "nombre_cliente": nombre,
        "own_nics": [], "total": len(devices),
        "gigabit": gigabit, "fast_ethernet": fast_eth,
        "slow_ethernet": slow_eth, "unknown": unknown_c,
        "file_servers": file_srvs, "residential_routers": res_rtrs,
        "subnets": build_subnets(devices), "devices": devices,
        "vlans": [], "traceroute": traceroute,
    }
    result = api_post(f"{api_url}/api/v1/scan", payload)
    if not result:
        return {"ok": False, "error": "Sin respuesta"}
    return {"ok": True, "scan_id": result.get("scan_id"), "score": result.get("score")}


# ── Modo heartbeat (Tarea Programada de Windows) ───────────────────────────────

def heartbeat_mode():
    if not CFG_FILE.exists():
        log(f"ERROR: Config no encontrada en {CFG_FILE}")
        log("Ejecutar en modo interactivo, hacer un scanner y aceptar monitoreo.")
        sys.exit(1)

    cfg      = json.loads(CFG_FILE.read_text(encoding="utf-8"))
    api_url  = cfg.get("api_url", "https://opsmonitor.emprendistore.com")
    api_key  = cfg.get("api_key", "")
    nombre   = cfg.get("nombre_cliente", "")
    local_ip = get_local_ip()
    parts    = local_ip.split(".")
    network  = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
    headers  = {"X-Agent-Key": api_key}

    log(f"Heartbeat -- {nombre} @ {local_ip}")

    hb = api_post(
        f"{api_url}/api/v1/agente/heartbeat",
        {"ip": local_ip, "hostname": socket.gethostname(), "version": VERSION},
        headers,
    )
    if hb is None:
        log("Sin conexion con el servidor")
        return

    log(f"Heartbeat OK -- {hb.get('n_pendientes', 0)} comando(s) pendiente(s)")

    res = _bg_scan_monitor(nombre, api_url, local_ip, network)
    log(f"Scan monitor: {res.get('total_devices', 0)} dispositivos, "
        f"{res.get('n_cambios', 0)} cambios")

    n_pend = hb.get("n_pendientes", 0)
    if n_pend > 0:
        cmds = api_get(f"{api_url}/api/v1/agente/poll", headers)
        if cmds:
            for cmd in cmds:
                cid, tipo = cmd["id"], cmd["tipo"]
                log(f"Ejecutando comando {cid}: {tipo}")
                try:
                    if tipo == "scan_monitor":
                        resultado = _bg_scan_monitor(nombre, api_url, local_ip, network)
                    elif tipo == "scan_full":
                        resultado = _bg_scan_full(nombre, api_url, local_ip, network)
                    else:
                        resultado = {"error": f"Tipo desconocido: {tipo}"}
                    estado = "completado" if resultado.get("ok") else "error"
                except Exception as e:
                    resultado = {"error": str(e)}
                    estado    = "error"
                    log(f"Error en comando {cid}: {e}")
                api_post(
                    f"{api_url}/api/v1/agente/resultado/{cid}",
                    {"estado": estado, "resultado": resultado},
                    headers,
                )
                log(f"Comando {cid} finalizado: {estado}")

    log("Ciclo completado.\n")


# ── Modo: Scanner completo ──────────────────────────────────────────────────────

def run_scanner():
    print()
    print("=" * 60)
    print("  Scanner de Red Completo")
    print("  Requiere: nmap instalado con 'Add to PATH'")
    print("  Ejecutar como Administrador para mejores resultados")
    print("=" * 60)
    print()

    nombre_cliente = input("  Nombre del cliente: ").strip()
    if not nombre_cliente:
        nombre_cliente = "Sin cliente"

    local_ip   = get_local_ip()
    parts      = local_ip.split(".")
    auto_range = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
    own_nics   = get_own_nics()

    print()
    print("  Adaptadores de red:")
    if own_nics:
        for n in own_nics:
            print(f"    {n['name']:<20} {n['speed_label']:<12}  {n['mac']}")
    else:
        print("    (No disponible en este sistema)")

    print()
    print(f"  Cliente        : {nombre_cliente}")
    print(f"  IP del scanner : {local_ip}")
    print()
    print("  " + "=" * 56)
    print(f"  RED A ESCANEAR : {auto_range}")
    print("  " + "=" * 56)
    print()
    print("  Presiona ENTER para escanear esa red.")
    print("  Solo si necesitas escanear OTRA red, escribila aqui.")
    rango_in = input("\n  > ").strip()
    rango    = rango_in if rango_in else auto_range
    print(f"\n  [OK] Escaneando: {rango}")
    print()

    print("[1/5] Descubriendo dispositivos...")
    xml_arp = run_nmap(["-sn", "-T4", "--max-retries", "1", rango], timeout=300)
    if not xml_arp:
        print("  [ERROR] ARP scan fallo. Verificar nmap y permisos de Administrador.")
        input("\n  Enter para volver al menu...")
        return

    devices = parse_arp_scan(xml_arp)
    print(f"      {len(devices)} dispositivos encontrados.")

    print()
    print("[2/5] Identificando tipos de dispositivos (puertos)...")
    xml_ports = run_nmap(
        ["-p", SCAN_PORTS, "--open", "-T4", "--max-retries", "1", rango], timeout=180)
    if xml_ports:
        parse_port_scan(xml_ports, {d["ip"]: d for d in devices})
    print("      Hecho.")

    print()
    print("[3/5] Escaneando velocidades SNMP e interfaces...")
    print("      (Puede tardar 3-8 minutos. Aguarda...)")
    xml_snmp = run_nmap(
        ["-sU", "-p", "161", "--open", "--script", "snmp-interfaces,snmp-vlans", rango],
        timeout=480)
    if xml_snmp:
        parse_snmp_scan(xml_snmp, {d["ip"]: d for d in devices})

    print()
    print("[4/5] Trazando ruta hacia internet (8.8.8.8)...")
    print("      (Puede tardar hasta 60 segundos. Aguarda...)")
    traceroute_data = run_traceroute("8.8.8.8")
    if traceroute_data["hops"]:
        print(f"      {traceroute_data['total_hops']} saltos detectados.")
    else:
        print("      No se pudo trazar la ruta.")

    all_vlans: dict = {}
    for dev in devices:
        for vlan in dev.get("vlans", []):
            vid = vlan["id"]
            if vid not in all_vlans:
                all_vlans[vid] = {"id": vid, "name": vlan.get("name", ""), "found_on": []}
            all_vlans[vid]["found_on"].append(dev["ip"])

    gigabit   = sum(1 for d in devices if d["speed_class"] == "gigabit")
    fast      = sum(1 for d in devices if d["speed_class"] == "100m")
    slow      = sum(1 for d in devices if d["speed_class"] == "10m")
    unknown   = sum(1 for d in devices if d["speed_class"] == "unknown")
    file_srvs = sum(1 for d in devices if d["device_type"] == "file_server")
    res_rtrs  = sum(1 for d in devices if d.get("is_residential_router"))
    subnets   = build_subnets(devices)

    payload = {
        "timestamp":          datetime.now().isoformat(),
        "network":            rango,
        "scanner_ip":         local_ip,
        "nombre_cliente":     nombre_cliente,
        "own_nics":           own_nics,
        "total":              len(devices),
        "gigabit":            gigabit,
        "fast_ethernet":      fast,
        "slow_ethernet":      slow,
        "unknown":            unknown,
        "file_servers":       file_srvs,
        "residential_routers": res_rtrs,
        "subnets":            subnets,
        "devices":            devices,
        "vlans":              list(all_vlans.values()),
        "traceroute":         traceroute_data,
    }

    print()
    print("[5/5] Enviando resultados al dashboard...")
    try:
        r = requests.post(f"{API_BASE}/scan", json=payload, timeout=20)
        if r.status_code == 200:
            data  = r.json()
            score = data.get("score", "N/A")
            print(f"  Enviado.  Scan ID: {data.get('scan_id','?')}  |  Puntaje: {score}/100")
            print("  Dashboard: https://opsmonitor.emprendistore.com")
        else:
            print(f"  [!] Error HTTP {r.status_code}: {r.text[:200]}")
    except Exception as e:
        print(f"  [!] Sin conexion: {e}")

    print()
    print("=" * 60)
    print("  RESUMEN")
    print("=" * 60)
    print(f"  Total dispositivos : {len(devices)}")
    print(f"  Gigabit (1 Gbps)   : {gigabit}")
    print(f"  Fast (100 Mbps)    : {fast}")
    print(f"  Slow (10 Mbps)     : {slow}")
    print(f"  Sin SNMP           : {unknown}")
    print(f"  VLANs detectadas   : {len(all_vlans)}")
    if file_srvs:
        print(f"  *** Serv. archivos : {file_srvs}  <-- REVISAR")
    if res_rtrs:
        print(f"  *** Routers resid. : {res_rtrs}  <-- REVISAR")
    print()
    print("  Dispositivos:")
    for dev in sorted(devices, key=lambda d: [int(x) for x in d["ip"].split(".")]):
        icon  = DEVICE_ICONS.get(dev["device_type"], "(?)")
        flags = ""
        if dev.get("has_smb"):
            flags += " [SMB]"
        if dev.get("is_residential_router"):
            flags += " [RESIDENCIAL]"
        print(f"    {icon} {dev['ip']:<16} {dev['speed_label']:<12} {dev['device_label']:<30}{flags}")

    print()
    print("=" * 60)
    resp = input("  El cliente acepta monitoreo automatico cada 30 min? (s/n): ").strip().lower()
    if resp == "s":
        install_monitor(nombre_cliente)
    else:
        print("  Sin monitoreo continuo. El tecnico hace el scanner en cada visita.")

    print()
    input("  Enter para volver al menu...")


# ── Modo: Auditoria de cableado ────────────────────────────────────────────────

def run_audit():
    print()
    print("=" * 60)
    print("  Auditoria de Cableado en Tiempo Real")
    print("=" * 60)
    print()
    print("  Prerequisito: abrir el dashboard y crear una sesion de")
    print("  auditoria para el cliente (seccion Inventario).")
    print()

    session_key = input("  Clave de sesion (desde el dashboard): ").strip()
    if not session_key:
        print("  Clave vacia. Volviendo al menu.")
        input("  Enter para continuar...")
        return

    print("\n  Conectando al servidor...")
    try:
        r = requests.get(f"{API_BASE}/audit/{session_key}/devices", timeout=10)
        if r.status_code == 404:
            print("\n  Error: sesion no encontrada o expirada.")
            print("  Inicia una nueva sesion desde el dashboard.")
            input("\n  Enter para continuar...")
            return
        r.raise_for_status()
        data = r.json()
    except requests.RequestException as e:
        print(f"\n  Error de conexion: {e}")
        input("\n  Enter para continuar...")
        return

    ips = [d["ip"] for d in data["devices"]]
    if not ips:
        print("\n  El cliente no tiene dispositivos registrados en el ultimo scan.")
        input("\n  Enter para continuar...")
        return

    print(f"\n  Cliente : {data['client_nombre']}")
    print(f"  Equipos : {len(ips)}")
    print()
    print("  El dashboard muestra en tiempo real que equipo se fue.")
    print("  Desconecta cables del patch panel de a uno.")
    print()
    print("  Ctrl+C para terminar y volver al menu.")
    print()
    input("  Presiona Enter para iniciar...\n")

    error_count = 0
    try:
        while True:
            t0            = time.time()
            online        = ping_all(ips)
            offline_count = len(ips) - len(online)
            try:
                resp = requests.post(
                    f"{API_BASE}/audit/report?session_key={session_key}",
                    json={"online_ips": online},
                    timeout=5,
                )
                if resp.status_code == 404:
                    print("\n\n  La sesion fue cerrada desde el dashboard.")
                    break
                error_count = 0
            except requests.RequestException:
                error_count += 1
                if error_count >= 5:
                    print("\n\n  Sin conexion al servidor (5 intentos fallidos).")
                    break

            elapsed = time.time() - t0
            ts      = time.strftime("%H:%M:%S")
            line    = f"  {ts}  |  Online: {len(online):>3}   Offline: {offline_count:>3}"
            if offline_count:
                line += "  <-- CABLE DESCONECTADO"
            sys.stdout.write(f"\r{line}   ")
            sys.stdout.flush()

            remaining = max(0.0, 2.0 - elapsed)
            if remaining:
                time.sleep(remaining)
    except KeyboardInterrupt:
        pass

    print("\n\n  Auditoria finalizada.")
    input("  Enter para volver al menu...")


# ── Modo: Test de latencia ──────────────────────────────────────────────────────

def run_latency():
    print()
    print("=" * 60)
    print("  Test de Latencia al Servidor")
    print("=" * 60)
    print()

    target = "opsmonitor.emprendistore.com"
    count  = 20
    print(f"  Destino : {target}")
    print(f"  Paquetes: {count}")
    print()

    times  = []
    sent   = received = 0
    try:
        for i in range(count):
            t_start = time.time()
            ok      = ping_ip(target)
            elapsed = (time.time() - t_start) * 1000
            sent   += 1
            if ok:
                received += 1
                times.append(elapsed)
                status = f"{elapsed:.1f} ms"
            else:
                status = "timeout"
            sys.stdout.write(f"\r  Paquete {i+1:>2}/{count}  {status:<20}")
            sys.stdout.flush()
            if i < count - 1:
                time.sleep(1)
    except KeyboardInterrupt:
        pass

    print()
    lost     = sent - received
    loss_pct = round(lost / sent * 100, 1) if sent else 0
    print()
    print("  RESULTADOS")
    print(f"  Enviados  : {sent}")
    print(f"  Recibidos : {received}")
    print(f"  Perdidos  : {lost} ({loss_pct}%)")
    if times:
        jitter = max(times) - min(times)
        print(f"  Min       : {min(times):.1f} ms")
        print(f"  Promedio  : {sum(times)/len(times):.1f} ms")
        print(f"  Max       : {max(times):.1f} ms")
        print(f"  Jitter    : {jitter:.1f} ms")
    print()
    if loss_pct > 10:
        print("  [!] Perdida de paquetes alta -- posible problema de conectividad")
    elif times and sum(times) / len(times) > 100:
        print("  [!] Latencia alta -- posible conexion lenta o congestion")
    else:
        print("  [OK] Conectividad normal")

    print()
    input("  Enter para volver al menu...")


# ── Menu principal ──────────────────────────────────────────────────────────────

def main_menu():
    while True:
        print()
        print("=" * 60)
        print(f"  OpsMonitor -- Herramienta de Diagnostico de Red v{VERSION}")
        print("=" * 60)
        print()
        print("  1. Scanner de red completo")
        print("  2. Auditoria de cableado en tiempo real")
        print("  3. Test de latencia al servidor")
        print("  4. Salir")
        print()
        opc = input("  Seleccionar opcion (1-4): ").strip()
        if opc == "1":
            run_scanner()
        elif opc == "2":
            run_audit()
        elif opc == "3":
            run_latency()
        elif opc == "4":
            print()
            sys.exit(0)
        else:
            print("  Opcion no valida.")


# ── Entry point ─────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("--heartbeat", action="store_true")
    args, _ = parser.parse_known_args()

    if args.heartbeat:
        heartbeat_mode()
    else:
        main_menu()
