// Setup Wizard - First-time install configuration UI.
//
// Multi-step slide-right form. Each step is a sibling div inside a track
// that translates horizontally as the user advances. Submission POSTs to
// /api/setup/save which fans out into separate tasks per app, then this UI
// hands off to the tasks page focused on the first task.

class SetupWizard {
  constructor() {
    this.container = null;
    this.suggestedName = '';
    this.dnsCheckTimer = null;
    this.dnsCheckController = null;
    this.onComplete = null;
    this.currentStep = 0;
    this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics'];
    this.stepIcons = ['🪐', '🛰️', '🛡️', '📊'];
    this.totalSteps = this.stepNames.length;
    this.domainCount = 0; // tracked dynamically as the user adds rows
  }

  initialize(setupDetector, onComplete = null) {
    this.onComplete = onComplete;
    this.create();
    this.attach();
    this.suggestName();
    this.renderAppTiles();
    this.preselectTimezone();
    this.showStep(0);
  }

  getWizardApps() {
    return [
      { slug: 'traefik',    recommended: true, defaultChecked: true,
        fallback: { name: 'Traefik',    description: 'Reverse proxy + automatic SSL via LetsEncrypt' } },
      { slug: 'crowdsec',   recommended: true, defaultChecked: true,
        fallback: { name: 'CrowdSec',   description: 'Host-installed intrusion prevention' } }
    ];
  }

  getMetricsApps() {
    return [
      { slug: 'prometheus', defaultChecked: false,
        fallback: { name: 'Prometheus', description: 'Scrapes and stores time-series metrics from your apps' } },
      { slug: 'grafana',    defaultChecked: false,
        fallback: { name: 'Grafana',    description: 'Dashboards and visualisations on top of Prometheus metrics' } }
    ];
  }

  create() {
    this.container = document.createElement('div');
    this.container.className = 'setup-wizard aurora-bg aurora-static';
    this.container.innerHTML = `
      <div class="aurora-stars" aria-hidden="true"></div>

      <div class="setup-content">
        <div class="aurora-header">
          <div class="aurora-logo">
            <img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
            <h1>LibrePortal</h1>
          </div>
          <p class="aurora-subtitle">Tuning your private universe before takeoff...</p>
        </div>

        <div class="setup-card">
        <div class="setup-progress">
          <div class="setup-progress-bar">
            <div class="setup-progress-fill" id="sw-progress-fill"></div>
          </div>
          <div class="setup-progress-text">
            <span id="sw-progress-step">Step 1 of ${this.totalSteps}</span>
            <span id="sw-progress-pct">0%</span>
          </div>
        </div>

        <form class="setup-form" id="setup-form" autocomplete="off">

          <div class="setup-track-wrap">
          <div class="setup-track" id="sw-track">

            <!-- Step 1: Identity -->
            <section class="setup-step" data-step="0">
              <div class="setup-field">
                <label for="sw-name">
                  Install Name
                  <span class="setup-tooltip" tabindex="0" data-tip="A unique name for this install — used in backups, SSH keys, and VPN identity.">?</span>
                </label>
                <div class="setup-input-row">
                  <span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🧑‍🚀</span>
                  <input type="text" id="sw-name" class="setup-input-with-icon" pattern="[a-zA-Z0-9\\-]+" required>
                  <button type="button" class="setup-manifest" id="sw-reroll" title="Manifest a new cosmic name">
                    <svg class="setup-manifest-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
                    <span>Manifest</span>
                  </button>
                </div>
              </div>

              <div class="setup-field">
                <label for="sw-timezone">
                  Timezone
                  <span class="setup-tooltip" tabindex="0" data-tip="Used by scheduled tasks and log timestamps. We'll preselect the OS detected zone.">?</span>
                </label>
                <div class="setup-input-row">
                  <span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🕒</span>
                  <select id="sw-timezone" class="setup-input-with-icon form-control" required>
                    <option value="">Select…</option>
                    <option value="Europe/London">Europe/London</option>
                    <option value="Europe/Paris">Europe/Paris</option>
                    <option value="Europe/Berlin">Europe/Berlin</option>
                    <option value="America/New_York">America/New_York</option>
                    <option value="America/Chicago">America/Chicago</option>
                    <option value="America/Los_Angeles">America/Los_Angeles</option>
                    <option value="America/Denver">America/Denver</option>
                    <option value="America/Phoenix">America/Phoenix</option>
                    <option value="Asia/Tokyo">Asia/Tokyo</option>
                    <option value="Australia/Sydney">Australia/Sydney</option>
                    <option value="Etc/UTC">Etc/UTC</option>
                  </select>
                </div>
              </div>
            </section>

            <!-- Step 2: Domains (optional, multi). Each domain enables HTTPS
                 routing for apps via Traefik. Skipping leaves the install
                 local-only (apps reachable by IP and Port, no SSL). -->
            <section class="setup-step" data-step="1">
              <div class="setup-field">
                <label>
                  Domains
                  <span class="setup-tooltip" tabindex="0" data-tip="Optional. Each domain you add unlocks https://app.&lt;domain&gt; routing (via Traefik) and a self-signed SSL cert. Skip entirely for a local-only install reachable by IP.">?</span>
                </label>
                <div id="sw-domain-list" class="setup-domain-list"></div>
                <button type="button" class="setup-domain-add" id="sw-domain-add">
                  <span>+</span> Add domain
                </button>
                <p class="setup-step-note">No domain? Skip this step — apps will be reachable by IP and Port on your LAN.</p>
              </div>
            </section>

            <!-- Step 3: Recommended apps (Traefik + Fail2ban) -->
            <section class="setup-step" data-step="2">
              <div class="setup-section">
                <div class="setup-section-title">Recommended Apps</div>
                <p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
                <div id="sw-apps-recommended"></div>

                <!-- Conditional: only shown when Traefik is ticked. -->
                <div class="setup-field setup-traefik-email" id="sw-traefik-email-wrap" style="display:none; margin-top: 10px;">
                  <label for="sw-traefik-email">
                    LetsEncrypt Email
                    <span class="setup-tooltip" tabindex="0" data-tip="Used by LetsEncrypt for ACME registration and cert-expiry warnings. Required because you ticked Traefik.">?</span>
                  </label>
                  <div class="setup-input-row">
                    <span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">✉️</span>
                    <input type="email" id="sw-traefik-email" class="setup-input-with-icon" placeholder="you@example.com">
                  </div>
                </div>
              </div>
            </section>

            <!-- Step 4: Optional metrics apps (Prometheus + Grafana). Off by
                 default — they're only useful if the user wants the MONITORING
                 toggle on apps to do anything. -->
            <section class="setup-step" data-step="3">
              <div class="setup-section">
                <div class="setup-section-title">Metrics Apps</div>
                <p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
                <div id="sw-apps-metrics"></div>
              </div>
            </section>

          </div>
          </div>

          <div class="setup-error" id="sw-error" style="display:none;"></div>

          <div class="setup-nav">
            <button type="button" class="setup-btn-back" id="sw-back" disabled>← Back</button>
            <button type="button" class="setup-btn-next" id="sw-next">Next →</button>
            <button type="submit" class="setup-launch" id="sw-submit" style="display:none;">
              <span class="setup-launch-text">Launch Install</span>
              <span class="setup-launch-arrow">→</span>
            </button>
          </div>

        </form>
        </div>
      </div>
    `;
    document.body.appendChild(this.container);
    document.body.classList.add('setup-wizard-open');
  }

  attach() {
    const $ = (id) => this.container.querySelector(id);

    $('#sw-reroll').addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      const btn = $('#sw-reroll');
      btn.classList.add('manifesting');
      setTimeout(() => btn.classList.remove('manifesting'), 700);
      this.suggestName(true);
    });

    $('#sw-domain-add').addEventListener('click', () => this.addDomainRow());
    // Seed with one empty row so the user has somewhere to type. They can
    // remove it if they truly want a local-only install.
    this.addDomainRow();

    this.attachLiveValidation();

    $('#sw-back').addEventListener('click', () => this.prev());
    $('#sw-next').addEventListener('click', () => this.next());

    $('#setup-form').addEventListener('submit', (e) => {
      e.preventDefault();
      console.log('[setup] form submit fired');
      this.submit();
    });

    $('#sw-submit').addEventListener('click', (e) => {
      e.preventDefault();
      console.log('[setup] launch button clicked');
      this.submit();
    });

    $('#setup-form').addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && this.currentStep < this.totalSteps - 1) {
        e.preventDefault();
        this.next();
      }
    });
  }

  showStep(n) {
    this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n));

    this.container.querySelectorAll('.setup-step').forEach((el, idx) => {
      el.classList.toggle('active', idx === this.currentStep);
    });

    const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100);
    const name = this.stepNames[this.currentStep];
    const icon = this.stepIcons[this.currentStep];
    this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`;
    this.container.querySelector('#sw-progress-step').innerHTML =
      `Step ${this.currentStep + 1} of ${this.totalSteps} <span class="setup-progress-sep">—</span> <span class="setup-progress-icon">${icon}</span> <span class="setup-progress-name">${name}</span>`;
    this.container.querySelector('#sw-progress-pct').textContent = `${pct}%`;

    this.container.querySelector('#sw-back').disabled = this.currentStep === 0;
    const isLast = this.currentStep === this.totalSteps - 1;
    this.container.querySelector('#sw-next').style.display = isLast ? 'none' : '';
    this.container.querySelector('#sw-submit').style.display = isLast ? '' : 'none';

    setTimeout(() => {
      const focusable = this.container.querySelector(`.setup-step.active input, .setup-step.active select`);
      if (focusable) focusable.focus();
    }, 350);
  }

  validateStep(idx) {
    const $ = (id) => this.container.querySelector(id);
    if (idx === 0) {
      const name = $('#sw-name').value.trim();
      if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
      if (!$('#sw-timezone').value) return 'Please select a timezone.';
    }
    if (idx === 1) {
      // Domains are optional, but any non-empty input must be valid.
      const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
      for (const input of inputs) {
        const v = input.value.trim();
        if (v && !/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(v)) {
          return 'One of your domains doesn\'t look valid.';
        }
      }
    }
    if (idx === 2) {
      const traefikBox = this.container.querySelector('input[data-app="traefik"]');
      if (traefikBox && traefikBox.checked) {
        const tEmail = $('#sw-traefik-email').value.trim();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tEmail)) {
          return 'Traefik needs a valid LetsEncrypt email.';
        }
      }
    }
    return null;
  }

  next() {
    const err = this.validateStep(this.currentStep);
    if (err) { this.showError(err); return; }
    this.clearError();
    this.showStep(this.currentStep + 1);
  }

  prev() {
    this.clearError();
    this.showStep(this.currentStep - 1);
  }

  async loadAppsManifest() {
    if (this._appsManifest) return this._appsManifest;
    try {
      const res = await fetch('/data/apps/generated/apps.json');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      const list = Array.isArray(data) ? data : (data.apps || []);
      this._appsManifest = new Map();
      for (const app of list) {
        const key = (app.slug || app.name || app.id || '').toLowerCase();
        if (key) this._appsManifest.set(key, app);
      }
    } catch (err) {
      this._appsManifest = new Map();
    }
    return this._appsManifest;
  }

  async renderAppTiles() {
    const manifest = await this.loadAppsManifest();
    const recBox = this.container.querySelector('#sw-apps-recommended');
    if (!recBox) return;

    const buildTile = ({ slug, defaultChecked, fallback, subOption }) => {
      const app = manifest.get(slug) || {};
      const name = app.title || app.name || fallback.name;
      const desc = app.description || fallback.description;
      const icon = app.icon || `/icons/apps/${slug}.svg`;
      // Sub-option lives OUTSIDE the parent label (nested <label> behaviour
      // is messy — clicks would toggle both checkboxes). Both labels are
      // siblings inside a wrapper div; CSS connects them visually.
      const subHtml = subOption ? `
        <label class="setup-app-suboption" data-parent="${slug}">
          <input type="checkbox" data-app-suboption="${slug}:${subOption.id}" ${subOption.defaultChecked ? 'checked' : ''}>
          <span class="setup-app-suboption-label">${subOption.label}</span>
        </label>
      ` : '';
      const tile = `
        <label class="setup-app">
          <input type="checkbox" data-app="${slug}" ${defaultChecked ? 'checked' : ''}>
          <div class="setup-app-icon-wrap">
            <img src="${icon}" alt="${name}" class="setup-app-icon"
                 onerror="this.onerror=null; this.src='/icons/apps/default.svg'">
          </div>
          <div class="setup-app-info">
            <div class="setup-app-name">${name}</div>
            <div class="setup-app-desc">${desc}</div>
          </div>
        </label>
      `;
      return subOption
        ? `<div class="setup-app-group">${tile}${subHtml}</div>`
        : tile;
    };

    const apps = this.getWizardApps();
    recBox.innerHTML = apps.map(buildTile).join('');

    apps.filter(a => a.defaultChecked).forEach(a => {
      const cb = this.container.querySelector(`input[data-app="${a.slug}"]`);
      if (cb) cb.checked = true;
    });

    const metricsBox = this.container.querySelector('#sw-apps-metrics');
    if (metricsBox) {
      const metricsApps = this.getMetricsApps();
      metricsBox.innerHTML = metricsApps.map(buildTile).join('');

      // Grafana's datasource is hardcoded to prometheus-service — ticking Grafana
      // auto-ticks and locks Prometheus.
      const gBox = this.container.querySelector('input[data-app="grafana"]');
      const pBox = this.container.querySelector('input[data-app="prometheus"]');
      if (gBox && pBox) {
        const sync = () => {
          if (gBox.checked) {
            pBox.checked = true;
            pBox.disabled = true;
            pBox.title = 'Required by Grafana';
          } else {
            pBox.disabled = false;
            pBox.title = '';
          }
        };
        gBox.addEventListener('change', sync);
        sync();
      }
    }

    // Once tiles are in the DOM, wire the Traefik checkbox to the conditional
    // LetsEncrypt email field, and pre-tick it if any domains are configured.
    const traefikBox = this.container.querySelector('input[data-app="traefik"]');
    const emailWrap = this.container.querySelector('#sw-traefik-email-wrap');
    if (traefikBox && emailWrap) {
      const sync = () => { emailWrap.style.display = traefikBox.checked ? '' : 'none'; };
      traefikBox.addEventListener('change', sync);
      sync();
    }
    // Sub-options: when a parent app is unchecked, disable + uncheck its
    // sub-option (it's meaningless without the parent installed).
    this.container.querySelectorAll('.setup-app-suboption').forEach((subLabel) => {
      const slug = subLabel.dataset.parent;
      const parent = this.container.querySelector(`input[data-app="${slug}"]`);
      const sub = subLabel.querySelector('input[type=checkbox]');
      if (!parent || !sub) return;
      const sync = () => {
        sub.disabled = !parent.checked;
        subLabel.classList.toggle('disabled', !parent.checked);
      };
      parent.addEventListener('change', sync);
      sync();
    });
    // Initial pass: if domains were already entered before tiles rendered,
    // reflect that on the Traefik recommendation now.
    this.refreshTraefikRecommendationFromDomains();
  }

  preselectTimezone() {
    let tz;
    try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { tz = null; }
    if (!tz) return;
    const sel = this.container.querySelector('#sw-timezone');
    if (!sel) return;
    const exists = Array.from(sel.options).some(o => o.value === tz);
    if (!exists) {
      const opt = document.createElement('option');
      opt.value = tz;
      opt.textContent = `${tz} (detected)`;
      sel.insertBefore(opt, sel.options[1] || null);
    }
    sel.value = tz;
  }

  generateName() {
    const ADJECTIVES = [
      'Quantum','Neutrino','Photon','Plasma','Quasar','Pulsar','Tachyon','Boson','Fermion','Hadron',
      'Gluon','Muon','Higgs','Entangled','Singular','Warped','Tunneling','Coherent','Superposed','Spectral',
      'Orbital','Cosmic','Stellar','Nebular','Astral','Gravitic','Inertial','Relativistic','Helical','Toroidal',
      'Holographic','Cryogenic','Crystalline','Resonant','Harmonic','Phasic','Drifting','Spinning','Pulsing','Hyper'
    ];
    const NOUNS = [
      'Frog','Fox','Otter','Raven','Wolf','Yak','Lynx','Owl','Hawk','Crow','Newt','Wren','Eel','Crab','Squid',
      'Octopus','Mantis','Cobra','Viper','Ferret','Badger','Penguin','Panda','Lemur','Quark','Nebula','Comet',
      'Nova','Eclipse','Aurora','Vortex','Helix','Halo','Phoenix','Hydra','Kraken','Sphinx','Specter','Phantom','Glyph'
    ];
    const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
    const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
    return `${adj}${noun}`;
  }

  suggestName(forceDifferent = false) {
    const input = this.container.querySelector('#sw-name');
    const previous = input ? input.value : '';
    let name = this.generateName();
    if (forceDifferent) {
      for (let i = 0; i < 8 && name === previous; i++) name = this.generateName();
    }
    this.suggestedName = name;
    if (!input) return;
    input.value = name;
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.classList.remove('setup-name-pulse');
    void input.offsetWidth;
    input.classList.add('setup-name-pulse');
    setTimeout(() => input.classList.remove('setup-name-pulse'), 600);
  }

  // Multi-domain editor — appends a new row up to the CFG_DOMAIN_1..9 cap.
  // Each row tracks its own DNS check timer/abort controller so multiple
  // simultaneous checks don't cancel each other.
  addDomainRow() {
    const list = this.container.querySelector('#sw-domain-list');
    if (!list) return;
    if (list.children.length >= 9) return; // CFG_DOMAIN_1..9

    const idx = this.domainCount++;
    const row = document.createElement('div');
    row.className = 'setup-domain-row';
    row.dataset.idx = idx;
    row.innerHTML = `
      <div class="setup-input-row">
        <span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🌍</span>
        <input type="text" class="setup-input-with-icon setup-domain-input" placeholder="example.org">
        <button type="button" class="setup-domain-remove" title="Remove this domain" aria-label="Remove">−</button>
      </div>
      <div class="setup-dns-status"></div>
    `;
    list.appendChild(row);

    const input = row.querySelector('.setup-domain-input');
    const status = row.querySelector('.setup-dns-status');
    const remove = row.querySelector('.setup-domain-remove');

    let dnsTimer = null;
    let dnsCtrl = null;

    input.addEventListener('input', (e) => {
      this.runDnsCheckForRow(e.target.value, status, input, () => dnsTimer, (t) => dnsTimer = t, () => dnsCtrl, (c) => dnsCtrl = c);
    });

    remove.addEventListener('click', () => {
      if (dnsCtrl) dnsCtrl.abort();
      if (dnsTimer) clearTimeout(dnsTimer);
      row.remove();
      this.refreshTraefikRecommendationFromDomains();
    });

    setTimeout(() => input.focus(), 30);
  }

  runDnsCheckForRow(domain, status, input, getTimer, setTimer, getCtrl, setCtrl) {
    const t = getTimer(); if (t) clearTimeout(t);
    const c = getCtrl(); if (c) c.abort();

    domain = domain.trim().toLowerCase();
    input.classList.remove('is-valid', 'is-invalid');
    input.removeAttribute('data-error');
    const row = input.closest('.setup-input-row');
    if (row) row.removeAttribute('data-error');

    if (!domain) {
      status.textContent = '';
      status.className = 'setup-dns-status';
      this.refreshTraefikRecommendationFromDomains();
      return;
    }
    if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(domain)) {
      status.textContent = '';
      status.className = 'setup-dns-status';
      input.classList.add('is-invalid');
      input.setAttribute('data-error', 'Doesn\'t look like a valid domain');
      if (row) row.setAttribute('data-error', 'Doesn\'t look like a valid domain');
      return;
    }

    status.textContent = 'Checking DNS…';
    status.className = 'setup-dns-status checking';

    setTimer(setTimeout(async () => {
      setCtrl(new AbortController());
      try {
        const res = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, {
          signal: getCtrl().signal
        });
        const data = await res.json();
        if (data.matches) {
          status.textContent = `✓ ${domain} → ${data.server_ip} (this server)`;
          status.className = 'setup-dns-status ok';
          input.classList.add('is-valid');
        } else {
          const detail = data.domain_ip
            ? `points to ${data.domain_ip}, this server is ${data.server_ip}`
            : `does not resolve (server: ${data.server_ip || 'unknown'})`;
          status.textContent = `⚠ ${domain} ${detail}. Traefik may not route this yet.`;
          status.className = 'setup-dns-status warn';
        }
        this.refreshTraefikRecommendationFromDomains();
      } catch (err) {
        if (err.name !== 'AbortError') {
          status.textContent = 'DNS check failed.';
          status.className = 'setup-dns-status warn';
        }
      }
    }, 500));
  }

  // Auto-untick Traefik when no valid domains exist (it has nothing to route).
  // Auto-tick when at least one domain is present and looks valid.
  refreshTraefikRecommendationFromDomains() {
    const traefikBox = this.container.querySelector('input[data-app="traefik"]');
    if (!traefikBox) return;
    const domains = this.collectDomains();
    traefikBox.checked = domains.length > 0;
    // Re-sync the LetsEncrypt email visibility
    const emailWrap = this.container.querySelector('#sw-traefik-email-wrap');
    if (emailWrap) emailWrap.style.display = traefikBox.checked ? '' : 'none';
  }

  collectDomains() {
    return Array.from(this.container.querySelectorAll('.setup-domain-input'))
      .map(i => i.value.trim().toLowerCase())
      .filter(d => d && /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(d));
  }

  async submit() {
    console.log('[setup] submit() entered');
    if (this._submitting) { console.log('[setup] already submitting, ignoring'); return; }
    this._submitting = true;
    const $ = (id) => this.container.querySelector(id);
    this.clearError();

    for (let i = 0; i < this.totalSteps; i++) {
      const err = this.validateStep(i);
      if (err) {
        console.log('[setup] step', i, 'validation failed:', err);
        this.showStep(i);
        this.showError(err);
        this._submitting = false;
        return;
      }
    }
    console.log('[setup] all steps valid, posting payload');

    const apps = Array.from(this.container.querySelectorAll('.setup-app input[type=checkbox][data-app]:checked'))
      .map(cb => cb.dataset.app);
    // Sub-options live under their parent app and only matter if the parent
    // is installed. Shape: { crowdsec: { dashboard: true }, ... }
    const appOptions = {};
    this.container.querySelectorAll('input[data-app-suboption]').forEach((cb) => {
      const [slug, optId] = cb.dataset.appSuboption.split(':');
      if (!apps.includes(slug)) return;
      appOptions[slug] = appOptions[slug] || {};
      appOptions[slug][optId] = cb.checked;
    });
    const domains = this.collectDomains();
    const payload = {
      install_name: $('#sw-name').value.trim(),
      timezone: $('#sw-timezone').value,
      domains,
      apps,
      appOptions,
      traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : ''
    };

    const submitBtn = $('#sw-submit');
    submitBtn.disabled = true;
    submitBtn.querySelector('.setup-launch-text').textContent = 'Launching…';

    try {
      const res = await fetch('/api/setup/save', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
      });
      console.log('[setup] /api/setup/save status', res.status);
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || `HTTP ${res.status}`);
      }
      const data = await res.json();
      console.log('[setup] handoff data', data);
      this.handoffToTasks(data);
    } catch (err) {
      console.error('[setup] submit error:', err);
      this.showError(err.message || 'Failed to launch install.');
      submitBtn.disabled = false;
      submitBtn.querySelector('.setup-launch-text').textContent = 'Launch Install';
    } finally {
      this._submitting = false;
    }
  }

  handoffToTasks({ firstTaskId, finalizeTaskId, setupGroup, installName, taskIds }) {
    try {
      sessionStorage.setItem('libreportal_setup_handoff', JSON.stringify({
        finalizeTaskId,
        setupGroup,
        installName,
        totalTaskCount: Array.isArray(taskIds) ? taskIds.length : 0,
        startedAt: Date.now()
      }));
    } catch { /* sessionStorage may be unavailable in private mode */ }

    if (typeof this.onComplete === 'function') {
      try { this.onComplete(); } catch (err) { console.error('[setup] onComplete threw:', err); }
    }

    this.container.classList.add('setup-launched');
    setTimeout(() => {
      // Path-based route (the app uses /… URLs); the specific task is still
      // selected via ?task=. Navigate via the SPA helper, with an absolute-path
      // full-load fallback.
      const target = `/tasks/all?task=${encodeURIComponent(firstTaskId)}&from=setup`;
      if (typeof window.navigateToRoute === 'function' && window.spaClean) {
        window.navigateToRoute(target);
        this.hide();
      } else {
        window.location.href = target;
      }
    }, 600);
  }

  // Live per-field validation. Each watched field has a validator that
  // returns null on valid input or an error message string. We mirror that
  // state onto the input as is-valid / is-invalid classes, and the message
  // gets pushed into a data-error attribute that the CSS tooltip reads.
  attachLiveValidation() {
    const validators = {
      'sw-name': (v) => {
        if (!v.trim()) return 'Required';
        if (!/^[a-zA-Z0-9-]+$/.test(v.trim())) return 'Letters, numbers, and hyphens only';
        return null;
      },
      'sw-traefik-email': (v) => {
        const traefikBox = this.container.querySelector('input[data-app="traefik"]');
        if (!traefikBox || !traefikBox.checked) return null; // Only required when Traefik is ticked
        if (!v.trim()) return 'Required';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())) return 'Doesn\'t look like a valid email';
        return null;
      },
      'sw-timezone': (v) => v ? null : 'Pick a timezone'
    };

    Object.entries(validators).forEach(([id, fn]) => {
      const el = this.container.querySelector(`#${id}`);
      if (!el) return;
      // Mirror error state onto the parent .setup-input-row so the CSS
      // tooltip-over-input (which lives on a row pseudo-element) can read
      // it via attr(data-error). Pseudo-elements can only read attrs from
      // their own host, so we have to bubble it up here.
      const row = el.closest('.setup-input-row');
      const run = () => {
        const err = fn(el.value);
        const hasContent = el.value.length > 0;
        el.classList.toggle('is-invalid', !!err && hasContent);
        el.classList.toggle('is-valid', !err && hasContent);
        if (err && hasContent) {
          el.setAttribute('data-error', err);
          row && row.setAttribute('data-error', err);
        } else {
          el.removeAttribute('data-error');
          row && row.removeAttribute('data-error');
        }
      };
      el.addEventListener('input', run);
      el.addEventListener('change', run);
      el.addEventListener('blur', run);
    });
  }

  showError(msg) {
    const errBox = this.container.querySelector('#sw-error');
    errBox.textContent = msg;
    errBox.style.display = '';
  }

  clearError() {
    const errBox = this.container.querySelector('#sw-error');
    errBox.style.display = 'none';
    errBox.textContent = '';
  }

  show() {
    if (this.container) this.container.classList.add('active');
  }

  hide() {
    if (this.container) {
      this.container.classList.add('hiding');
      document.body.classList.remove('setup-wizard-open');
      setTimeout(() => {
        if (this.container && this.container.parentNode) {
          this.container.parentNode.removeChild(this.container);
        }
      }, 500);
    }
  }
}

window.SetupWizard = SetupWizard;
