#!/usr/bin/env python3
"""
SYLink PenTest VM — mini UI web port 8080.

Design refresh — style cohérent avec le portail client UniSOC (slate + amber).

Endpoints :
  GET  /                      — page principale (activation si pas activée + gestion réseau)
  GET  /api/network-state     — JSON état réseau live
  POST /api/network-config    — applique config DHCP/static
  POST /api/route             — ajoute route statique
  POST /api/route/delete      — supprime route
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

CONFIG_DIR = Path("/opt/sylink-pentest/etc")
LOG_DIR = Path("/opt/sylink-pentest/log")
NETPLAN_FILE = Path("/etc/netplan/99-sylink-pentest.yaml")
NETPLAN_BACKUP = CONFIG_DIR / "netplan_backup.yaml"
PORT = int(os.environ.get("SETUP_UI_PORT", "8080"))


def load_state() -> dict:
    state = {"activated": False}
    enroll = CONFIG_DIR / "enroll.json"
    license_file = CONFIG_DIR / "license.json"
    if enroll.exists():
        state.update(json.loads(enroll.read_text()))
    if license_file.exists():
        try:
            lic = json.loads(license_file.read_text())
            if lic.get("api_key"):
                state["activated"] = True
                state["tenant_id"] = lic.get("tenant_id")
                state["expires_at"] = lic.get("expires_at")
                state["enabled_services"] = lic.get("enabled_services") or []
        except Exception:
            pass
    return state


def get_resources_state() -> dict:
    """Collecte CPU, RAM, disque, réseau (rx/tx bytes), uptime."""
    out = {}
    # CPU loadavg
    try:
        with open("/proc/loadavg") as f:
            parts = f.read().split()
            out["load_1"] = float(parts[0])
            out["load_5"] = float(parts[1])
            out["load_15"] = float(parts[2])
    except Exception:
        pass
    # CPU count
    try:
        out["cpu_count"] = os.cpu_count() or 1
    except Exception:
        out["cpu_count"] = 1
    # RAM
    try:
        with open("/proc/meminfo") as f:
            mem = {}
            for line in f:
                k, v = line.split(":", 1)
                mem[k.strip()] = int(v.strip().split()[0]) * 1024  # bytes
            total = mem.get("MemTotal", 1)
            avail = mem.get("MemAvailable", 0)
            out["mem_total"] = total
            out["mem_used"] = total - avail
            out["mem_used_pct"] = round(100 * (total - avail) / total, 1)
    except Exception:
        pass
    # Disk root
    try:
        st = os.statvfs("/")
        used = (st.f_blocks - st.f_bavail) * st.f_frsize
        total = st.f_blocks * st.f_frsize
        out["disk_total"] = total
        out["disk_used"] = used
        out["disk_used_pct"] = round(100 * used / total, 1)
    except Exception:
        pass
    # Network rx/tx (sum of all real interfaces)
    try:
        rx_total = 0
        tx_total = 0
        with open("/proc/net/dev") as f:
            for line in f.readlines()[2:]:  # skip headers
                parts = line.split()
                iface = parts[0].rstrip(":")
                if iface in ("lo",) or iface.startswith(("veth", "docker", "br-")):
                    continue
                rx_total += int(parts[1])
                tx_total += int(parts[9])
        out["net_rx_bytes"] = rx_total
        out["net_tx_bytes"] = tx_total
    except Exception:
        pass
    # Uptime
    try:
        with open("/proc/uptime") as f:
            out["uptime_seconds"] = int(float(f.read().split()[0]))
    except Exception:
        pass
    return out


def fmt_bytes(n: int) -> str:
    if n is None:
        return "—"
    units = ["B", "KB", "MB", "GB", "TB"]
    for u in units:
        if n < 1024:
            return f"{n:.1f} {u}" if u != "B" else f"{n} {u}"
        n /= 1024
    return f"{n:.1f} PB"


def fmt_uptime(s: int) -> str:
    if not s:
        return "—"
    days = s // 86400
    hours = (s % 86400) // 3600
    minutes = (s % 3600) // 60
    parts = []
    if days:
        parts.append(f"{days}j")
    if hours:
        parts.append(f"{hours}h")
    if minutes or not parts:
        parts.append(f"{minutes}min")
    return " ".join(parts)


def get_network_state() -> dict:
    out = {"interfaces": [], "default_gateway": None, "dns_servers": [], "mode": "unknown", "routes_static": []}
    try:
        ip_addr = subprocess.run(["ip", "-j", "addr"], capture_output=True, text=True, timeout=5).stdout
        for iface in json.loads(ip_addr):
            name = iface.get("ifname", "")
            if name in ("lo",) or name.startswith(("veth", "docker", "br-", "podman")):
                continue
            addrs = iface.get("addr_info", [])
            ipv4 = next((f"{a['local']}/{a['prefixlen']}" for a in addrs if a.get("family") == "inet"), None)
            out["interfaces"].append({
                "name": name, "mac": iface.get("address"), "ipv4": ipv4,
                "operstate": iface.get("operstate"), "mtu": iface.get("mtu"),
            })
    except Exception:
        pass
    try:
        rt = subprocess.run(["ip", "-j", "route"], capture_output=True, text=True, timeout=5).stdout
        for r in json.loads(rt):
            if r.get("dst") == "default":
                out["default_gateway"] = r.get("gateway")
            elif r.get("dst") and r["dst"] != "default":
                out["routes_static"].append({"cidr": r["dst"], "via": r.get("gateway"), "dev": r.get("dev")})
    except Exception:
        pass
    try:
        with open("/etc/resolv.conf") as f:
            for line in f:
                if line.startswith("nameserver"):
                    out["dns_servers"].append(line.split()[1])
    except Exception:
        pass
    if NETPLAN_FILE.exists():
        try:
            content = NETPLAN_FILE.read_text()
            if "dhcp4: true" in content:
                out["mode"] = "dhcp"
            elif "addresses:" in content:
                out["mode"] = "static"
        except Exception:
            pass
    return out


def primary_interface() -> str:
    try:
        out = subprocess.run(["ip", "-o", "route", "show", "default"], capture_output=True, text=True, timeout=5).stdout
        parts = out.split()
        if "dev" in parts:
            return parts[parts.index("dev") + 1]
    except Exception:
        pass
    return "enp6s18"


def is_rfc1918(cidr_or_ip: str) -> bool:
    import ipaddress
    try:
        net = ipaddress.ip_network(cidr_or_ip, strict=False)
        return net.is_private and not net.is_loopback and not net.is_multicast
    except Exception:
        return False


def apply_netplan_config(cfg: dict) -> tuple[bool, str]:
    iface = primary_interface()
    if cfg.get("mode") == "static":
        if not cfg.get("ipv4_address") or not cfg.get("ipv4_gateway"):
            return False, "Adresse IP et gateway requises en mode statique"
        if not is_rfc1918(cfg["ipv4_address"]) or not is_rfc1918(cfg["ipv4_gateway"]):
            return False, "L'IP et la gateway doivent être en RFC1918 (10/8, 172.16/12, 192.168/16)"
        ns = cfg.get("dns_servers") or ["9.9.9.9", "1.1.1.1"]
        eth_block = f"""      dhcp4: false
      addresses: ["{cfg['ipv4_address']}"]
      routes:
        - to: default
          via: {cfg['ipv4_gateway']}
      nameservers:
        addresses: [{', '.join(ns)}]"""
    else:
        eth_block = "      dhcp4: true"
    yaml_content = f"""network:
  version: 2
  renderer: networkd
  ethernets:
    {iface}:
{eth_block}
"""
    NETPLAN_BACKUP.parent.mkdir(parents=True, exist_ok=True)
    if NETPLAN_FILE.exists():
        shutil.copyfile(NETPLAN_FILE, NETPLAN_BACKUP)
    NETPLAN_FILE.write_text(yaml_content)
    os.chmod(NETPLAN_FILE, 0o600)
    res = subprocess.run(["netplan", "apply"], capture_output=True, text=True, timeout=30)
    if res.returncode != 0:
        if NETPLAN_BACKUP.exists():
            shutil.copyfile(NETPLAN_BACKUP, NETPLAN_FILE)
            subprocess.run(["netplan", "apply"], capture_output=True, timeout=30)
        return False, res.stderr or "netplan apply a échoué"
    return True, "OK"


def add_static_route(cidr: str, via: str) -> tuple[bool, str]:
    if cidr in ("0.0.0.0/0", "::/0"):
        return False, "La default route (0.0.0.0/0) est interdite"
    if not is_rfc1918(cidr) or not is_rfc1918(via):
        return False, "RFC1918 obligatoire"
    res = subprocess.run(["ip", "route", "add", cidr, "via", via], capture_output=True, text=True, timeout=10)
    return res.returncode == 0, res.stderr or "OK"


def del_static_route(cidr: str) -> tuple[bool, str]:
    res = subprocess.run(["ip", "route", "del", cidr], capture_output=True, text=True, timeout=10)
    return res.returncode == 0, res.stderr or "OK"


HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SYLink PenTest — Setup</title>
<style>
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  :root {{
    --bg:        #020617;
    --bg-card:   #0f172a;
    --border:    #1e293b;
    --border-h:  #334155;
    --fg:        #f1f5f9;
    --fg-mute:   #94a3b8;
    --fg-dim:    #64748b;
    --primary:   #f59e0b;
    --primary-h: #fbbf24;
    --primary-d: #b45309;
    --success:   #10b981;
    --warn:      #f59e0b;
    --danger:    #ef4444;
  }}
  html, body {{ background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; min-height: 100%; line-height: 1.5; -webkit-font-smoothing: antialiased; }}
  body {{ padding: 24px; }}
  .container {{ max-width: 1280px; margin: 0 auto; }}

  /* Header */
  header {{ display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }}
  .logo {{ display: flex; align-items: center; gap: 12px; font-size: 18px; font-weight: 700; }}
  .logo-mark {{ width: 32px; height: 32px; border-radius: 8px; background: linear-gradient(135deg, var(--primary), var(--primary-d)); display: flex; align-items: center; justify-content: center; color: #0f172a; font-weight: 900; font-size: 14px; }}
  .logo-text {{ color: var(--fg); }}
  .logo-sub {{ color: var(--fg-mute); font-weight: 400; font-size: 13px; }}
  header .meta {{ font-size: 12px; color: var(--fg-mute); }}

  /* Status banner — full width en haut */
  .banner {{ display: flex; align-items: center; gap: 16px; padding: 18px 24px; border-radius: 14px; margin-bottom: 24px; border: 1px solid var(--border); }}
  .banner.activated {{ background: linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05)); border-color: rgba(16,185,129,0.4); }}
  .banner.pending {{ background: linear-gradient(135deg, rgba(245,158,11,0.15), rgba(245,158,11,0.05)); border-color: rgba(245,158,11,0.4); }}
  .banner-icon {{ width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 24px; }}
  .banner.activated .banner-icon {{ background: var(--success); color: #022c22; }}
  .banner.pending .banner-icon {{ background: var(--primary); color: #422006; }}
  .banner h2 {{ font-size: 18px; font-weight: 700; margin-bottom: 4px; }}
  .banner p {{ color: var(--fg-mute); font-size: 14px; }}
  .banner .actions {{ margin-left: auto; }}

  /* Grid 2 cols */
  .grid-2 {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
  @media (max-width: 980px) {{ .grid-2 {{ grid-template-columns: 1fr; }} }}

  /* Cards */
  .card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 22px; margin-bottom: 16px; }}
  .card-title {{ display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }}
  .card-title h3 {{ font-size: 15px; font-weight: 700; color: var(--fg); display: flex; align-items: center; gap: 10px; }}
  .card-title-icon {{ width: 28px; height: 28px; border-radius: 7px; background: rgba(245,158,11,0.15); color: var(--primary); display: flex; align-items: center; justify-content: center; font-size: 14px; }}
  .section {{ margin-top: 20px; }}
  .section-label {{ font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--fg-mute); margin-bottom: 10px; font-weight: 600; }}

  /* Definition list (key:value) */
  dl {{ font-size: 13px; }}
  dl > div {{ display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); }}
  dl > div:last-child {{ border-bottom: none; }}
  dt {{ color: var(--fg-mute); font-weight: 500; }}
  dd {{ color: var(--fg); font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; text-align: right; word-break: break-all; }}
  dd a {{ color: var(--primary); text-decoration: none; }}
  dd a:hover {{ color: var(--primary-h); text-decoration: underline; }}

  /* Forms */
  form {{ display: block; }}
  .field {{ margin-bottom: 14px; }}
  .field-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }}
  label {{ display: block; font-size: 12px; font-weight: 500; color: var(--fg-mute); margin-bottom: 6px; }}
  input[type=text], input[type=number], select {{
    width: 100%; padding: 10px 12px; border: 1px solid var(--border-h); border-radius: 8px;
    background: var(--bg); color: var(--fg); font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px;
    transition: border-color 0.15s, box-shadow 0.15s;
  }}
  input:focus, select:focus {{ outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(245,158,11,0.15); }}
  button {{
    background: var(--primary); color: #0f172a; padding: 10px 20px; border: none; border-radius: 8px;
    cursor: pointer; font-weight: 600; font-size: 13px; transition: background 0.15s; font-family: inherit;
  }}
  button:hover {{ background: var(--primary-h); }}
  button.danger {{ background: var(--danger); color: #fff; }}
  button.danger:hover {{ background: #dc2626; }}
  button.secondary {{ background: var(--border-h); color: var(--fg); }}
  button.secondary:hover {{ background: var(--fg-dim); }}
  button.small {{ padding: 4px 10px; font-size: 11px; }}

  /* Badges */
  .badge {{ display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
  .badge-dhcp {{ background: rgba(59,130,246,0.15); color: #93c5fd; border: 1px solid rgba(59,130,246,0.3); }}
  .badge-static {{ background: rgba(245,158,11,0.15); color: #fcd34d; border: 1px solid rgba(245,158,11,0.3); }}
  .badge-success {{ background: rgba(16,185,129,0.15); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.3); }}
  .badge-state-up {{ background: rgba(16,185,129,0.15); color: #6ee7b7; }}

  /* Activation block */
  .activation-grid {{ display: grid; grid-template-columns: auto 1fr; gap: 24px; align-items: start; }}
  @media (max-width: 700px) {{ .activation-grid {{ grid-template-columns: 1fr; }} }}
  .qr-container {{ background: white; padding: 12px; border-radius: 10px; }}
  .qr-container pre {{ font-size: 6px; line-height: 0.7; color: black; font-family: ui-monospace, monospace; }}
  .activation-options {{ font-size: 13px; }}
  .activation-step {{ margin-bottom: 16px; }}
  .activation-step-num {{ display: inline-flex; width: 22px; height: 22px; border-radius: 50%; background: rgba(245,158,11,0.15); color: var(--primary); align-items: center; justify-content: center; font-weight: 700; font-size: 11px; margin-right: 8px; }}
  .activation-code {{ font-family: ui-monospace, monospace; font-size: 22px; font-weight: 700; color: var(--primary); letter-spacing: 5px; padding: 12px 18px; background: var(--bg); border: 1px solid var(--border-h); border-radius: 8px; display: inline-block; margin-top: 4px; }}
  .activation-url {{ display: block; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border-h); border-radius: 6px; font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; color: var(--fg-mute); margin-top: 4px; }}

  /* Alerts */
  .alert {{ padding: 12px 16px; border-radius: 8px; font-size: 12px; margin: 14px 0; line-height: 1.5; }}
  .alert-warn {{ background: rgba(245,158,11,0.1); color: #fcd34d; border: 1px solid rgba(245,158,11,0.3); }}
  .alert-ok {{ background: rgba(16,185,129,0.1); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.3); }}
  .alert-err {{ background: rgba(239,68,68,0.1); color: #fca5a5; border: 1px solid rgba(239,68,68,0.3); }}
  .alert-info {{ background: rgba(59,130,246,0.1); color: #93c5fd; border: 1px solid rgba(59,130,246,0.3); }}

  /* Misc */
  code {{ background: var(--bg); padding: 2px 6px; border-radius: 3px; font-size: 11px; font-family: ui-monospace, monospace; color: var(--fg); }}
  .empty-state {{ color: var(--fg-dim); font-style: italic; padding: 12px 0; text-align: center; }}
  .text-mute {{ color: var(--fg-mute); }}
  .footer {{ text-align: center; margin-top: 32px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--fg-dim); font-size: 12px; }}
  .footer a {{ color: var(--primary); text-decoration: none; }}
  .footer a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">

  <!-- HEADER -->
  <header>
    <div class="logo">
      <div class="logo-mark">SY</div>
      <div>
        <div class="logo-text">SYLink PenTest</div>
        <div class="logo-sub">VM bot pentest interne</div>
      </div>
    </div>
    <div class="meta">{hostname}</div>
  </header>

  <!-- BANNIÈRE STATUS -->
  {status_banner}

  <!-- LAYOUT 2 COLONNES -->
  <div class="grid-2">

    <!-- COLONNE GAUCHE -->
    <div>
      {activation_card}
      {specs_card}
    </div>

    <!-- COLONNE DROITE -->
    <div>
      {network_card}
      {routes_card}
    </div>

  </div>

  <div id="result" class="container" style="margin-top:8px;"></div>

  <div class="footer">
    Documentation : <a href="{portal_base}/docs/pentest-vm-setup">guide d'installation</a>
    &nbsp;·&nbsp;
    Logs locaux : <code>journalctl -u sylink-pentest-agent</code>
    &nbsp;·&nbsp;
    Support : <a href="mailto:contact@unisoc.fr">contact@unisoc.fr</a>
  </div>

</div>

<script>
  function toggleStaticFields() {{
    var mode = document.getElementById('mode').value;
    document.getElementById('static-fields').style.display = mode === 'static' ? 'block' : 'none';
  }}
  function showResult(html) {{ document.getElementById('result').innerHTML = html; document.getElementById('result').scrollIntoView({{behavior:'smooth', block:'nearest'}}); }}
  async function applyNetwork(ev) {{
    ev.preventDefault();
    var f = ev.target;
    var body = {{ mode: f.mode.value }};
    if (f.mode.value === 'static') {{
      body.ipv4_address = f.ipv4_address.value;
      body.ipv4_gateway = f.ipv4_gateway.value;
      body.dns_servers = f.dns.value.split(',').map(s => s.trim()).filter(Boolean);
    }}
    showResult('<div class="alert alert-warn">Application en cours…</div>');
    try {{
      var r = await fetch('/api/network-config', {{ method: 'POST', body: JSON.stringify(body), headers: {{'Content-Type': 'application/json'}} }});
      var data = await r.json();
      if (data.success) {{
        showResult('<div class="alert alert-ok">Configuration appliquée. Rechargement…</div>');
        setTimeout(() => location.reload(), 2000);
      }} else {{
        showResult('<div class="alert alert-err">Échec : ' + (data.error || 'erreur inconnue') + '</div>');
      }}
    }} catch(e) {{ showResult('<div class="alert alert-err">' + e.message + '</div>'); }}
  }}
  async function addRoute(ev) {{
    ev.preventDefault();
    var cidr = document.getElementById('route_cidr').value;
    var via = document.getElementById('route_via').value;
    showResult('<div class="alert alert-warn">Ajout en cours…</div>');
    try {{
      var r = await fetch('/api/route', {{ method: 'POST', body: JSON.stringify({{cidr, via}}), headers: {{'Content-Type': 'application/json'}} }});
      var data = await r.json();
      if (data.success) location.reload();
      else showResult('<div class="alert alert-err">Échec : ' + (data.error || 'erreur') + '</div>');
    }} catch(e) {{ showResult('<div class="alert alert-err">' + e.message + '</div>'); }}
  }}
  async function delRoute(cidr) {{
    if (!confirm('Supprimer la route ' + cidr + ' ?')) return;
    try {{
      var r = await fetch('/api/route/delete', {{ method: 'POST', body: JSON.stringify({{cidr}}), headers: {{'Content-Type': 'application/json'}} }});
      var data = await r.json();
      if (data.success) location.reload();
      else showResult('<div class="alert alert-err">' + (data.error || 'Échec') + '</div>');
    }} catch(e) {{ showResult('<div class="alert alert-err">' + e.message + '</div>'); }}
  }}
</script>
</body>
</html>
"""


def render_status_banner(state: dict) -> str:
    if state.get("activated"):
        services = ', '.join(state.get('enabled_services') or []) or '—'
        exp = state.get('expires_at') or '—'
        if exp != '—':
            try:
                exp = exp[:10]  # YYYY-MM-DD only
            except Exception:
                pass
        return f"""<div class="banner activated">
  <div class="banner-icon">✓</div>
  <div>
    <h2>VM activée</h2>
    <p>Tenant <strong style="color:var(--fg)">{state.get('tenant_id', '?')}</strong>
       · Services : <span class="text-mute">{services}</span>
       · Expire le <span class="text-mute">{exp}</span></p>
  </div>
  <div class="actions">
    <span class="badge badge-success">EN LIGNE</span>
  </div>
</div>"""
    return """<div class="banner pending">
  <div class="banner-icon">⏳</div>
  <div>
    <h2>VM en attente d'activation</h2>
    <p>Activez cette VM dans votre espace client UniSOC pour qu'elle soit pilotable par votre SOC.</p>
  </div>
</div>"""


def render_activation_card(state: dict) -> str:
    """Bloc d'activation — affiché UNIQUEMENT si pas activée."""
    if state.get("activated"):
        return ""
    try:
        qr_text = ""
        content = Path("/etc/issue").read_text() if Path("/etc/issue").exists() else ""
        qr_text = content[: content.find("Code d'activation")] if "Code d'activation" in content else ""
        qr_text = "\n".join(l for l in qr_text.splitlines() if l.strip())
    except Exception:
        qr_text = "(QR indisponible — voir logs init)"
    return f"""<div class="card">
  <div class="card-title">
    <h3><div class="card-title-icon">📱</div>Activation</h3>
  </div>

  <div class="activation-grid">
    <div class="qr-container"><pre>{qr_text}</pre></div>
    <div class="activation-options">

      <div class="activation-step">
        <span class="activation-step-num">1</span><strong>Scanner le QR</strong> avec votre téléphone connecté au portail UniSOC (recommandé)
      </div>

      <div class="activation-step">
        <span class="activation-step-num">2</span><strong>Code d'activation manuel</strong>
        <div class="activation-code">{state.get('short_code', '—')}</div>
      </div>

      <div class="activation-step">
        <span class="activation-step-num">3</span><strong>URL directe</strong>
        <a class="activation-url" href="{state.get('qr_url', '#')}">{state.get('qr_url', '—')}</a>
      </div>

    </div>
  </div>
</div>"""


def render_resources_card(res: dict) -> str:
    """Card monitoring système — CPU/RAM/disque/réseau/uptime."""
    def progress_bar(pct, color="primary"):
        if pct is None:
            return "<span class=text-mute>—</span>"
        color_map = {"primary": "var(--primary)", "danger": "var(--danger)", "success": "var(--success)"}
        c = color_map.get(color, color_map["primary"])
        if pct >= 90: c = color_map["danger"]
        elif pct >= 75: c = "#f59e0b"
        return f"""<div style="display:flex;align-items:center;gap:10px;">
          <div style="flex:1;background:var(--bg);border-radius:999px;height:6px;overflow:hidden;border:1px solid var(--border);">
            <div style="width:{pct}%;background:{c};height:100%;transition:width 0.3s;"></div>
          </div>
          <span style="font-family:ui-monospace,monospace;font-size:11px;color:var(--fg);min-width:42px;text-align:right;">{pct}%</span>
        </div>"""

    cpu_load = res.get("load_1", 0)
    cpu_count = res.get("cpu_count", 1)
    cpu_pct = min(round(100 * cpu_load / cpu_count, 1), 100) if cpu_count else 0

    return f"""<div class="card">
  <div class="card-title">
    <h3><div class="card-title-icon">📊</div>Gestion des ressources</h3>
    <span class="badge badge-success">LIVE</span>
  </div>

  <dl style="font-size:12px;">
    <div><dt>CPU charge (1 min)</dt><dd style="min-width:160px;">{progress_bar(cpu_pct)}</dd></div>
    <div><dt>Load avg</dt><dd>{res.get('load_1', '?'):.2f} / {res.get('load_5', '?'):.2f} / {res.get('load_15', '?'):.2f}</dd></div>
    <div><dt>RAM</dt><dd style="min-width:160px;">{progress_bar(res.get('mem_used_pct'))}</dd></div>
    <div><dt>RAM utilisée / totale</dt><dd>{fmt_bytes(res.get('mem_used'))} / {fmt_bytes(res.get('mem_total'))}</dd></div>
    <div><dt>Disque /</dt><dd style="min-width:160px;">{progress_bar(res.get('disk_used_pct'))}</dd></div>
    <div><dt>Disque utilisé / total</dt><dd>{fmt_bytes(res.get('disk_used'))} / {fmt_bytes(res.get('disk_total'))}</dd></div>
    <div><dt>Réseau reçu (cumul)</dt><dd>{fmt_bytes(res.get('net_rx_bytes'))} ↓</dd></div>
    <div><dt>Réseau envoyé (cumul)</dt><dd>{fmt_bytes(res.get('net_tx_bytes'))} ↑</dd></div>
    <div><dt>Uptime</dt><dd>{fmt_uptime(res.get('uptime_seconds'))}</dd></div>
  </dl>
  <p class="text-mute" style="font-size:11px;margin-top:14px;">Rafraîchi à chaque chargement de page. Les compteurs réseau sont cumulés depuis le boot.</p>
</div>"""


def render_specs_card(state: dict) -> str:
    return f"""<div class="card">
  <div class="card-title">
    <h3><div class="card-title-icon">⚙</div>Spécifications</h3>
  </div>
  <dl>
    <div><dt>Hostname</dt><dd>{os.uname().nodename}</dd></div>
    <div><dt>Fingerprint</dt><dd>{(state.get('machine_fingerprint') or '—')[:24]}…</dd></div>
    <div><dt>API</dt><dd>{state.get('pt_api_base', 'https://pentest.unisoc.fr')}</dd></div>
    <div><dt>Portail SOC</dt><dd><a href="{state.get('portal_base', 'https://client.unisoc.fr')}">{state.get('portal_base', 'https://client.unisoc.fr')}</a></dd></div>
  </dl>
</div>"""


def render_network_card(net: dict) -> str:
    iface_rows = ""
    current_ipv4 = ""
    for i in net["interfaces"]:
        operstate_badge = '<span class="badge badge-state-up">UP</span>' if i.get("operstate") == "UP" else f'<span class="badge">{i.get("operstate","?")}</span>'
        iface_rows += f'<div><dt>{i["name"]}</dt><dd>{i.get("ipv4") or "—"} {operstate_badge}</dd></div>'
        if i.get("ipv4") and not current_ipv4:
            current_ipv4 = i["ipv4"]
    mode_badge = '<span class="badge badge-dhcp">DHCP</span>' if net["mode"] == "dhcp" else (
        '<span class="badge badge-static">IP FIXE</span>' if net["mode"] == "static" else '<span class="badge">?</span>'
    )
    dhcp_selected = "selected" if net["mode"] != "static" else ""
    static_selected = "selected" if net["mode"] == "static" else ""
    static_display = "block" if net["mode"] == "static" else "none"
    gateway = net["default_gateway"] or "—"
    dns_str = ", ".join(net["dns_servers"]) or "—"
    current_gw = net["default_gateway"] or ""
    current_dns = ", ".join(net["dns_servers"])
    return f"""<div class="card">
  <div class="card-title">
    <h3><div class="card-title-icon">🌐</div>Gestion réseau</h3>
    {mode_badge}
  </div>

  <div class="section">
    <div class="section-label">État actuel</div>
    <dl>
      {iface_rows}
      <div><dt>Gateway</dt><dd>{gateway}</dd></div>
      <div><dt>DNS</dt><dd>{dns_str}</dd></div>
    </dl>
  </div>

  <div class="section">
    <div class="section-label">Modifier la configuration</div>
    <form onsubmit="applyNetwork(event)">
      <div class="field">
        <label for="mode">Mode</label>
        <select id="mode" name="mode" onchange="toggleStaticFields()">
          <option value="dhcp" {dhcp_selected}>DHCP (automatique)</option>
          <option value="static" {static_selected}>IP fixe (statique)</option>
        </select>
      </div>

      <div id="static-fields" style="display: {static_display};">
        <div class="field">
          <label for="ipv4_address">Adresse IP / CIDR</label>
          <input id="ipv4_address" type="text" name="ipv4_address" placeholder="192.168.1.42/24" value="{current_ipv4}">
        </div>
        <div class="field">
          <label for="ipv4_gateway">Gateway</label>
          <input id="ipv4_gateway" type="text" name="ipv4_gateway" placeholder="192.168.1.1" value="{current_gw}">
        </div>
        <div class="field">
          <label for="dns">DNS (séparés par virgule)</label>
          <input id="dns" type="text" name="dns" placeholder="9.9.9.9, 1.1.1.1" value="{current_dns}">
        </div>
      </div>

      <div class="alert alert-info">
        En cas de perte de connectivité &gt; 5 min après reconfig, rollback automatique vers la config précédente.
      </div>

      <button type="submit">Appliquer la configuration</button>
    </form>
  </div>
</div>"""


def render_routes_card(net: dict) -> str:
    if net["routes_static"]:
        routes_rows = ""
        for r in net["routes_static"]:
            routes_rows += f'<div><dt><code>{r["cidr"]}</code> via <code>{r["via"] or "—"}</code></dt><dd><button class="danger small" onclick="delRoute(\'{r["cidr"]}\')">Supprimer</button></dd></div>'
    else:
        routes_rows = '<div class="empty-state">Aucune route additionnelle</div>'
    return f"""<div class="card">
  <div class="card-title">
    <h3><div class="card-title-icon">🛣</div>Routes statiques</h3>
  </div>
  <p class="text-mute" style="font-size:12px;margin-bottom:16px;">
    Pour scanner d'autres réseaux internes (VLAN, sites distants via VPN). RFC1918 obligatoire ; <code>0.0.0.0/0</code> interdit.
  </p>

  <div class="section">
    <div class="section-label">Routes actives</div>
    <dl>{routes_rows}</dl>
  </div>

  <div class="section">
    <div class="section-label">Ajouter une route</div>
    <form onsubmit="addRoute(event)">
      <div class="field-row">
        <div class="field">
          <label for="route_cidr">Destination CIDR</label>
          <input id="route_cidr" type="text" placeholder="192.168.50.0/24" required>
        </div>
        <div class="field">
          <label for="route_via">Via gateway</label>
          <input id="route_via" type="text" placeholder="192.168.1.254" required>
        </div>
      </div>
      <button type="submit" class="secondary">Ajouter la route</button>
    </form>
  </div>
</div>"""


def render_page() -> str:
    state = load_state()
    net = get_network_state()
    res = get_resources_state()
    return HTML_TEMPLATE.format(
        hostname=os.uname().nodename,
        portal_base=state.get("portal_base", "https://client.unisoc.fr"),
        status_banner=render_status_banner(state),
        activation_card=render_activation_card(state),
        specs_card=render_specs_card(state) + render_resources_card(res),
        network_card=render_network_card(net),
        routes_card=render_routes_card(net),
    )


class Handler(BaseHTTPRequestHandler):
    def _json(self, code, data):
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_body(self):
        length = int(self.headers.get("Content-Length", "0"))
        return json.loads(self.rfile.read(length)) if length else {}

    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/":
            page = render_page()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(page.encode())
        elif path == "/api/network-state":
            self._json(200, get_network_state())
        else:
            self.send_response(404); self.end_headers()

    def do_POST(self):
        path = urlparse(self.path).path
        try:
            data = self._read_body()
        except Exception as e:
            self._json(400, {"success": False, "error": f"JSON invalide: {e}"}); return
        if path == "/api/network-config":
            ok, msg = apply_netplan_config(data)
            self._json(200 if ok else 400, {"success": ok, "error": None if ok else msg})
        elif path == "/api/route":
            ok, msg = add_static_route(data.get("cidr", ""), data.get("via", ""))
            self._json(200 if ok else 400, {"success": ok, "error": None if ok else msg})
        elif path == "/api/route/delete":
            ok, msg = del_static_route(data.get("cidr", ""))
            self._json(200 if ok else 400, {"success": ok, "error": None if ok else msg})
        else:
            self.send_response(404); self.end_headers()

    def log_message(self, format, *args):
        pass


def main():
    server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    print(f"sylink-pentest-setup-ui listening on 0.0.0.0:{PORT}")
    server.serve_forever()


if __name__ == "__main__":
    main()
