#!/usr/bin/env node
// WiFi provisioning captive portal — lightweight HTTP server.
//
// Serves the setup page, handles WiFi scan results, and processes
// credential submissions. Communicates with wifi-provision.sh via
// a named pipe (FIFO) for connect requests and a file for results.
//
// Captive portal detection:
//   iOS:     GET /hotspot-detect.html → 302 to portal
//   Android: GET /generate_204       → 302 to portal
//   Other:   All DNS resolves to AP_IP via dnsmasq, any HTTP request
//            on port 80 serves the portal page.
//
// Usage: node server.js --port 80 --ap-ip 192.168.4.1 \
//          --scan-cache /tmp/wifi-provision-scan.json \
//          --connect-fifo /tmp/wifi-provision-connect \
//          --result-file /tmp/wifi-provision-result \
//          --brand-json /path/to/brand.json \
//          --hostname maxy

"use strict";

const http = require("http");
const fs = require("fs");
const path = require("path");

// ── Parse CLI args ──────────────────────────────────────────────────
const args = {};
for (let i = 2; i < process.argv.length; i += 2) {
  const key = process.argv[i].replace(/^--/, "");
  args[key] = process.argv[i + 1];
}

const PORT = parseInt(args.port || "80", 10);
const AP_IP = args["ap-ip"] || "192.168.4.1";
const SCAN_CACHE = args["scan-cache"] || "/tmp/wifi-provision-scan.json";
const CONNECT_FIFO = args["connect-fifo"] || "/tmp/wifi-provision-connect";
const RESULT_FILE = args["result-file"] || "/tmp/wifi-provision-result";
const BRAND_JSON = args["brand-json"] || "";
const HOSTNAME = args.hostname || "maxy";
const PORTAL_HOST = args["portal-host"] || "";
// Brand service port (e.g. 19200). Distinct from PORT, which is the
// captive portal's own listen port (always 80). Used only to render the
// post-connect URL.
const DEVICE_PORT = parseInt(args["device-port"] || "19200", 10);

// ── Load brand config ─────────────────────────────────────────────────
// All visual surfaces (colour, font, logo) come from brand.json so the
// captive portal matches the rest of the product. Earlier this file
// hard-coded a generic dark navy + system font fallback that bore no
// relation to the brand's actual look — the operator-side complaint
// that surfaced this. Now: read the brand's `defaultColors`,
// `defaultFonts`, and `assets.logo`, fall through to neutral defaults
// only if a field is missing from brand.json.
let brandName = "Maxy";
const colors = {
  primary: "#1a1a2e",
  primaryHover: "#0f0f1f",
  background: "#FAFAF8",
  text: "#1a1a2e",
  card: "rgba(0,0,0,0.04)",
  cardBorder: "rgba(0,0,0,0.08)",
  muted: "#6b6b6b",
};
const fonts = {
  display: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
  body: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
};
let logoBase64 = "";

try {
  if (BRAND_JSON && fs.existsSync(BRAND_JSON)) {
    const brand = JSON.parse(fs.readFileSync(BRAND_JSON, "utf-8"));
    if (brand.productName) brandName = brand.productName;

    if (brand.defaultColors) {
      const c = brand.defaultColors;
      if (c.primary) colors.primary = c.primary;
      if (c.primaryHover) colors.primaryHover = c.primaryHover;
      if (c.background) colors.background = c.background;
      if (c.agentBubble) colors.card = c.agentBubble;
    }
    if (brand.defaultFonts) {
      if (brand.defaultFonts.display) fonts.display = brand.defaultFonts.display;
      if (brand.defaultFonts.body) fonts.body = brand.defaultFonts.body;
    }

    // Load logo. Prefer assets.logo (the larger brand mark) and fall
    // back to assets.icon.
    const brandDir = path.dirname(BRAND_JSON);
    const logoFile = (brand.assets && (brand.assets.logo || brand.assets.icon)) || "";
    if (logoFile) {
      const logoPath = path.join(brandDir, "assets", logoFile);
      if (fs.existsSync(logoPath)) {
        const logoData = fs.readFileSync(logoPath);
        const ext = path.extname(logoPath).slice(1);
        const mime = ext === "png" ? "image/png" : ext === "ico" ? "image/x-icon" : "image/" + ext;
        logoBase64 = `data:${mime};base64,${logoData.toString("base64")}`;
      }
    }
  }
} catch (err) {
  console.error(`[wifi-provision-server] brand load error: ${err.message}`);
}

// ── State ───────────────────────────────────────────────────────────
let connecting = false;

// ── HTML helpers ────────────────────────────────────────────────────
function escapeHTML(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;");
}

// ── HTML template ───────────────────────────────────────────────────
function portalHTML() {
  const escapedBrandName = escapeHTML(brandName);
  const logoTag = logoBase64
    ? `<img src="${logoBase64}" alt="${escapedBrandName}" class="logo">`
    : `<div class="logo-text">${escapedBrandName}</div>`;

  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>${escapedBrandName} Setup</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: ${fonts.body};
    background: ${colors.background};
    color: ${colors.text};
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 24px 16px;
  }
  .logo { width: 56px; height: 56px; margin-bottom: 12px; border-radius: 12px; }
  .logo-text {
    font-family: ${fonts.display};
    font-size: 28px; font-weight: 600; margin-bottom: 8px;
    color: ${colors.primary};
  }
  h1 {
    font-family: ${fonts.display};
    font-size: 22px; font-weight: 500; margin-bottom: 4px;
  }
  .subtitle { font-size: 14px; color: ${colors.muted}; margin-bottom: 24px; }
  .card {
    background: ${colors.card};
    border: 1px solid ${colors.cardBorder};
    border-radius: 12px;
    width: 100%; max-width: 360px;
    padding: 16px;
    margin-bottom: 16px;
  }
  .network-list { list-style: none; }
  .network-item {
    display: flex; align-items: center; justify-content: space-between;
    padding: 12px;
    border-radius: 8px;
    cursor: pointer;
    transition: background 0.15s;
  }
  .network-item:hover, .network-item.selected {
    background: ${colors.cardBorder};
  }
  .network-item.selected { border: 1px solid ${colors.primary}; }
  .network-name { font-size: 15px; font-weight: 500; }
  .network-meta { font-size: 12px; color: ${colors.muted}; }
  .signal-bars { display: flex; gap: 2px; align-items: flex-end; height: 16px; }
  .signal-bar {
    width: 4px; border-radius: 1px;
    background: ${colors.cardBorder};
  }
  .signal-bar.active { background: ${colors.primary}; }
  .password-group {
    position: relative;
    margin-top: 16px;
  }
  .password-group input {
    width: 100%;
    padding: 12px 44px 12px 12px;
    background: ${colors.background};
    border: 1px solid ${colors.cardBorder};
    border-radius: 8px;
    color: ${colors.text};
    font-size: 15px;
    outline: none;
  }
  .password-group input:focus { border-color: ${colors.primary}; }
  .toggle-pw {
    position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
    background: none; border: none; color: ${colors.muted}; cursor: pointer;
    font-size: 13px; padding: 4px 8px;
  }
  .btn {
    width: 100%; padding: 14px;
    background: ${colors.primary};
    color: #fff; font-size: 16px; font-weight: 600;
    border: none; border-radius: 8px;
    cursor: pointer; margin-top: 16px;
    transition: background 0.15s;
    min-height: 48px;
  }
  .btn:hover:not(:disabled) { background: ${colors.primaryHover}; }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }
  .error {
    color: #c0392b; font-size: 13px;
    margin-top: 8px; display: none;
  }
  .empty { text-align: center; padding: 24px; color: ${colors.muted}; font-size: 14px; }

  /* Progress / success screens */
  .screen { display: none; width: 100%; max-width: 360px; text-align: center; }
  .screen.active { display: block; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .spinner {
    width: 40px; height: 40px;
    border: 3px solid ${colors.cardBorder};
    border-top-color: ${colors.primary};
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 24px auto;
  }
  .success-icon {
    width: 48px; height: 48px; margin: 24px auto;
    border-radius: 50%; background: ${colors.primary};
    display: flex; align-items: center; justify-content: center;
    font-size: 24px; color: #fff;
  }
  .address-box {
    background: ${colors.card};
    border: 1px solid ${colors.cardBorder};
    border-radius: 8px;
    padding: 12px 16px;
    margin: 16px auto;
    display: inline-block;
    max-width: 100%;
    word-break: break-all;
  }
  .address-box a {
    color: ${colors.primary};
    text-decoration: underline;
    font-size: 16px;
    font-weight: 600;
    display: block;
    -webkit-tap-highlight-color: ${colors.primary};
  }
  .hint { color: #888; font-size: 13px; margin-top: 12px; line-height: 1.5; }
</style>
</head>
<body>
  ${logoTag}
  <h1>Welcome to ${escapedBrandName}</h1>
  <p class="subtitle">Connect your device to WiFi</p>

  <!-- Screen: network selection -->
  <div id="select-screen" class="screen active">
    <div class="card">
      <ul class="network-list" id="network-list">
        <li class="empty">Scanning for networks...</li>
      </ul>
    </div>
    <div class="password-group" id="pw-group" style="display:none">
      <input type="password" id="password" autocomplete="off" autocapitalize="off"
             placeholder="WiFi password">
      <button class="toggle-pw" type="button" id="toggle-pw-btn">Show</button>
    </div>
    <div class="error" id="error-msg"></div>
    <button class="btn" id="connect-btn" disabled>Connect</button>
  </div>

  <!-- Screen: connecting -->
  <div id="progress-screen" class="screen">
    <div class="card" style="padding:32px 16px">
      <div class="spinner"></div>
      <h1 id="progress-title">Connecting...</h1>
      <p class="hint" id="progress-hint">Do not close this page.<br>Your phone will reconnect to your WiFi automatically.</p>
    </div>
  </div>

  <!-- Screen: success -->
  <div id="success-screen" class="screen">
    <div class="card" style="padding:32px 16px">
      <div class="success-icon">&#10003;</div>
      <h1>Connected!</h1>
      <p class="hint">${escapedBrandName} is now online. Redirecting in <span id="redirect-countdown">15</span>s…</p>
      <a id="open-btn" href="#" class="btn" style="text-decoration:none;display:flex;align-items:center;justify-content:center">Open ${escapedBrandName} →</a>
      <p class="hint" style="margin-top:16px">Or copy this URL into your browser:</p>
      <div class="address-box" style="user-select:all" id="manual-box">
        <span id="device-link-text"></span>
      </div>
      <p class="hint">This access point will close shortly.<br>Your phone will reconnect to your WiFi automatically.</p>
    </div>
  </div>

<script>
(function() {
  "use strict";
  var selectedSSID = "";
  // The brand service port (19200, configurable via --port), NOT the
  // captive portal port (always 80). devicePort is appended to the
  // post-connect URL so the link points at the admin server.
  var devicePort = ${DEVICE_PORT === 80 ? '""' : JSON.stringify(":" + DEVICE_PORT)};
  var deviceHostname = ${JSON.stringify(HOSTNAME)};

  // Safe text insertion — no innerHTML for user-controlled content
  function setText(el, text) { el.textContent = text; }

  function escapeAttr(s) {
    return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
            .replace(/'/g, "&#x27;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  }

  function signalBarsHTML(signal) {
    var level = signal > 70 ? 4 : signal > 50 ? 3 : signal > 30 ? 2 : 1;
    var html = "";
    var heights = [6, 9, 12, 16];
    for (var i = 0; i < 4; i++) {
      html += '<div class="signal-bar' + (i < level ? ' active' : '') +
              '" style="height:' + heights[i] + 'px"></div>';
    }
    return html;
  }

  function renderNetworks(networks) {
    var list = document.getElementById("network-list");
    // Clear existing content safely
    while (list.firstChild) list.removeChild(list.firstChild);

    if (!networks || networks.length === 0) {
      var empty = document.createElement("li");
      empty.className = "empty";
      empty.textContent = "No WiFi networks found. Make sure your router is turned on.";
      list.appendChild(empty);
      return;
    }
    // Deduplicate by SSID (keep strongest signal)
    var seen = {};
    var unique = [];
    networks.sort(function(a, b) { return b.signal - a.signal; });
    for (var i = 0; i < networks.length; i++) {
      if (!seen[networks[i].ssid]) {
        seen[networks[i].ssid] = true;
        unique.push(networks[i]);
      }
    }
    var max = Math.min(unique.length, 20);
    for (var j = 0; j < max; j++) {
      var n = unique[j];
      var li = document.createElement("li");
      li.className = "network-item";
      li.setAttribute("data-ssid", n.ssid);

      var info = document.createElement("div");
      var nameDiv = document.createElement("div");
      nameDiv.className = "network-name";
      nameDiv.textContent = n.ssid;
      var metaDiv = document.createElement("div");
      metaDiv.className = "network-meta";
      metaDiv.textContent = n.security || "Open";
      info.appendChild(nameDiv);
      info.appendChild(metaDiv);

      var bars = document.createElement("div");
      bars.className = "signal-bars";
      // Signal bars are static markup — no user content
      bars.innerHTML = signalBarsHTML(n.signal);

      li.appendChild(info);
      li.appendChild(bars);
      li.addEventListener("click", (function(ssid) {
        return function() { selectNetwork(this, ssid); };
      })(n.ssid));
      list.appendChild(li);
    }
  }

  function selectNetwork(el, ssid) {
    var items = document.querySelectorAll(".network-item");
    for (var i = 0; i < items.length; i++) items[i].classList.remove("selected");
    el.classList.add("selected");
    selectedSSID = ssid;
    document.getElementById("pw-group").style.display = "block";
    document.getElementById("password").focus();
    document.getElementById("connect-btn").disabled = false;
    document.getElementById("error-msg").style.display = "none";
  }

  document.getElementById("toggle-pw-btn").addEventListener("click", function() {
    var inp = document.getElementById("password");
    if (inp.type === "password") { inp.type = "text"; this.textContent = "Hide"; }
    else { inp.type = "password"; this.textContent = "Show"; }
  });

  function showScreen(id) {
    var screens = document.querySelectorAll(".screen");
    for (var i = 0; i < screens.length; i++) screens[i].classList.remove("active");
    document.getElementById(id).classList.add("active");
  }

  function showError(msg) {
    var el = document.getElementById("error-msg");
    el.textContent = msg;
    el.style.display = "block";
  }

  function showSuccess(data) {
    var hostnameAddr = data.hostname
      ? "http://" + data.hostname + ".local" + devicePort
      : null;
    var ipAddr = data.ip ? "http://" + data.ip + devicePort : null;
    // Auto-navigate target: prefer the IP because mDNS .local resolution
    // is flaky on Android (notably Brave) and the redirect dead-ends.
    var redirectAddr = ipAddr || hostnameAddr;
    var openBtn = document.getElementById("open-btn");
    if (openBtn) openBtn.href = redirectAddr;
    var manualText = document.getElementById("device-link-text");
    if (manualText) manualText.textContent = redirectAddr;
    showScreen("success-screen");
    // 15s countdown: the Pi keeps the AP up for ~10s after writing the
    // success result so this poll can land, then tears it down; the
    // phone then needs ~5s to drop the AP SSID and rejoin home WiFi.
    var remaining = 15;
    var countdownEl = document.getElementById("redirect-countdown");
    var ticker = setInterval(function() {
      remaining -= 1;
      if (countdownEl) countdownEl.textContent = String(remaining);
      if (remaining <= 0) {
        clearInterval(ticker);
        // Try multiple navigation primitives — captive webviews on some
        // platforms ignore one but accept another.
        try { window.location.replace(redirectAddr); } catch(e) {}
        try { window.location.href = redirectAddr; } catch(e) {}
        // Last-resort: synthesise a click on a freshly-built anchor.
        try {
          var a = document.createElement("a");
          a.href = redirectAddr; a.rel = "noopener";
          document.body.appendChild(a); a.click();
        } catch(e) {}
      }
    }, 1000);
  }

  function pollResult() {
    var attempts = 0;
    var poll = setInterval(function() {
      attempts++;
      if (attempts > 30) {
        clearInterval(poll);
        document.getElementById("progress-hint").textContent =
          "Taking longer than expected. Try opening http://" +
          deviceHostname + ".local" + devicePort + " in your browser.";
        return;
      }
      var xhr = new XMLHttpRequest();
      xhr.open("GET", "/result", true);
      xhr.onload = function() {
        if (xhr.status === 200) {
          clearInterval(poll);
          try {
            var data = JSON.parse(xhr.responseText);
            if (data.success) {
              showSuccess(data);
            } else {
              showScreen("select-screen");
              document.getElementById("connect-btn").disabled = false;
              showError(data.error || "Connection failed. Check your password and try again.");
            }
          } catch(e) { /* keep polling */ }
        }
      };
      xhr.onerror = function() { /* AP still down, keep polling */ };
      xhr.send();
    }, 2000);
  }

  document.getElementById("connect-btn").addEventListener("click", function() {
    var password = document.getElementById("password").value;
    if (!selectedSSID) return;
    if (!password) { showError("Please enter the WiFi password."); return; }

    this.disabled = true;
    document.getElementById("progress-title").textContent = "Connecting to " + selectedSSID + "...";
    showScreen("progress-screen");

    var xhr = new XMLHttpRequest();
    xhr.open("POST", "/connect", true);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.onload = function() {
      try {
        var data = JSON.parse(xhr.responseText);
        if (data.success) {
          showSuccess(data);
        } else if (data.status === "connecting") {
          // Accepted — poll for result
          pollResult();
        } else {
          showScreen("select-screen");
          document.getElementById("connect-btn").disabled = false;
          showError(data.error || "Connection failed. Check your password and try again.");
        }
      } catch(e) { pollResult(); }
    };
    xhr.onerror = function() {
      // Network error expected — AP goes down during switchover
      pollResult();
    };
    xhr.send(JSON.stringify({ ssid: selectedSSID, password: password }));
  });

  // Load networks on page load
  var xhr = new XMLHttpRequest();
  xhr.open("GET", "/scan", true);
  xhr.onload = function() {
    try { renderNetworks(JSON.parse(xhr.responseText)); }
    catch(e) {
      var list = document.getElementById("network-list");
      while (list.firstChild) list.removeChild(list.firstChild);
      var empty = document.createElement("li");
      empty.className = "empty";
      empty.textContent = "Could not load networks. Please refresh.";
      list.appendChild(empty);
    }
  };
  xhr.onerror = function() {
    var list = document.getElementById("network-list");
    while (list.firstChild) list.removeChild(list.firstChild);
    var empty = document.createElement("li");
    empty.className = "empty";
    empty.textContent = "Could not load networks. Please refresh.";
    list.appendChild(empty);
  };
  xhr.send();
})();
</script>
</body>
</html>`;
}

// ── Request handler ─────────────────────────────────────────────────

function handleRequest(req, res) {
  const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
  const pathname = url.pathname;

  // Captive portal detection — iOS
  if (pathname === "/hotspot-detect.html" || pathname === "/library/test/success.html") {
    res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
    res.end();
    return;
  }

  // Captive portal detection — Android
  if (pathname === "/generate_204" || pathname === "/gen_204" ||
      pathname === "/connecttest.txt" || pathname === "/ncsi.txt") {
    res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
    res.end();
    return;
  }

  // Captive portal detection — Windows
  if (pathname === "/redirect" || pathname === "/fwlink/") {
    res.writeHead(302, { Location: `http://${PORTAL_HOST || AP_IP}/` });
    res.end();
    return;
  }

  // API: GET /scan — return cached WiFi scan results
  if (pathname === "/scan" && req.method === "GET") {
    try {
      const data = fs.readFileSync(SCAN_CACHE, "utf-8");
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(data);
    } catch {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end("[]");
    }
    return;
  }

  // API: GET /result — return connection result (polled by client)
  if (pathname === "/result" && req.method === "GET") {
    try {
      if (fs.existsSync(RESULT_FILE)) {
        const data = fs.readFileSync(RESULT_FILE, "utf-8");
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(data);
        return;
      }
    } catch { /* fall through */ }
    res.writeHead(404);
    res.end();
    return;
  }

  // API: POST /connect — submit WiFi credentials
  if (pathname === "/connect" && req.method === "POST") {
    console.error(`[wifi-provision-server] /connect POST received connecting-flag=${connecting}`);
    if (connecting) {
      res.writeHead(409, { "Content-Type": "application/json" });
      res.end('{"error":"Connection already in progress"}');
      return;
    }

    let body = "";
    let bodyLen = 0;
    req.on("data", chunk => {
      bodyLen += chunk.length;
      if (bodyLen > 4096) { req.destroy(); return; }
      body += chunk;
    });
    req.on("end", () => {
      try {
        const { ssid, password } = JSON.parse(body);

        if (!ssid || typeof ssid !== "string") {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end('{"error":"SSID is required"}');
          return;
        }
        if (!password || typeof password !== "string") {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end('{"error":"Password is required"}');
          return;
        }
        // Reject passwords containing newlines (breaks FIFO protocol)
        if (password.includes("\n") || password.includes("\r")) {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end('{"error":"Password cannot contain newline characters"}');
          return;
        }

        // Validate SSID against scan cache (prevent injection of arbitrary SSIDs)
        try {
          const cached = JSON.parse(fs.readFileSync(SCAN_CACHE, "utf-8"));
          const found = cached.some(n => n.ssid === ssid);
          if (!found) {
            res.writeHead(400, { "Content-Type": "application/json" });
            res.end('{"error":"Network not found in scan results"}');
            return;
          }
        } catch { /* if cache is missing, allow the attempt */ }

        // Remove any previous result
        try { fs.unlinkSync(RESULT_FILE); } catch { /* ok */ }

        // Write to FIFO — wifi-provision.sh reads this.
        // Use async open+write to avoid blocking the event loop (a FIFO
        // write blocks until a reader opens the other end).
        connecting = true;
        console.error(`[wifi-provision-server] /connect ssid=${JSON.stringify(ssid)} → 200 sync-success hostname=${HOSTNAME}`);
        res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
        res.end(JSON.stringify({
          success: true,
          hostname: HOSTNAME,
        }));

        const fifoData = `${ssid}\n${password}`;
        fs.open(CONNECT_FIFO, "w", (err, fd) => {
          if (err) {
            connecting = false;
            console.error(`[wifi-provision-server] FIFO open error: ${err.message}`);
            return;
          }
          fs.write(fd, fifoData, (writeErr) => {
            fs.close(fd, () => {});
            if (writeErr) {
              connecting = false;
              console.error(`[wifi-provision-server] FIFO write error: ${writeErr.message}`);
            }
          });
        });

        // Poll for result to reset the connecting flag
        const pollInterval = setInterval(() => {
          try {
            if (fs.existsSync(RESULT_FILE)) {
              connecting = false;
              clearInterval(pollInterval);
            }
          } catch { /* keep polling */ }
        }, 1000);

        // Safety timeout — reset connecting flag after 90s regardless
        setTimeout(() => {
          connecting = false;
          clearInterval(pollInterval);
        }, 90000);

      } catch {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end('{"error":"Invalid JSON"}');
      }
    });
    return;
  }

  // Default: serve portal page. If the client hit the raw AP IP (e.g.
  // 192.168.4.1) and we have a friendly portal host configured, redirect
  // so the URL bar shows the brand-aware name (e.g. http://maxy.setup/).
  // dnsmasq's wildcard DNS resolves PORTAL_HOST → AP_IP transparently
  // while the AP is up.
  const incomingHost = (req.headers.host || "").split(":")[0];
  if (PORTAL_HOST && incomingHost === AP_IP) {
    res.writeHead(302, { Location: `http://${PORTAL_HOST}/` });
    res.end();
    return;
  }
  res.writeHead(200, {
    "Content-Type": "text/html; charset=utf-8",
    "Cache-Control": "no-store, no-cache, must-revalidate",
    "Pragma": "no-cache",
    "Expires": "0",
  });
  res.end(portalHTML());
}

// ── Server ──────────────────────────────────────────────────────────

const server = http.createServer(handleRequest);

// Bind to 0.0.0.0 instead of AP_IP so the server survives wlan0 IP
// removal/re-addition during the AP retry path (connect failure → re-start AP).
// Security is acceptable: the AP subnet is the only reachable network.
server.listen(PORT, "0.0.0.0", () => {
  console.error(`[wifi-provision-server] listening on http://0.0.0.0:${PORT}`);
});

server.on("error", (err) => {
  console.error(`[wifi-provision-server] server error: ${err.message}`);
  if (err.code === "EADDRINUSE") {
    console.error(`[wifi-provision-server] port ${PORT} in use — captive portal detection will not work`);
  }
  process.exit(1);
});
