// PXEForge web UI — vanilla JS, no build step, no framework, no network
// dependencies. Uses fetch() + EventSource only.
//
// Tabs (Phase 4): Dashboard / Network / Forge Gate / Storage / Terminal /
// About. The shell swaps a single view into #view-root.
//
// Keep this readable — nobody wants to debug a clever vanilla-JS
// framework at 3 AM. Plain dumb DOM construction is the design.

(function () {
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const el = (tag, attrs = {}, children = []) => {
    const e = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'class') e.className = v;
      else if (k === 'html') e.innerHTML = v;
      else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
      else if (v !== false && v != null) e.setAttribute(k, v);
    }
    for (const c of [].concat(children)) {
      if (c == null || c === false) continue;
      if (typeof c === 'string') e.appendChild(document.createTextNode(c));
      else e.appendChild(c);
    }
    return e;
  };
  const fmtBytes = (n) => {
    const u = ['B','KB','MB','GB','TB']; let i = 0;
    while (n >= 1024 && i < u.length-1) { n /= 1024; i++; }
    return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i];
  };
  const fmtAgo = (ts) => {
    const d = (ts instanceof Date) ? ts : new Date(ts);
    if (isNaN(d.getTime())) return '-';
    const ds = Math.floor((Date.now() - d.getTime()) / 1000);
    if (ds < 0)     return 'in ' + Math.abs(ds) + 's';
    if (ds < 60)    return ds + 's ago';
    if (ds < 3600)  return Math.floor(ds/60) + 'm ago';
    if (ds < 86400) return Math.floor(ds/3600) + 'h ago';
    return d.toLocaleString();
  };
  const fmtUptime = (secs) => {
    secs = Math.max(0, Math.floor(secs || 0));
    const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60;
    if (h) return h + 'h ' + m + 'm';
    if (m) return m + 'm ' + s + 's';
    return s + 's';
  };
  const familyLabel = (f) => ({
    debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family',
    open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine',
    windows_pe: 'Windows', unknown: 'Unknown',
  }[f] || f);
  const archLabel = (a) => {
    if (!a) return '—';
    if (typeof a === 'string') return a;
    if (a && typeof a === 'object') {
      if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')';
      return JSON.stringify(a);
    }
    return String(a);
  };

  // ── network helpers ──────────────────────────────────────────────
  async function getJSON(url) {
    const r = await fetch(url);
    if (!r.ok) throw new Error(url + ': ' + r.status);
    return r.json();
  }
  async function putJSON(url, body) {
    return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
  }
  async function postJSON(url, body) {
    return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
  }

  // Animated "anvil + progress bar" widget. Built once here and inlined
  // wherever a card wants to convey "an image is being forged onto the
  // bench right now." Used on the dashboard and on the Forge Gate.
  function forgeProgressWidget(imaging, gateTotal) {
    const total = Math.max(gateTotal, imaging, 1);
    const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
    const root = el('div', {class: 'forge-progress' + (imaging === 0 ? ' idle' : '')}, [
      el('div', {class: 'anvil', 'aria-hidden': 'true'}),
      el('div', {class: 'info'}, [
        el('div', {class: 'label'},
          imaging === 0
            ? 'No active imaging'
            : (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' being forged')),
        el('div', {class: 'bar-track'},
          el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
      ]),
    ]);
    return root;
  }

  // Categorize an ISO row's "bootable now" status — drives the amber
  // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
  function bootability(iso, settings) {
    const fam = iso.introspection.family;
    const isWin = fam === 'windows_pe';
    if (isWin && !settings.windows_enabled) {
      return { ok: false, reason: 'Windows boot disabled in Settings' };
    }
    if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
      // Linux without a detected kernel falls through to sanboot which
      // rarely works for >1 GiB ISOs.
      if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
        return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
      }
      return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
    }
    return { ok: true };
  }

  // ── views ────────────────────────────────────────────────────────
  const views = {
    dashboard: async () => {
      const status = await getJSON('/api/status');
      const isos = await getJSON('/api/isos');
      const clients = (await getJSON('/api/clients')).clients || [];
      const gates = (await getJSON('/api/gate')).gates || [];

      const ipxeOk = (status.ipxe_assets || []).length > 0;
      const stats = el('div', {class: 'statstrip'}, [
        el('div', {class: 'card'}, el('div', {class: 'stat'}, [
          el('div', {class: 'label'}, 'Server status'),
          el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')},
              ipxeOk ? 'Ready' : 'Not ready'),
          el('div', {class: 'trend'},
            ipxeOk ? 'Bootloaders bundled, accepting clients'
                   : 'No iPXE binaries bundled'),
        ])),
        el('div', {class: 'card'}, [
          el('div', {class: 'stat', style: 'padding-bottom:0'}, [
            el('div', {class: 'label'}, 'Imaging now'),
            el('div', {class: 'value'}, String(status.imaging_count || 0)),
            el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
          ]),
          forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
        ]),
        el('div', {class: 'card'}, el('div', {class: 'stat'}, [
          el('div', {class: 'label'}, 'Images available'),
          el('div', {class: 'value'}, String(isos.length)),
          el('div', {class: 'trend'},
            isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
            isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux  ·  ' +
            (status.nfs_active || 0) + ' NFS active'),
        ])),
        el('div', {class: 'card'}, el('div', {class: 'stat'}, [
          el('div', {class: 'label'}, 'Uptime'),
          el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
          el('div', {class: 'trend'}, 'PXEForge ' + status.version),
        ])),
      ]);

      // Recent connections — the operator's at-a-glance "who tried to
      // boot" log. Use last_seen desc (already sorted by API).
      const recent = clients.slice(0, 8);
      const recentRows = recent.map(c => {
        const g = gates.find(g => g.mac === c.mac);
        let status = el('span', {class: 'tag ok'}, 'active');
        if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
        else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' at gate');
        return el('tr', {}, [
          el('td', {class: 'mono'}, c.mac),
          el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
          el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))),
          el('td', {}, fmtAgo(c.last_seen)),
          el('td', {}, status),
        ]);
      });
      const recentBlock = el('div', {class: 'card'}, [
        el('header', {}, [
          el('h2', {}, 'Recent connections'),
          el('span', {class: 'sub'}, clients.length + ' total'),
        ]),
        recent.length
          ? el('table', {}, [
              el('thead', {}, el('tr', {}, [
                el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'),
                el('th',{},'Last seen'), el('th',{},'Status'),
              ])),
              el('tbody', {}, recentRows),
            ])
          : el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'),
      ]);

      // At-a-glance pool of problem images — Bootimus-style early warning.
      const settings = status.settings;
      const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
      const problemsBlock = problems.length ? el('div', {class:'card'}, [
        el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
        el('div', {class:'body'},
          problems.map(({i, b}) => el('div', {class:'row-warn'},
            '⚠ ' + i.filename + ' — ' + b.reason)))
      ]) : null;

      return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
    },

    network: async () => {
      const net = await getJSON('/api/network');
      const dns = el('input', {type:'text', value: net.dns_server || '',
                                placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'});
      const msg = el('div', {class:'msg'});

      const save = el('button', {onclick: async () => {
        const r = await putJSON('/api/network', { dns_server: dns.value });
        if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
        else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; }
      }}, 'Save');

      const networkCard = el('div', {class:'card'}, [
        el('header', {}, el('h2', {}, 'Network')),
        el('div', {class:'body'}, [
          el('div', {class:'kv'}, [
            el('div', {class:'k'}, 'Server IP'),
            el('div', {class:'v'}, net.server_ip || '?'),
            el('div', {class:'k'}, 'NIC name'),
            el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
            el('div', {class:'k'}, 'Subnet mask'),
            el('div', {class:'v'}, net.subnet_mask || '?'),
            el('div', {class:'k'}, 'Gateway'),
            el('div', {class:'v'}, net.gateway || '?'),
            el('div', {class:'k'}, 'Public base URL'),
            el('div', {class:'v'}, net.public_base_url),
          ]),
          el('p', {class:'msg'},
            'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
            'To change them, set PXEFORGE_PUBLIC_IP and restart — editing them ' +
            'from a hot UI would silently break PXE for every client mid-boot.'),
          el('label', {class:'field', style:'margin-top:18px'}, [
            el('span', {class:'name'}, 'DNS server (optional, informational)'),
            dns,
            el('span', {class:'hint'},
              'PXEForge does not run a DNS server itself; this field records ' +
              'what your upstream DNS is so you don\'t have to dig it out at ' +
              '3 AM during a deployment.'),
          ]),
          save, msg,
        ]),
      ]);

      return el('div', {class:'grid'}, [networkCard]);
    },

    gate: async () => {
      const [{ gates = [] }, isos] = await Promise.all([
        getJSON('/api/gate'), getJSON('/api/isos'),
      ]);
      const targets = isos.flatMap(i => i.boot_entries.map(e => ({
        id: e.id, title: e.title + '  — ' + familyLabel(i.introspection.family)
      })));
      const pick = el('select', {},
        [el('option', {value: ''}, '— choose an image —')]
        .concat(targets.map(t => el('option', {value: t.id}, t.title)))
      );
      const launch = el('button', {}, 'Launch for all waiting');
      const msg = el('div', {class:'msg'});
      launch.onclick = async () => {
        if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
        const r = await postJSON('/api/gate/assign', { target: pick.value, gate_ids: [] });
        if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
        const j = await r.json();
        if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
        msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
        msg.className = 'msg ok';
        render('gate');
      };

      const track = gates.length
        ? el('div', {class:'gate-track'},
            gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [
              el('div', {class:'pos'}, '#' + g.position),
              el('div', {}, [
                el('div', {class:'mac'}, g.mac),
                el('div', {class:'meta'},
                  (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
              ]),
              el('div', {}, g.assigned_target
                ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
                : el('span', {class:'tag accent'}, 'waiting')),
              el('button', {class:'ghost', onclick: async () => {
                await fetch('/api/gate/' + encodeURIComponent(g.id), {method:'DELETE'});
                render('gate');
              }}, 'Release'),
            ]))
          )
        : el('div', {class:'empty'},
            'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');

      const imaging = gates.filter(g => g.assigned_target).length;

      return el('div', {class:'grid'}, [
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Forge')),
          forgeProgressWidget(imaging, gates.length),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Launch an image across the gate')),
          el('div', {class:'body'}, [
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'Target image'),
              pick,
              el('span', {class:'hint'},
                'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'),
            ]),
            launch, msg,
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Gate positions'),
            el('span', {class:'sub'}, gates.length + ' waiting'),
          ]),
          el('div', {class:'body'}, track),
        ]),
      ]);
    },

    storage: async () => {
      const [isos, settings, nfsRes] = await Promise.all([
        getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
      ]);
      const mounts = nfsRes.mounts || [];

      // ── Upload card ──
      const drop = el('div', {class:'drop', id:'drop'}, [
        el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
        el('div', {style:'font-size:12px;margin-top:6px'},
           'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
      ]);
      const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
                                style:'display:none', id:'file'});
      const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
      const upMsg = el('div', {class:'msg', id:'upmsg'});

      drop.onclick = () => file.click();
      drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
      drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
      drop.addEventListener('drop', e => {
        e.preventDefault(); drop.classList.remove('hover');
        if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
      });
      file.onchange = () => { if (file.files[0]) upload(file.files[0]); };

      function upload(f) {
        upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
        upMsg.className = 'msg';
        prog.classList.add('active');
        const fd = new FormData(); fd.append('file', f);
        const xhr = new XMLHttpRequest();
        xhr.upload.onprogress = e => {
          if (e.lengthComputable) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%';
        };
        xhr.onload = () => {
          prog.classList.remove('active');
          $('#bar').style.width = '0';
          if (xhr.status >= 200 && xhr.status < 300) {
            upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok';
            render('storage');
          } else {
            upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText;
            upMsg.className = 'msg err';
          }
        };
        xhr.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; };
        xhr.open('POST', '/api/isos');
        xhr.send(fd);
      }

      // ── ISO table (mixed local + NFS) ──
      const rows = isos.map(i => {
        const b = bootability(i, settings);
        const isNfs = i.source && i.source.kind === 'nfs';
        const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
          el('td', {}, [
            el('div', {}, i.filename),
            !b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
                  : (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
          ]),
          el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))),
          el('td', {class:'num'}, fmtBytes(i.size_bytes)),
          el('td', {},
            el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
              isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
          el('td', {}, fmtAgo(i.uploaded_at)),
          el('td', {style:'text-align:right'},
            isNfs
              ? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share')
              : el('button', {class:'danger', onclick: async () => {
                  if (!confirm('Remove this image?')) return;
                  await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
                  render('storage');
                }}, 'Remove')),
        ]);
        return tr;
      });
      const isoTable = isos.length
        ? el('table', {}, [
            el('thead', {}, el('tr', {}, [
              el('th',{},'Name'), el('th',{},'Type'),
              el('th',{class:'num'},'Size'),
              el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''),
            ])),
            el('tbody', {}, rows),
          ])
        : el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');

      // ── NFS section ──
      const nfsMsg = el('div', {class:'msg'});
      const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
      const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
      const nfsVer = el('select', {}, [
        el('option', {value:'v41'}, 'NFSv4.1 (default)'),
        el('option', {value:'v3'}, 'NFSv3'),
      ]);
      const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
      const addNfs = el('button', {onclick: async () => {
        if (!nfsServer.value || !nfsExport.value) {
          nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
        }
        nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
        const r = await postJSON('/api/nfs', {
          server: nfsServer.value, export: nfsExport.value,
          version: nfsVer.value, read_only: nfsRo.checked,
        });
        if (r.ok) {
          nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
          render('storage');
        } else {
          const t = await r.text();
          nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
        }
      }}, 'Mount share');

      const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
        el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
        el('div', {}, [
          el('div', {class:'id'}, m.server + ':' + m.export),
          el('div', {class:'meta'},
            (m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
            (m.read_only ? 'read-only' : 'read-write') + ' · ' +
            (m.mounted ? m.iso_count + ' isos' : 'not mounted')),
          m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
        ]),
        el('button', {class:'ghost', onclick: async () => {
          const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
          if (r.ok) render('storage');
        }}, 'Re-scan'),
        el('button', {class:'danger', onclick: async () => {
          if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
          await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
          render('storage');
        }}, 'Unmount'),
        el('span'),
      ])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];

      return el('div', {class:'grid'}, [
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Upload ISO')),
          el('div', {class:'body'}, [drop, file, prog, upMsg]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'NFS shares'),
            el('span', {class:'sub'}, mounts.length + ' configured'),
          ]),
          el('div', {class:'body'}, [
            el('div', {class:'form-row'}, [
              el('label', {class:'field'}, [
                el('span', {class:'name'}, 'NFS server'),
                nfsServer,
              ]),
              el('label', {class:'field'}, [
                el('span', {class:'name'}, 'Export path'),
                nfsExport,
              ]),
              el('label', {class:'field'}, [
                el('span', {class:'name'}, 'Version'),
                nfsVer,
              ]),
              el('label', {class:'check', style:'margin-top:18px'}, [
                nfsRo, el('span', {}, 'Read-only'),
              ]),
            ]),
            addNfs, nfsMsg,
            el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
            el('p', {class:'msg', style:'margin-top:14px'},
              'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
              'mount.nfs binary (bundled in the default Docker image). On ' +
              'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
              'NFS mounts as a CSI driver outside the pod.'),
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Available images'),
            el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
          ]),
          isoTable,
        ]),
      ]);
    },

    hosts: async () => {
      const [{ hosts = [] }, isos] = await Promise.all([
        getJSON('/api/hosts'), getJSON('/api/isos'),
      ]);
      const targets = isos.flatMap(i => i.boot_entries.map(e => ({
        id: e.id, title: e.title + '  — ' + familyLabel(i.introspection.family),
      })));

      // Reserved menu shortcuts that the operator might want to bind.
      const reserved = [
        {id: '_local',        title: '↳ Boot from Local HDD (built-in)'},
        {id: '_gate',         title: '↳ Gated Deployment (built-in)'},
        {id: '_tools_menu',   title: '↳ Tools menu (built-in)'},
      ];

      const macInput   = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
      const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
      const targetSel = el('select', {},
        [el('option', {value:''}, '— choose a target —')]
        .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
        .concat(targets.map(t => el('option', {value: t.id}, t.title))));
      const msg = el('div', {class:'msg'});

      const upsertBtn = el('button', {onclick: async () => {
        if (!macInput.value || !targetSel.value) {
          msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
        }
        const r = await postJSON('/api/hosts', {
          mac: macInput.value, target: targetSel.value, label: labelInput.value,
        });
        if (r.ok) {
          msg.textContent = 'Saved.'; msg.className = 'msg ok';
          render('hosts');
        } else {
          const t = await r.text();
          msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
        }
      }}, 'Bind MAC to target');

      const rows = hosts.map(h => el('tr', {}, [
        el('td', {class:'mono'}, h.mac),
        el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
        el('td', {class:'mono'}, h.target),
        el('td', {}, fmtAgo(h.updated_at)),
        el('td', {style:'text-align:right'},
          el('button', {class:'danger', onclick: async () => {
            if (!confirm('Remove binding for ' + h.mac + '?')) return;
            await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
            render('hosts');
          }}, 'Remove')),
      ]));

      const table = hosts.length
        ? el('table', {}, [
            el('thead', {}, el('tr', {}, [
              el('th',{},'MAC'), el('th',{},'Label'),
              el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
            ])),
            el('tbody', {}, rows),
          ])
        : el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');

      return el('div', {class:'grid'}, [
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
          el('div', {class:'body'}, [
            el('div', {class:'form-row'}, [
              el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
              el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
              el('label', {class:'field', style:'grid-column:1 / -1'}, [
                el('span', {class:'name'}, 'Target'),
                targetSel,
                el('span', {class:'hint'},
                  'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
              ]),
            ]),
            upsertBtn, msg,
            el('p', {class:'msg', style:'margin-top:14px'},
              'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
              'short-circuits past the interactive menu and chains directly. ' +
              'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Bound hosts'),
            el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
          ]),
          table,
        ]),
      ]);
    },

    terminal: async () => {
      // Two-pane layout: live log on top (auto-scrolling), command line
      // on bottom. Mirrors the Minecraft-server console feel from the
      // brief — output and input share one continuous timeline.
      const pane = el('div', {class:'pane'});
      const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
      const auto = el('input', {type:'checkbox'}); auto.checked = true;
      const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
      const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');

      const term = el('div', {class:'terminal'}, [
        el('div', {class:'toolbar'}, [
          el('span', {}, 'Live log + operator console'),
          el('div', {class:'right'}, [
            el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
              [auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
            clearBtn,
          ]),
        ]),
        pane,
        el('div', {class:'input-row'}, [
          el('span', {class:'prompt'}, '>'),
          input,
        ]),
      ]);

      function append(line, kind) {
        const lvl = (line.level || 'info').toLowerCase();
        const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
        const span = el('span', {class: 'lvl-' + lvl}, [
          el('span', {class:'ts'}, ts + '  '),
          el('span', {class:'tg'}, '[' + (line.target || 'pxeforge') + '] '),
          line.message,
          '\n',
        ]);
        if (kind === 'echo') {
          span.firstChild.nextSibling.textContent = '';
          span.firstChild.textContent = '';
          span.classList.add('echo');
        }
        pane.appendChild(span);
        if (auto.checked) pane.scrollTop = pane.scrollHeight;
      }

      // Initial fetch — show recent buffer in case SSE is slow to open.
      try {
        const r = await getJSON('/api/log/recent');
        for (const l of (r.lines || [])) append(l);
      } catch (e) {
        append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
                message: 'failed to load recent logs: ' + e.message});
      }

      // Live SSE stream. EventSource auto-reconnects on disconnect.
      const es = new EventSource('/api/log/stream');
      es.onmessage = (ev) => {
        try { append(JSON.parse(ev.data)); }
        catch { append({timestamp: new Date().toISOString(), level:'debug', target:'pxeforge::ui', message: ev.data}); }
      };
      es.addEventListener('lagged', (ev) => {
        const j = JSON.parse(ev.data || '{}');
        append({timestamp: new Date().toISOString(), level:'warn',
                target:'pxeforge::ui',
                message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
      });
      es.onerror = () => {
        // EventSource quietly retries; surface a hint without spamming.
        // We only append once, on transition from connected → erroring.
        if (!term._notedErr) {
          term._notedErr = true;
          append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
                  message: 'log stream connection lost — auto-reconnecting'});
          setTimeout(() => { term._notedErr = false; }, 5000);
        }
      };
      // Close the SSE when the view changes — avoids piling up streams.
      term._cleanup = () => es.close();

      // Command history (in-memory only, ↑/↓ to recall).
      const history = [];
      let hi = -1;

      input.addEventListener('keydown', async (e) => {
        if (e.key === 'Enter') {
          const cmd = input.value;
          if (!cmd.trim()) return;
          input.value = '';
          history.unshift(cmd); if (history.length > 100) history.pop();
          hi = -1;
          // The echo also comes back from the server in the live tail,
          // so we don't append it locally — keeps the order consistent.
          try {
            const r = await postJSON('/api/terminal', {command: cmd});
            const j = await r.json();
            const out = j.output || '';
            if (out === '\f') { pane.innerHTML = ''; return; }
            // Output also gets pushed onto the LogBus by the server, but
            // include it locally so even if the SSE stream dropped we
            // see it. Tagged "echo" so it stands out from regular logs.
            append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
                    target: 'terminal-output', message: out});
          } catch (err) {
            append({timestamp: new Date().toISOString(), level:'error',
                    target:'pxeforge::ui', message: 'command failed: ' + err.message});
          }
        } else if (e.key === 'ArrowUp') {
          if (history.length === 0) return;
          hi = Math.min(hi + 1, history.length - 1);
          input.value = history[hi];
          e.preventDefault();
        } else if (e.key === 'ArrowDown') {
          hi = Math.max(hi - 1, -1);
          input.value = hi < 0 ? '' : history[hi];
          e.preventDefault();
        }
      });

      // Welcome banner.
      append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal',
              message: 'Connected. Type "help" for available commands.'});

      // Focus the input on next tick (after view swap completes).
      setTimeout(() => input.focus(), 50);

      return term;
    },

    about: async () => {
      const status = await getJSON('/api/status');
      return el('div', {class:'card'}, [
        el('div', {class:'about-hero'}, [
          el('h2', {}, 'PXEForge'),
          el('p', {class:'lead'},
            'Air-gapped network PXE boot, container-native, that anyone can run. ' +
            'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
            'the image once, run it forever.'),
          el('div', {class:'who'}, [
            el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
            el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
            el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url),
          ]),
          el('p', {class:'msg', style:'margin-top:18px'},
            'iPXE is an internal implementation detail. Everything the firmware ' +
            'executes is generated from the settings on these tabs — there is no ' +
            'hand-written .ipxe path anywhere in this product.'),
          el('p', {class:'msg'},
            'Vision: a deployment-grade tool that works on first try in the most ' +
            'awkward environments — air-gapped labs, customer sites without ' +
            'internet, OpenShift clusters with strict SCCs — without ever asking ' +
            'an operator to install drivers signed with test certificates or to ' +
            'flip "testsigning" on a target machine.'),
        ]),
      ]);
    },
  };

  // ── shell ────────────────────────────────────────────────────────
  const viewTitles = {
    dashboard: 'Dashboard',
    network: 'Network',
    gate: 'Forge Gate',
    storage: 'Storage',
    hosts: 'Hosts',
    terminal: 'Terminal',
    about: 'About',
  };

  // Theme toggle. The data-attribute is set on <html> by the inline
  // script in index.html before paint; we just flip it here and persist.
  function applyTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    try { localStorage.setItem('pxeforge-theme', theme); } catch {}
  }
  function currentTheme() {
    return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
  }
  document.addEventListener('DOMContentLoaded', () => {
    const btn = $('#theme-toggle');
    if (btn) {
      btn.addEventListener('click', () => {
        applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
      });
    }
  });
  // Keyboard shortcut: T toggles theme (skip when typing in an input).
  document.addEventListener('keydown', (e) => {
    if (e.key !== 't' && e.key !== 'T') return;
    if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
    applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
  });

  let currentBody = null;

  async function render(view) {
    view = view || 'dashboard';
    $$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
    $('[data-bind=view_title]').textContent = viewTitles[view] || view;
    const root = $('#view-root');
    // Clean up any per-view resources (e.g. terminal SSE) before swap.
    if (currentBody && typeof currentBody._cleanup === 'function') {
      try { currentBody._cleanup(); } catch (e) { /* ignore */ }
    }
    root.innerHTML = '';
    // Animated anvil loader. Replaces the old text "Loading…" so the
    // user gets a sense of "the forge is heating up" instead of a flat
    // spinner. The SVG itself drives all animation via SMIL, no JS.
    root.appendChild(el('div', {class:'loader'}, [
      el('div', {class:'anvil'}),
      el('div', {}, 'Heating the forge…'),
    ]));
    try {
      const body = await views[view]();
      root.innerHTML = '';
      root.appendChild(body);
      currentBody = body;
    } catch (e) {
      root.innerHTML = '';
      root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
    }
  }

  async function refreshChips() {
    try {
      const s = await getJSON('/api/status');
      const r = await fetch('/readyz');
      $$('[data-bind=version]').forEach(n => n.textContent = s.version);
      $$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
      $$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
      $$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
      $$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
      const chip = $('[data-bind=ready_chip]');
      if (chip) {
        if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
        else      { chip.textContent = '● not ready'; chip.className = 'chip notready'; }
      }
    } catch {
      const chip = $('[data-bind=ready_chip]');
      if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; }
    }
  }

  document.addEventListener('click', (e) => {
    const a = e.target.closest('.sidebar nav a[data-view]');
    if (a) { e.preventDefault(); render(a.dataset.view); }
  });

  render('dashboard');
  refreshChips();
  setInterval(refreshChips, 3000);
})();
