#!/usr/bin/env python3
"""freezeos_usb_creator — standalone GTK tool to create a FreezeOS install USB
from a Debian-based machine (website-download edition).

Flow (matches the in-OS "Make Install USB" wizard, but self-contained):
  1. Log in with the fleet account (validates + loads the supported-device list).
  2. Pick a supported device (→ that model's perf kernel) or Generic hardware.
  3. Pick a release channel (stable / beta / dev).
  4. Optionally preselect a WiFi network (auto-joined on first boot).
  5. Pick the target USB, then Create — downloads the latest installer and writes
     a proper GPT + FAT32 ESP (hard-drive-style) install USB with a preset
     (freezeos-install.json) that pre-fills the recovery installer's wizard:
     auth_mode=login + username/email/region (the user types their password on
     the target machine — the USB NEVER carries the password or any token),
     wifi (auto-joined by recovery), channel, and the chosen model's payload.
Root operations (partition/format/write) go through pkexec of usb-helper.sh.
The bash launcher (freezeos-usb-creator) installs all dependencies first.

Preset contract (consumed by the recovery installer — keep the key names in
sync with fleet/recovery/freezeos-recovery's _preset reader and
build-recovery-env.sh's recovery-init wifi pre-seed):
  username / email / region / auth_mode / install_mode / desired_channel
  wifi: {ssid, psk}          — singular object, NOT a list
  lightdm_autologin          — read by scripts/install/install-os.sh
  model_fingerprint / payload_file — read by freezeos-hw-payload (on-media path)
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading

TOOL_VERSION = "1.3.1"
TOOL_UPDATE_URL = "https://os.freeze2k.net/downloads/freezeos-usb-creator.tar.gz"
TOOL_MANIFEST_URL = "https://os.freeze2k.net/downloads/freezeos-usb-creator.json"


def _ver_tuple(v):
    try:
        return tuple(int(x) for x in str(v).strip().split("."))
    except Exception:
        return (0,)

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib   # noqa: E402

FLEET_DEFAULT = "https://os.freeze2k.net"       # master: auth + provision create/status
DOWNLOAD_HOST = "https://os.undervision.com"    # dev/CDN: ~10x faster for big downloads
HELPER = os.environ.get("FREEZEOS_USB_HELPER",
                        os.path.join(os.path.dirname(os.path.abspath(__file__)), "usb-helper.sh"))
CACHE = os.path.expanduser("~/.cache/freezeos")


def curl_json(url, method="GET", body=None, timeout=25):
    """Small JSON request via curl (robust TLS on odd Debian boxes).

    NOTE: no `-f` — we WANT the JSON error body on 4xx/5xx (e.g. "invalid
    credentials") instead of curl swallowing it and reporting a generic network
    error. Only a real transport failure (rc != 0) is a 'network error'."""
    cmd = ["curl", "-sS", "--max-time", str(timeout), "-X", method,
           "-H", "Content-Type: application/json", url]
    if body is not None:
        cmd[cmd.index(url):cmd.index(url)] = ["--data-binary", "@-"]
    try:
        r = subprocess.run(cmd, input=(json.dumps(body) if body is not None else None),
                           capture_output=True, text=True, timeout=timeout + 5)
        if r.returncode != 0:
            return None, "network error (rc=%d) %s" % (r.returncode, (r.stderr or "").strip()[:140])
        try:
            d = json.loads(r.stdout)
        except ValueError:
            return None, "unexpected server response: %s" % (r.stdout or "(empty)").strip()[:140]
        if not d.get("ok", True):
            return None, d.get("error", "request failed")
        return d, None
    except Exception as e:
        return None, str(e)


class App(Gtk.Window):
    def __init__(self):
        super().__init__(title="Create FreezeOS Install USB — v%s" % TOOL_VERSION)
        # Small default height + scroll so it fits any monitor; the action bar is
        # pinned outside the scroll so Create is never off-screen.
        self.set_default_size(600, 520)
        self.set_border_width(12)
        self.user_id = None
        self.account = {}      # {username, email, region} from login — preset prefill
        self.models = []
        self.iso_path = None
        self._cancel = threading.Event()   # set by Cancel; work threads honor it
        self._proc = None                  # current helper subprocess (to kill)
        self._job_id = None                # current server provision job (to cancel)

        root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.add(root)
        scroll = Gtk.ScrolledWindow()
        scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroll.set_vexpand(True)
        root.pack_start(scroll, True, True, 0)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        outer.set_margin_end(10)
        scroll.add(outer)

        title = Gtk.Label()
        title.set_markup("<big><b>Create FreezeOS Install USB</b></big>")
        title.set_halign(Gtk.Align.START)
        outer.pack_start(title, False, False, 0)

        # 0 ─ Mode
        outer.pack_start(self._section("What to make"), False, False, 0)
        self.mode_portable = Gtk.RadioButton.new_with_label_from_widget(
            None, "Install FreezeOS ONTO the USB — portable, boots as a real FreezeOS (needs ≥32 GB)")
        self.mode_installer = Gtk.RadioButton.new_with_label_from_widget(
            self.mode_portable, "Make an installer USB — boots an installer that installs FreezeOS to a PC's disk")
        for _rb in (self.mode_portable, self.mode_installer):
            _l = _rb.get_child()
            if isinstance(_l, Gtk.Label):
                _l.set_line_wrap(True); _l.set_max_width_chars(52)
        outer.pack_start(self.mode_portable, False, False, 0)
        outer.pack_start(self.mode_installer, False, False, 0)

        # 1 ─ Account
        outer.pack_start(self._section("1 · Fleet account"), False, False, 0)
        self.e_user = self._entry("", "email or username")
        self.e_pass = self._entry("", "password"); self.e_pass.set_visibility(False)
        outer.pack_start(self._row("Account", self.e_user), False, False, 0)
        outer.pack_start(self._row("Password", self.e_pass), False, False, 0)
        self.btn_login = Gtk.Button(label="Log in")
        self.btn_login.connect("clicked", self.on_login)
        outer.pack_start(self.btn_login, False, False, 0)

        # 2 ─ Device
        outer.pack_start(self._section("2 · Device (uses that model's perf kernel)"), False, False, 0)
        self.cb_model = Gtk.ComboBoxText()
        self.cb_model.append("generic", "Generic / other hardware (stock kernel)")
        self.cb_model.set_active_id("generic")
        self.cb_model.set_sensitive(False)
        outer.pack_start(self.cb_model, False, False, 0)

        # 3 ─ Channel
        outer.pack_start(self._section("3 · Release channel"), False, False, 0)
        self.cb_chan = Gtk.ComboBoxText()
        for c in ("stable", "beta", "dev"):
            self.cb_chan.append(c, c)
        self.cb_chan.set_active_id("stable")
        outer.pack_start(self.cb_chan, False, False, 0)

        # 4 ─ WiFi
        outer.pack_start(self._section("4 · WiFi (optional — auto-joins on first boot)"), False, False, 0)
        self.e_ssid = self._entry("", "network name (blank = skip)")
        self.e_psk = self._entry("", "password"); self.e_psk.set_visibility(False)
        outer.pack_start(self._row("SSID", self.e_ssid), False, False, 0)
        outer.pack_start(self._row("Passphrase", self.e_psk), False, False, 0)

        # Options
        outer.pack_start(self._section("Options"), False, False, 0)
        self.chk_autologin = Gtk.CheckButton(
            label="Auto-login without password (LightDM)")
        outer.pack_start(self.chk_autologin, False, False, 0)

        # 5 ─ Target USB
        outer.pack_start(self._section("5 · Target USB drive"), False, False, 0)
        usb_row = Gtk.Box(spacing=8)
        self.cb_usb = Gtk.ComboBoxText()
        usb_row.pack_start(self.cb_usb, True, True, 0)
        btn_refresh = Gtk.Button(label="↻")
        btn_refresh.connect("clicked", lambda _b: self.refresh_usbs())
        usb_row.pack_start(btn_refresh, False, False, 0)
        outer.pack_start(usb_row, False, False, 0)

        # ── Pinned bottom bar (outside the scroll, always visible) ──────
        root.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
        act = Gtk.Box(spacing=8)
        self.btn_update = Gtk.Button(label="Update tool")
        self.btn_update.connect("clicked", self.on_update)
        act.pack_start(self.btn_update, False, False, 0)
        self.btn_create = Gtk.Button(label="Create USB")
        self.btn_create.get_style_context().add_class("suggested-action")
        self.btn_create.connect("clicked", self.on_create)
        act.pack_end(self.btn_create, False, False, 0)
        self.btn_cancel = Gtk.Button(label="Cancel")
        self.btn_cancel.get_style_context().add_class("destructive-action")
        self.btn_cancel.set_sensitive(False)
        self.btn_cancel.connect("clicked", self.on_cancel)
        act.pack_end(self.btn_cancel, False, False, 0)
        root.pack_start(act, False, False, 0)

        self.bar = Gtk.ProgressBar(show_text=True)
        root.pack_start(self.bar, False, False, 0)
        self.status = Gtk.Label(label="Log in to load your supported devices.")
        self.status.set_halign(Gtk.Align.START); self.status.set_line_wrap(True)
        root.pack_start(self.status, False, False, 0)

        self.refresh_usbs()

    # ── small UI helpers ────────────────────────────────────────────
    def _section(self, t):
        l = Gtk.Label(); l.set_markup("<b>%s</b>" % GLib.markup_escape_text(t))
        l.set_halign(Gtk.Align.START); l.set_margin_top(6); return l

    def _entry(self, val="", ph=""):
        e = Gtk.Entry(); e.set_text(val)
        if ph: e.set_placeholder_text(ph)
        return e

    def _row(self, label, widget):
        r = Gtk.Box(spacing=8)
        l = Gtk.Label(label=label); l.set_size_request(90, -1); l.set_halign(Gtk.Align.START)
        r.pack_start(l, False, False, 0); r.pack_start(widget, True, True, 0)
        return r

    def _set_status(self, msg):
        GLib.idle_add(self.status.set_text, msg)

    def _set_prog(self, frac, text):
        GLib.idle_add(self.bar.set_fraction, max(0.0, min(1.0, frac)))
        GLib.idle_add(self.bar.set_text, text)

    # ── USB enumeration ─────────────────────────────────────────────
    def refresh_usbs(self):
        # Use lsblk's key="value" pairs output so a MODEL/vendor string with
        # spaces (e.g. "SanDisk Ultra") can't shift the columns. Treat a disk as
        # a removable target if RM=1 OR HOTPLUG=1 OR TRAN=usb (covers sticks, SD
        # readers, and USB SSD/NVMe enclosures that report RM=0).
        import shlex
        self.cb_usb.remove_all()
        try:
            out = subprocess.check_output(
                ["lsblk", "-dnP", "-o", "NAME,PATH,SIZE,TYPE,RM,HOTPLUG,TRAN,MODEL"],
                text=True)
        except Exception:
            out = ""
        found = 0
        for line in out.splitlines():
            d = {}
            try:
                for tok in shlex.split(line):
                    if "=" in tok:
                        k, v = tok.split("=", 1)
                        d[k] = v
            except ValueError:
                continue
            if d.get("TYPE") != "disk":
                continue
            if not (d.get("RM") == "1" or d.get("HOTPLUG") == "1" or d.get("TRAN") == "usb"):
                continue
            path = d.get("PATH") or ("/dev/" + d.get("NAME", ""))
            model = (d.get("MODEL") or "").strip()
            self.cb_usb.append(path, "%s  (%s %s)" % (path, d.get("SIZE", ""), model))
            found += 1
        if found:
            self.cb_usb.set_active(0)
        else:
            self.cb_usb.append("", "no removable USB detected — insert one and press ↻")
            self.cb_usb.set_active(0)

    # ── 1. Login → validate + load device list ──────────────────────
    def on_login(self, _btn):
        url = FLEET_DEFAULT
        user = self.e_user.get_text().strip()
        pw = self.e_pass.get_text()
        if not user or not pw:
            self._set_status("Enter your fleet account and password.")
            return
        self.btn_login.set_sensitive(False)
        self._set_status("Logging in…")

        def work():
            d, err = curl_json(url + "/api/fleet/install/supported-models", "POST",
                               {"username": user, "password": pw})
            def done():
                self.btn_login.set_sensitive(True)
                if err:
                    self._set_status("Login failed: %s" % err); return
                self.user_id = d.get("user_id")
                # Canonical identity for the install preset. Older fleet
                # servers don't return these — fall back to what was typed.
                typed = self.e_user.get_text().strip()
                self.account = {
                    "username": d.get("username") or ("" if "@" in typed else typed),
                    "email": d.get("email") or (typed if "@" in typed else ""),
                    "region": d.get("region") or "",
                }
                self.models = d.get("models", [])
                self.cb_model.remove_all()
                self.cb_model.append("generic", "Generic / other hardware (stock kernel)")
                for m in self.models:
                    label = ("%s %s" % (m.get("vendor") or "", m.get("model") or "")).strip()
                    self.cb_model.append(str(m["fingerprint_id"]),
                                         "%s  ·  %s" % (label, m.get("kernel_version", "perf")))
                self.cb_model.set_active_id("generic")
                self.cb_model.set_sensitive(True)
                self._set_status("Logged in. %d supported device(s) available." % len(self.models))
            GLib.idle_add(done)
        threading.Thread(target=work, daemon=True).start()

    # ── installer download (Update button + used by Create) ─────────
    def _dl_base(self, path):
        """Prefer the fast dev CDN when it actually has this file, else prod."""
        try:
            r = subprocess.run(["curl", "-fsSI", "--max-time", "10", DOWNLOAD_HOST + path],
                               capture_output=True, text=True)
            if r.returncode == 0:
                return DOWNLOAD_HOST
        except Exception:
            pass
        return FLEET_DEFAULT

    def _download_installer(self, url, force=False):
        meta, err = curl_json(url + "/api/fleet/install/latest")
        if err:
            return None, "cannot reach fleet: %s" % err
        ver = meta.get("version", "latest")
        dl = meta.get("download_url") or ("/downloads/freezeos-install-%s.iso" % ver)
        # Resilience (2026-08-16): the endpoint reports a version, but the
        # download dir may carry only the -latest symlink — a missing
        # versioned symlink 404'd every download. If neither host actually
        # serves the versioned path, fall back to -latest.
        def _head_ok(u):
            try:
                return subprocess.run(["curl", "-fsSI", "--max-time", "10", u],
                                      capture_output=True).returncode == 0
            except Exception:
                return False
        if not (_head_ok(DOWNLOAD_HOST + dl) or _head_ok(FLEET_DEFAULT + dl)):
            dl = "/downloads/freezeos-install-latest.iso"
        dest = os.path.join(CACHE, "freezeos-install-%s.iso" % ver)
        if os.path.isfile(dest) and not force and meta.get("size_bytes"):
            if os.path.getsize(dest) == meta["size_bytes"]:
                return dest, None
        os.makedirs(CACHE, exist_ok=True)
        p = subprocess.Popen(["bash", HELPER, "download", self._dl_base(dl) + dl, dest,
                              meta.get("sha256") or ""],
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        for line in p.stdout:
            line = line.strip()
            if line.startswith("PROGRESS:"):
                try:
                    n, msg = line[len("PROGRESS:"):].split(" ", 1)
                    self._set_prog(int(n) / 100.0, msg)
                except ValueError:
                    pass
            elif line.startswith("ERROR:"):
                p.wait(); return None, line[len("ERROR:"):]
        p.wait()
        if p.returncode != 0 or not os.path.isfile(dest):
            return None, "installer download failed"
        return dest, None

    def on_update(self, _btn):
        # Self-update: check the version manifest; if a newer tool is published,
        # download + verify + replace this tool's files, then relaunch. (The OS
        # installer ISO is fetched automatically during Create, not here.)
        self.btn_update.set_sensitive(False)
        self._set_status("Checking for a newer version…")

        def work():
            meta, err = curl_json(TOOL_MANIFEST_URL)
            if err:
                return GLib.idle_add(self._upd_done, False, "cannot check for updates: %s" % err)
            latest = str(meta.get("version", "0"))
            if _ver_tuple(latest) <= _ver_tuple(TOOL_VERSION):
                return GLib.idle_add(self._upd_done, False,
                                     "You're on the latest version (v%s)." % TOOL_VERSION)
            url = meta.get("url") or TOOL_UPDATE_URL
            sha = meta.get("sha256", "")
            tmp = tempfile.mkdtemp(prefix="fzuc-upd-")
            try:
                tgz = os.path.join(tmp, "tool.tar.gz")
                self._set_status("Downloading v%s…" % latest)
                r = subprocess.run(["curl", "-fLsS", "--max-time", "120", "-o", tgz, url],
                                   capture_output=True, text=True)
                if r.returncode != 0:
                    return GLib.idle_add(self._upd_done, False, "download failed")
                if sha:
                    got = subprocess.run(["sha256sum", tgz], capture_output=True, text=True).stdout.split()[0]
                    if got != sha:
                        return GLib.idle_add(self._upd_done, False, "checksum mismatch — aborted")
                with tarfile.open(tgz) as t:
                    t.extractall(tmp)
                newdir = os.path.join(tmp, "freezeos-usb-creator")
                appdir = os.path.dirname(os.path.abspath(__file__))
                if not os.path.isdir(newdir):
                    return GLib.idle_add(self._upd_done, False, "unexpected archive layout")
                for fn in os.listdir(newdir):
                    shutil.copy2(os.path.join(newdir, fn), os.path.join(appdir, fn))
                    if fn in ("freezeos-usb-creator", "usb-helper.sh", "freezeos_usb_creator.py"):
                        os.chmod(os.path.join(appdir, fn), 0o755)
            except Exception as e:
                return GLib.idle_add(self._upd_done, False, "update failed: %s" % e)
            finally:
                shutil.rmtree(tmp, ignore_errors=True)
            GLib.idle_add(self._upd_done, True, latest)
        threading.Thread(target=work, daemon=True).start()

    def _upd_done(self, ok, info):
        self.btn_update.set_sensitive(True)
        if not ok:
            self._set_status(info)
            return
        self._set_status("Updated to v%s — restarting…" % info)
        GLib.timeout_add(800, lambda: os.execv(sys.executable, [sys.executable] + sys.argv))

    # ── 5. Create ────────────────────────────────────────────────────
    def _stream_helper(self, args):
        """Run a (pkexec) helper command, driving the progress bar off its
        PROGRESS:/LOG:/ERROR: markers. Returns (ok, err)."""
        try:
            p = subprocess.Popen(args, stdout=subprocess.PIPE,
                                 stderr=subprocess.STDOUT, text=True)
        except Exception as e:
            return False, str(e)
        self._proc = p
        failed = None
        for line in p.stdout:
            line = line.strip()
            if line.startswith("PROGRESS:"):
                try:
                    n, msg = line[len("PROGRESS:"):].split(" ", 1)
                    self._set_prog(int(n) / 100.0, msg)
                except ValueError:
                    pass
            elif line.startswith("ERROR:"):
                failed = line[len("ERROR:"):]
            elif line.startswith("LOG:"):
                self._set_status(line[len("LOG:"):])
        p.wait()
        self._proc = None
        if self._cancel.is_set():
            return False, "canceled"
        if p.returncode != 0 or failed:
            return False, failed or ("helper exited %d (authorization cancelled?)" % p.returncode)
        return True, None

    def on_create(self, _btn):
        dev = self.cb_usb.get_active_id()
        if not self.user_id:
            self._set_status("Log in first."); return
        if not dev:
            self._set_status("Select a target USB drive."); return
        portable = self.mode_portable.get_active()
        warn = Gtk.MessageDialog(transient_for=self, modal=True,
                                 message_type=Gtk.MessageType.WARNING,
                                 buttons=Gtk.ButtonsType.OK_CANCEL,
                                 text=("Erase %s and install FreezeOS onto it?" if portable
                                       else "Erase %s and write the FreezeOS installer?") % dev)
        warn.format_secondary_text("ALL DATA on %s will be destroyed." % dev)
        resp = warn.run(); warn.destroy()
        if resp != Gtk.ResponseType.OK:
            return

        model_id = self.cb_model.get_active_id()
        fingerprint_id = None if model_id == "generic" else int(model_id)
        channel = self.cb_chan.get_active_id() or "stable"
        self._cancel.clear(); self._job_id = None
        self.btn_create.set_sensitive(False)
        self.btn_cancel.set_sensitive(True)
        target = self._make_portable if portable else self._make_installer
        threading.Thread(target=target, args=(dev, fingerprint_id, model_id, channel),
                         daemon=True).start()

    def on_cancel(self, _btn):
        self._cancel.set()
        self.btn_cancel.set_sensitive(False)
        self._set_status("Canceling…")
        p = self._proc
        if p is not None:
            try:
                p.terminate()
            except Exception:
                pass
        jid = self._job_id
        if jid is not None:
            def _srv():
                curl_json(FLEET_DEFAULT + "/api/fleet/install/provision-cancel", "POST",
                          {"username": self.e_user.get_text().strip(),
                           "password": self.e_pass.get_text(), "job_id": jid})
            threading.Thread(target=_srv, daemon=True).start()

    # ── Portable: install FreezeOS ONTO the USB (provision flow) ─────
    def _make_portable(self, dev, fingerprint_id, model_id, channel):
        import time as _t
        url = FLEET_DEFAULT
        user = self.e_user.get_text().strip()
        pw = self.e_pass.get_text()
        try:
            size = int(subprocess.check_output(["lsblk", "-dnbo", "SIZE", dev], text=True).strip())
        except Exception:
            size = 0
        self._set_status("Requesting your personalized FreezeOS build…")
        d, err = curl_json(url + "/api/fleet/install/provision-usb", "POST",
                           {"username": user, "password": pw, "fingerprint_id": fingerprint_id,
                            "channel": channel, "usb_size_bytes": size})
        if err:
            return GLib.idle_add(self._fail, "provision request failed: %s" % err)
        job_id = d.get("job_id")
        self._job_id = job_id
        dl = sha = None; raw = 0
        while True:
            if self._cancel.is_set():
                return GLib.idle_add(self._fail, "canceled")
            _t.sleep(6)
            s, err = curl_json(url + "/api/fleet/install/provision-status", "POST",
                               {"username": user, "password": pw, "job_id": job_id})
            if err:
                return GLib.idle_add(self._fail, "status check failed: %s" % err)
            st = s.get("status")
            self._set_prog((s.get("progress", 0) or 0) / 100.0,
                           "Building on fleet: %s" % (s.get("progress_label") or st))
            if st in ("failed", "expired", "canceled"):
                return GLib.idle_add(self._fail, "build %s" % st)
            if st == "done" and s.get("download_url"):
                # via prod (validates the token) → 302 to the fast static file on
                # the CDN box; curl -L follows it.
                dl = FLEET_DEFAULT + s["download_url"]; sha = s.get("artifact_sha256")
                raw = int(s.get("raw_size_bytes") or 0)
                break
        os.makedirs(CACHE, exist_ok=True)
        imggz = os.path.join(CACHE, "freezeos-usb-%s.img.gz" % job_id)
        self._set_status("Downloading personalized image…")
        ok, err = self._stream_helper(["bash", HELPER, "download", dl, imggz])
        if not ok:
            return GLib.idle_add(self._fail, err or "download failed")
        if sha:
            got = subprocess.run(["sha256sum", imggz], capture_output=True, text=True).stdout.split()[0]
            if got != sha:
                return GLib.idle_add(self._fail, "image checksum mismatch — aborted")
        self._set_status("Writing FreezeOS to %s (admin authorization)…" % dev)
        ok, err = self._stream_helper(["pkexec", "bash", HELPER, "write-image", imggz, dev, str(raw)])
        if not ok:
            return GLib.idle_add(self._fail, err or "write failed")

        def done():
            self.btn_create.set_sensitive(True); self.btn_cancel.set_sensitive(False); self._set_prog(1.0, "Done")
            self._set_status("✓ FreezeOS installed to %s — eject and boot the target machine from it." % dev)
            self._secureboot_note()
        GLib.idle_add(done)

    # ── Installer: make a USB that installs FreezeOS to a PC's disk ──
    def _make_installer(self, dev, fingerprint_id, model_id, channel):
        url = FLEET_DEFAULT
        model = None
        if fingerprint_id is not None:
            model = next((m for m in self.models
                          if str(m["fingerprint_id"]) == model_id), None)
        # Preset JSON — the recovery installer on the target machine reads
        # this from the USB root to pre-fill its wizard (auth_mode=login →
        # the user only types their password there; the USB never carries
        # the password or any token — a baked token would be expired and
        # invalid by first boot anyway).
        preset = {
            "version": 1,
            "source": "usb-creator",
            "auth_mode": "login",
            "install_mode": "to_disk",
            "desired_channel": channel,
            "username": self.account.get("username") or "",
            "email": self.account.get("email") or "",
            "region": self.account.get("region") or "",
            "lightdm_autologin": self.chk_autologin.get_active(),
        }
        ssid = self.e_ssid.get_text().strip()
        if ssid:
            # Singular `wifi` object is what recovery-init auto-connects;
            # keep the list form too for forward compatibility.
            preset["wifi"] = {"ssid": ssid, "psk": self.e_psk.get_text()}
            preset["wifi_networks"] = [preset["wifi"]]

        # Per-device payload (driver/firmware fixes) baked on-media so the
        # install works offline — mirrors install-usb.ps1's Step 8c.
        extras_dir = None
        fp_hash = (model or {}).get("fingerprint_hash")
        if fp_hash:
            preset["model_fingerprint"] = fp_hash
            self._set_status("Checking for a per-device payload…")
            pj, perr = curl_json(url + "/api/fleet/device-payload/fp/%s" % fp_hash)
            payload = (pj or {}).get("payload") or {}
            purl = payload.get("download_url")
            if not perr and purl:
                if not purl.startswith("http"):
                    purl = url + purl
                extras_dir = tempfile.mkdtemp(prefix="fzuc-extras-")
                pdir = os.path.join(extras_dir, "boot", "freezeos", "payload")
                os.makedirs(pdir, exist_ok=True)
                pfile = "freezeos-payload-%s.tar.gz" % fp_hash
                ok, derr = self._stream_helper(
                    ["bash", HELPER, "download", purl, os.path.join(pdir, pfile),
                     payload.get("sha256") or ""])
                if ok:
                    preset["payload_file"] = pfile
                else:
                    # Payload is an optimization — never block the USB build.
                    self._set_status("Payload fetch failed (%s) — device will "
                                     "fetch it over the network instead." % derr)
                    shutil.rmtree(extras_dir, ignore_errors=True)
                    extras_dir = None

        self._set_status("Downloading installer…")
        iso, err = self._download_installer(url)
        if err:
            return GLib.idle_add(self._fail, err)
        os.makedirs(CACHE, exist_ok=True)
        preset_path = os.path.join(CACHE, "freezeos-install.json")
        with open(preset_path, "w") as f:
            json.dump(preset, f, indent=2)
        self._set_status("Writing installer USB (admin authorization)…")
        args = ["pkexec", "bash", HELPER, "write-usb", dev, iso, preset_path]
        if extras_dir:
            args.append(extras_dir)
        ok, err = self._stream_helper(args)
        if extras_dir:
            shutil.rmtree(extras_dir, ignore_errors=True)
        if not ok:
            return GLib.idle_add(self._fail, err or "write failed")

        def done():
            self.btn_create.set_sensitive(True); self.btn_cancel.set_sensitive(False); self._set_prog(1.0, "Done")
            self._set_status("✓ Installer USB ready on %s — boot the PC from it; "
                             "your account is pre-filled, you'll just enter your "
                             "fleet password there." % dev)
            self._secureboot_note()
        GLib.idle_add(done)

    def _secureboot_note(self):
        """First-boot Secure Boot guidance. The written USB is signed with the
        FreezeOS MOK and ships MokManager + the certs on its ESP, so on a machine
        with Secure Boot ON the very first boot lands in MOK enrollment."""
        msg = (
            "This USB is signed for Secure Boot and carries the FreezeOS key + "
            "MOK Manager on it.\n\n"
            "On the target machine's FIRST boot with Secure Boot ON, you'll see "
            "the blue “Shim UEFI key management” screen (MOK Manager):\n"
            "   1. Enroll MOK  →  Continue  →  Yes\n"
            "   2. If asked for a key, pick the FreezeOS key under /certs "
            "(freezeos-mok.cer)\n"
            "   3. Reboot — FreezeOS then boots normally.\n\n"
            "You can re-open it any time from the boot menu entry "
            "“Enroll / Manage Secure Boot Key (MOK Manager)”.\n\n"
            "If the machine has Secure Boot OFF, it just boots — nothing to do."
        )
        d = Gtk.MessageDialog(transient_for=self, modal=True,
                              message_type=Gtk.MessageType.INFO,
                              buttons=Gtk.ButtonsType.OK,
                              text="✓ Done — Secure Boot first-boot steps")
        d.format_secondary_text(msg)
        d.run(); d.destroy()

    def _fail(self, msg):
        self.btn_create.set_sensitive(True)
        self.btn_cancel.set_sensitive(False)
        self._job_id = None
        if self._cancel.is_set() or msg == "canceled":
            self._set_status("Canceled."); self._set_prog(0.0, "")
        else:
            self._set_status("Failed: %s" % msg)


def main():
    w = App()
    w.connect("destroy", Gtk.main_quit)
    w.show_all()
    Gtk.main()


if __name__ == "__main__":
    main()
