#!/usr/bin/python3
"""Moodtop OS disk installer (root). Replaces Calamares.

  mood-installer scan                 -> JSON: disks, firmware mode, live/persistence state
  mood-installer run <config.json>    -> install (started by `mood-admin install-start` as the
                                         transient systemd unit `mood-install`, so it survives the UI)

Progress: /run/mood-install/progress.json (world-readable), log: /run/mood-install/install.log,
copied to /var/log/mood-install.log on the new system.

Layouts
  erase: GPT = 1 MiB bios_grub + 512 MiB EFI (FAT32) + ext4 root "Moodtop" (rest of the disk)
  free : UEFI + GPT only; new ext4 root in the largest unallocated region, existing EFI partition reused
Source: the live squashfs (clean system) or, with keepStuff on a persistent live USB, the running
system itself (apps, files and settings saved on the USB; the student account becomes the new account).
Bootloader: the GRUB package for this firmware mode comes from the ISO's offline pool (/pool), then
grub-install (shim + Debian-signed GRUB on UEFI, so Secure Boot keeps working).
"""
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

STATE = Path("/run/mood-install")
PROGRESS = STATE / "progress.json"
LOG = STATE / "install.log"
TARGET = STATE / "target"
SQUASH_MNT = STATE / "squash"
MEDIUM = Path("/run/live/medium")
MIN_BYTES = 16 << 30
MIB = 1 << 20
ESP_TYPE = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
BIOS_TYPE = "21686148-6449-6e6f-744e-656564454649"
ROOT_TYPE = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"
MS_DATA = "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7"
LIVE_PKGS = ["live-boot", "live-boot-initramfs-tools", "live-boot-doc", "live-tools", "live-config",
             "live-config-systemd", "calamares", "calamares-settings-debian"]
GROUPS = ["sudo", "audio", "video", "render", "input", "plugdev", "netdev", "bluetooth", "users", "lpadmin", "scanner", "cdrom", "dip"]
RESERVED = {"root", "student", "daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp", "proxy",
            "www-data", "backup", "list", "irc", "nobody", "messagebus", "polkitd", "admin", "user", "sudo", "adm", "wheel",
            "operator", "staff", "shadow", "utmp", "tty", "disk", "kmem", "dialout", "video", "audio", "input", "render",
            "plugdev", "netdev", "users", "_greetd", "avahi", "colord", "rtkit", "pulse", "saned", "sshd", "tss"}
KEYBOARDS = [
    ("gb", "", "English (UK)"), ("us", "", "English (US)"), ("us", "intl", "English (US, international)"),
    ("ie", "", "English (Ireland)"), ("au", "", "English (Australia)"), ("ca", "", "English (Canada)"),
    ("fr", "", "French"), ("ca", "fr", "French (Canada)"), ("be", "", "Belgian"), ("ch", "", "Swiss German"),
    ("ch", "fr", "Swiss French"), ("de", "", "German"), ("at", "", "German (Austria)"), ("es", "", "Spanish"),
    ("latam", "", "Spanish (Latin America)"), ("pt", "", "Portuguese"), ("br", "", "Portuguese (Brazil)"),
    ("it", "", "Italian"), ("nl", "", "Dutch"), ("dk", "", "Danish"), ("no", "", "Norwegian"), ("se", "", "Swedish"),
    ("fi", "", "Finnish"), ("is", "", "Icelandic"), ("pl", "", "Polish"), ("cz", "", "Czech"), ("sk", "", "Slovak"),
    ("hu", "", "Hungarian"), ("ro", "", "Romanian"), ("gr", "", "Greek"), ("tr", "", "Turkish"), ("ru", "", "Russian"),
    ("ua", "", "Ukrainian"), ("il", "", "Hebrew"), ("ara", "", "Arabic"), ("in", "eng", "Indian (English)"),
    ("jp", "", "Japanese"), ("kr", "", "Korean"), ("cn", "", "Chinese"), ("th", "", "Thai"), ("vn", "", "Vietnamese"),
    ("us", "dvorak", "English (Dvorak)"), ("us", "colemak", "English (Colemak)"),
]


# ------------------------------------------------------------------ helpers
def log(msg):
    STATE.mkdir(mode=0o755, exist_ok=True)
    with open(LOG, "a") as f:
        f.write(time.strftime("%H:%M:%S ") + msg.rstrip() + "\n")


def tail(n=40):
    try:
        return "\n".join(LOG.read_text(errors="replace").splitlines()[-n:])
    except OSError:
        return ""


_last = {}


def progress(state, stage, label, pct, detail="", error=""):
    _last.update(state=state, stage=stage, label=label, pct=round(pct, 1), detail=detail, error=error)
    data = dict(_last, t=time.time(), logTail=tail())
    STATE.mkdir(mode=0o755, exist_ok=True)
    tmp = STATE / ".progress.tmp"
    tmp.write_text(json.dumps(data))
    os.chmod(tmp, 0o644)
    os.replace(tmp, PROGRESS)


class Fail(Exception):
    pass


def sh(cmd, check=True, input=None, timeout=3600, env=None, quiet=False):
    if not quiet:
        log("$ " + " ".join(str(c) for c in cmd))
    r = subprocess.run([str(c) for c in cmd], capture_output=True, text=True, input=input, timeout=timeout, env=env)
    out = (r.stdout + r.stderr).strip()
    if out and not quiet:
        log(out[-3000:])
    if check and r.returncode != 0:
        raise Fail(f"{Path(str(cmd[0])).name} failed ({r.returncode}): {out[-300:]}")
    return r


def lsblk_tree():
    out = subprocess.run(["lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,TRAN,RM,RO,MODEL,VENDOR,PTTYPE,FSTYPE,LABEL,PARTTYPE,PARTLABEL,MOUNTPOINTS"],
                         capture_output=True, text=True).stdout
    return json.loads(out).get("blockdevices", [])


def ancestors(dev):
    """All whole disks underneath a device (partition, dm/ventoy mapping, loop...)."""
    r = subprocess.run(["lsblk", "-nspo", "PATH,TYPE", dev], capture_output=True, text=True)
    return {l.split()[0] for l in r.stdout.splitlines() if len(l.split()) == 2 and l.split()[1] == "disk"}


def live_disks():
    disks = set()
    try:
        for line in Path("/proc/mounts").read_text().splitlines():
            src, mnt = line.split()[:2]
            if (mnt.startswith("/run/live") or mnt == "/lib/live/mount/medium") and src.startswith("/dev/"):
                disks |= ancestors(src)
    except OSError:
        pass
    for extra in ("/dev/mapper/ventoy", "/dev/mapper/vtoy_persistent"):
        if Path(extra).exists():
            disks |= ancestors(extra)
    return disks


def is_live():
    return "boot=live" in Path("/proc/cmdline").read_text()


def has_persistence():
    return "/run/live/persistence" in Path("/proc/mounts").read_text()


def free_regions(disk):
    """[(start, end)] of unallocated space in bytes (parted, 1 MiB aligned)."""
    r = subprocess.run(["parted", "-sm", disk, "unit", "B", "print", "free"], capture_output=True, text=True)
    regions = []
    for line in r.stdout.splitlines():
        f = line.rstrip(";").split(":")
        if len(f) >= 5 and f[4] == "free":
            s, e = int(f[1].rstrip("B")), int(f[2].rstrip("B"))
            s = (s + MIB - 1) // MIB * MIB
            e = (e + 1) // MIB * MIB
            if e - s >= 64 * MIB:
                regions.append((s, e))
    return regions


def detect_os(p):
    fs, pt, lab = (p.get("fstype") or ""), (p.get("parttype") or "").lower(), (p.get("label") or p.get("partlabel") or "")
    if fs == "ntfs" and pt in ("", MS_DATA, "0x7") and "recovery" not in lab.lower() and (p.get("size") or 0) > 8 << 30:
        return "Windows"
    if fs in ("apfs", "hfsplus"):
        return "macOS"
    if fs in ("ext4", "btrfs", "xfs", "f2fs") and (p.get("size") or 0) > 6 << 30:
        return "Moodtop" if lab == "Moodtop" else "Linux"
    return ""


def source_bytes():
    for p in (MEDIUM / "live/filesystem.size",):
        try:
            return int(p.read_text().split()[0])
        except (OSError, ValueError, IndexError):
            pass
    return 5_500_000_000


def scan():
    live = is_live()
    uefi = Path("/sys/firmware/efi").is_dir()
    busy = live_disks()
    disks = []
    for d in lsblk_tree():
        if d.get("type") != "disk" or d.get("ro") or not d.get("size") or re.match(r"^(zram|loop|sr|fd|ram)", d["name"]):
            continue
        size = int(d["size"])
        parts = []
        oses = []
        has_esp = False
        for p in d.get("children") or []:
            if p.get("type") != "part":
                continue
            o = detect_os(p)
            if o and o not in oses:
                oses.append(o)
            if (p.get("parttype") or "").lower() == ESP_TYPE:
                has_esp = True
            parts.append({"path": p["path"], "fs": p.get("fstype") or "", "label": p.get("label") or p.get("partlabel") or "",
                          "sizeBytes": int(p.get("size") or 0), "os": o})
        pttype = d.get("pttype") or ""
        regions = free_regions(d["path"]) if pttype else [(MIB, size - MIB)]
        free = max((e - s for s, e in regions), default=0)
        live_medium = d["path"] in busy
        can_erase, why_e = True, ""
        if live_medium:
            can_erase, why_e = False, "This is the USB Moodtop is running from"
        elif size < MIN_BYTES:
            can_erase, why_e = False, f"Too small — Moodtop needs at least {MIN_BYTES >> 30} GB"
        can_free, why_f = True, ""
        if live_medium:
            can_free, why_f = False, "This is the USB Moodtop is running from"
        elif not uefi:
            can_free, why_f = False, "Installing alongside another system needs a UEFI computer"
        elif pttype != "gpt":
            can_free, why_f = False, "This disk doesn't use a GPT partition table"
        elif not has_esp:
            can_free, why_f = False, "No EFI system partition on this disk"
        elif free < MIN_BYTES:
            can_free, why_f = False, f"Not enough free space ({free / 1e9:.1f} GB unallocated, needs {MIN_BYTES / 1e9:.0f} GB). Shrink a partition first (e.g. in Windows Disk Management)."
        disks.append({"path": d["path"], "name": d["name"], "model": " ".join(filter(None, [(d.get("vendor") or "").strip(), (d.get("model") or "").strip()])) or d["name"],
                      "sizeBytes": size, "tran": d.get("tran") or ("virtio" if d["name"].startswith("vd") else ""), "removable": bool(d.get("rm")),
                      "liveMedium": live_medium, "pttype": pttype, "canErase": can_erase, "eraseWhyNot": why_e,
                      "canFree": can_free, "freeWhyNot": why_f, "freeBytes": free, "oses": oses, "partitions": parts})
    disks.sort(key=lambda x: (x["liveMedium"], x["removable"], -x["sizeBytes"]))
    tz = "Europe/London"
    try:
        tz = os.path.realpath("/etc/localtime").split("/zoneinfo/", 1)[1]
    except IndexError:
        pass
    kb = {"layout": "gb", "variant": ""}
    try:
        t = Path("/etc/default/keyboard").read_text()
        kb = {"layout": (re.search(r'XKBLAYOUT="([^"]*)"', t) or [None, "gb"])[1].split(",")[0],
              "variant": (re.search(r'XKBVARIANT="([^"]*)"', t) or [None, ""])[1].split(",")[0]}
    except OSError:
        pass
    mem = 0
    try:
        mem = int(re.search(r"MemTotal:\s+(\d+)", Path("/proc/meminfo").read_text())[1]) * 1024
    except (OSError, TypeError):
        pass
    st = {}
    try:
        st = json.loads(PROGRESS.read_text())
    except (OSError, ValueError):
        pass
    state = st.get("state", "idle")
    if state == "running" and subprocess.run(["systemctl", "is-active", "--quiet", "mood-install"]).returncode != 0:
        state = "failed"
    user = "student"
    return {"live": live, "uefi": uefi, "persistence": live and has_persistence(), "user": user,
            "sourceBytes": source_bytes(), "minBytes": MIN_BYTES, "ramBytes": mem, "disks": disks,
            "defaults": {"timezone": tz, "keyboard": kb, "hostname": "moodtop"},
            "keyboards": [{"layout": l, "variant": v, "name": n} for l, v, n in KEYBOARDS], "state": state}


def validate(c):
    if not re.fullmatch(r"/dev/[A-Za-z0-9/_-]{1,64}", c.get("disk", "")):
        raise Fail("pick a disk")
    if c.get("mode") not in ("erase", "free"):
        raise Fail("pick how to install")
    if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,30}", c.get("username", "")) or c["username"] in RESERVED or c["username"].startswith("systemd-"):
        raise Fail("that username can't be used")
    if not re.fullmatch(r"[^:\n\x00]{0,80}", c.get("fullName", "")):
        raise Fail("invalid name")
    if not c.get("password") or "\n" in c["password"] or len(c["password"]) > 256:
        raise Fail("pick a password")
    if not re.fullmatch(r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?", c.get("hostname", "")):
        raise Fail("invalid computer name")
    tz = c.get("timezone") or "Europe/London"
    if not re.fullmatch(r"[A-Za-z0-9_+\-/]{1,60}", tz) or ".." in tz or not Path("/usr/share/zoneinfo", tz).is_file():
        raise Fail("unknown time zone")
    kb = c.get("keyboard") or {}
    if not re.fullmatch(r"[a-z]{2,8}", kb.get("layout", "gb")) or not re.fullmatch(r"[a-z0-9_-]{0,20}", kb.get("variant", "")):
        raise Fail("invalid keyboard layout")
    return c


# ------------------------------------------------------------------ install steps
def part_path(disk, n, wait=15):
    base = Path(disk).resolve().name
    end = time.time() + wait
    while time.time() < end:
        for child in Path("/sys/block", base).glob(base + "*"):
            pf = child / "partition"
            if pf.exists() and pf.read_text().strip() == str(n) and Path("/dev", child.name).exists():
                return f"/dev/{child.name}"
        subprocess.run(["udevadm", "settle", "-t", "3"], capture_output=True)
        time.sleep(0.5)
    raise Fail(f"partition {n} of {disk} did not appear")


def part_numbers(disk):
    base = Path(disk).resolve().name
    nums = {}
    for child in Path("/sys/block", base).glob(base + "*"):
        pf = child / "partition"
        if pf.exists():
            nums[int(pf.read_text())] = f"/dev/{child.name}"
    return nums


def release_disk(disk):
    """Unmount / swapoff anything living on the disk."""
    r = subprocess.run(["lsblk", "-nlpo", "PATH,MOUNTPOINTS", disk], capture_output=True, text=True)
    for line in r.stdout.splitlines():
        f = line.split(None, 1)
        if len(f) == 2:
            for mp in f[1].split("\\x0a"):
                if mp == "[SWAP]":
                    sh(["swapoff", f[0]], check=False)
                elif mp:
                    sh(["umount", "-l", mp], check=False)


def partition_erase(disk):
    release_disk(disk)
    sh(["wipefs", "-a", "-f", disk], check=False)
    layout = (f'label: gpt\n'
              f'size=1MiB, type={BIOS_TYPE.upper()}, name="bios_grub"\n'
              f'size=512MiB, type={ESP_TYPE.upper()}, name="EFI system"\n'
              f'type={ROOT_TYPE.upper()}, name="Moodtop"\n')
    sh(["sfdisk", "--wipe", "always", "--wipe-partitions", "always", disk], input=layout)
    sh(["partprobe", disk], check=False)
    sh(["udevadm", "settle", "-t", "10"], check=False)
    return {"esp": part_path(disk, 2), "root": part_path(disk, 3), "espNew": True}


def partition_free(disk):
    regions = free_regions(disk)
    if not regions:
        raise Fail("no free space on the disk")
    s, e = max(regions, key=lambda r: r[1] - r[0])
    if e - s < MIN_BYTES:
        raise Fail("not enough free space")
    before = part_numbers(disk)
    esp = None
    for d in lsblk_tree():
        if d["path"] == disk:
            for p in d.get("children") or []:
                if (p.get("parttype") or "").lower() == ESP_TYPE:
                    esp = p["path"]
                    break
    if not esp:
        raise Fail("no EFI system partition on this disk")
    sh(["sfdisk", "--append", "--no-reread", disk], input=f'start={s // 512}, size={(e - s) // 512}, type={ROOT_TYPE.upper()}, name="Moodtop"\n')
    sh(["partprobe", disk], check=False)
    sh(["udevadm", "settle", "-t", "10"], check=False)
    time.sleep(1)
    new = sorted(set(part_numbers(disk)) - set(before))
    if not new:
        raise Fail("the new partition did not appear")
    return {"esp": esp, "root": part_path(disk, new[-1]), "espNew": False}


def blkid(dev, tag):
    return subprocess.run(["blkid", "-s", tag, "-o", "value", dev], capture_output=True, text=True).stdout.strip()


def copy_system(src, total, keep):
    excludes = ["/proc/*", "/sys/*", "/dev/*", "/run/*", "/tmp/*", "/mnt/*", "/media/*", "/lost+found", "/var/tmp/*",
                "/var/cache/apt/archives/*.deb", "/swapfile", "/root/.cache", "/var/log/journal/*", "/etc/live",
                "/home/*/.cache/*", "/var/lib/mood-install", "/persistence.conf"]
    cmd = ["rsync", "-aHAXx", "--numeric-ids", "--info=progress2", "--no-inc-recursive", "--outbuf=L"]
    for x in excludes:
        cmd += ["--exclude", x]
    cmd += [str(src).rstrip("/") + "/", str(TARGET) + "/"]
    log("$ " + " ".join(cmd))
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    buf = b""
    last = 0
    while True:
        ch = p.stdout.read(256)
        if not ch:
            break
        buf += ch
        parts = re.split(rb"[\r\n]", buf)
        buf = parts.pop()
        for seg in parts:
            m = re.match(rb"\s*([\d,]+)\s+(\d+)%", seg)
            if m and time.time() - last > 0.5:
                last = time.time()
                done = int(m[1].replace(b",", b""))
                pct = int(m[2])
                progress("running", "copy", "Copying Moodtop to your disk", 10 + pct * 0.68,
                         f"{done / 1e9:.1f} GB of {max(total, done) / 1e9:.1f} GB")
    err = p.stderr.read().decode(errors="replace")
    p.wait()
    if err.strip():
        log(err[-4000:])
    if p.returncode not in (0, 23, 24):
        raise Fail(f"copying files failed (rsync {p.returncode}): {err.strip()[-300:]}")
    if p.returncode == 23:
        log("warning: some files could not be copied (rsync 23), continuing")
    sh(["sh", "/usr/lib/mood/runtime/mood-fix-perms", TARGET], check=False)


def chroot(cmd, check=True, input=None, env=None, timeout=3600):
    e = dict(os.environ, DEBIAN_FRONTEND="noninteractive", LC_ALL="C.UTF-8", PATH="/usr/sbin:/usr/bin:/sbin:/bin")
    e.update(env or {})
    return sh(["chroot", TARGET, *cmd], check=check, input=input, env=e, timeout=timeout)


def mount_api(with_proc=True):
    for d in ("dev", "dev/pts", "sys", "run"):
        (TARGET / d).mkdir(parents=True, exist_ok=True)
    sh(["mount", "--bind", "/dev", TARGET / "dev"])
    sh(["mount", "--bind", "/dev/pts", TARGET / "dev/pts"])
    sh(["mount", "--rbind", "/sys", TARGET / "sys"])
    sh(["mount", "-t", "tmpfs", "tmpfs", TARGET / "run"])
    if with_proc:
        (TARGET / "proc").mkdir(exist_ok=True)
        sh(["mount", "-t", "proc", "proc", TARGET / "proc"])
    if (MEDIUM / "pool").is_dir():
        (TARGET / "mnt/moodtop-media").mkdir(parents=True, exist_ok=True)
        sh(["mount", "--bind", MEDIUM, TARGET / "mnt/moodtop-media"])


def umount_all():
    subprocess.run(["sync"])
    for _ in range(3):
        r = subprocess.run(["umount", "-R", str(TARGET)], capture_output=True, text=True)
        if r.returncode == 0 or "not mounted" in r.stderr:
            return
        time.sleep(1)
    subprocess.run(["umount", "-R", "-l", str(TARGET)], capture_output=True)


def set_kv(path, key, value):
    t = path.read_text() if path.exists() else ""
    line = f"{key}={value}"
    if re.search(rf"^#?\s*{key}=.*$", t, re.M):
        t = re.sub(rf"^#?\s*{key}=.*$", line, t, count=1, flags=re.M)
    else:
        t = t.rstrip("\n") + "\n" + line + "\n"
    path.write_text(t)


def configure_users(c, keep):
    T = TARGET
    user, full = c["username"], c.get("fullName") or c["username"]
    has_student = "\nstudent:" in "\n" + (T / "etc/passwd").read_text()
    groups = ",".join(g for g in GROUPS if re.search(rf"^{g}:", (T / "etc/group").read_text(), re.M))
    if keep and has_student:
        chroot(["usermod", "-l", user, "-d", f"/home/{user}", "-m", "-c", full, "student"])
        chroot(["groupmod", "-n", user, "student"])
        chroot(["usermod", "-aG", groups, user])
        # configs that remembered the old home path
        home = T / "home" / user
        for sub in (".config", ".local/share/applications", ".local/share/mood"):
            base = home / sub
            if not base.is_dir():
                continue
            for f in base.rglob("*"):
                try:
                    if f.is_file() and not f.is_symlink() and f.stat().st_size < 1 << 20:
                        b = f.read_bytes()
                        if b"/home/student" in b and b"\0" not in b[:4096]:
                            f.write_bytes(b.replace(b"/home/student", f"/home/{user}".encode()))
                except OSError:
                    pass
    else:
        if has_student:
            chroot(["userdel", "-r", "-f", "student"], check=False)
            shutil.rmtree(T / "home/student", ignore_errors=True)
        chroot(["useradd", "-m", "-s", "/bin/bash", "-c", full, "-G", groups, user])
    chroot(["chpasswd"], input=f"{user}:{c['password']}\n")
    chroot(["passwd", "-l", "root"], check=False)
    for g in ("video", "render", "input"):
        chroot(["usermod", "-aG", g, "_greetd"], check=False)


def configure_system(c, parts, keep):
    T = TARGET
    root_uuid, esp_uuid = blkid(parts["root"], "UUID"), blkid(parts["esp"], "UUID")
    (T / "boot/efi").mkdir(parents=True, exist_ok=True)
    (T / "etc/fstab").write_text(
        "# /etc/fstab — written by the Moodtop installer\n"
        f"UUID={root_uuid}\t/\text4\terrors=remount-ro,noatime\t0\t1\n"
        f"UUID={esp_uuid}\t/boot/efi\tvfat\tumask=0077,shortname=winnt\t0\t2\n")
    host = c["hostname"]
    (T / "etc/hostname").write_text(host + "\n")
    (T / "etc/hosts").write_text(f"127.0.0.1\tlocalhost\n127.0.1.1\t{host}\n::1\t\tlocalhost ip6-localhost ip6-loopback\nff02::1\t\tip6-allnodes\nff02::2\t\tip6-allrouters\n")
    tz = c.get("timezone") or "Europe/London"
    lt = T / "etc/localtime"
    if lt.is_symlink() or lt.exists():
        lt.unlink()
    lt.symlink_to(f"/usr/share/zoneinfo/{tz}")
    (T / "etc/timezone").write_text(tz + "\n")
    kb = c.get("keyboard") or {"layout": "gb", "variant": ""}
    (T / "etc/default/keyboard").write_text(f'XKBMODEL="pc105"\nXKBLAYOUT="{kb.get("layout", "gb")}"\nXKBVARIANT="{kb.get("variant", "")}"\nXKBOPTIONS=""\nBACKSPACE="guess"\n')
    greet = ('[terminal]\nvt = 1\n\n[default_session]\ncommand = "cage -s -m last -- gtkgreet -l -s /etc/greetd/moodtop.css"\nuser = "_greetd"\n')
    if c.get("autologin"):
        greet += f'\n[initial_session]\ncommand = "/usr/bin/mood-session"\nuser = "{c["username"]}"\n'
    (T / "etc/greetd/config.toml").write_text(greet)
    for f in ("etc/sudoers.d/moodtop-live", "usr/share/applications/mood-install.desktop", "usr/share/applications/mood-installer.desktop",
              "usr/share/applications/calamares-install-debian.desktop", "usr/share/applications/calamares.desktop", "persistence.conf"):
        try:
            (T / f).unlink()
        except FileNotFoundError:
            pass
    shutil.rmtree(T / "etc/live", ignore_errors=True)
    (T / "etc/machine-id").write_text("")
    (T / "etc/mood").mkdir(exist_ok=True)
    (T / "etc/mood/installed").write_text(json.dumps({"at": int(time.time()), "mode": c["mode"], "keepStuff": keep,
                                                      "uefi": Path("/sys/firmware/efi").is_dir()}) + "\n")
    # the live USB's automatic-update choice carries over; default on
    upd = T / "etc/mood/update.json"
    if not upd.exists():
        upd.write_text('{"auto": true, "channel": "stable"}\n')


def remove_live_packages():
    installed = [p for p in LIVE_PKGS if chroot(["dpkg-query", "-W", "-f=${Status}", p], check=False).stdout.startswith("install ok installed")]
    if installed:
        chroot(["dpkg", "--purge", *installed], check=False)
    chroot(["apt-get", "-y", "-o", "Dpkg::Options::=--force-confold", "autoremove", "--purge"], check=False, timeout=1800)


def by_id(disk):
    r = subprocess.run(["udevadm", "info", "-q", "symlink", "-n", disk], capture_output=True, text=True).stdout.split()
    for s in sorted(r):
        if s.startswith("disk/by-id/") and "-part" not in s:
            return "/dev/" + s
    return disk


def bootloader(c, parts, uefi):
    T = TARGET
    disk = c["disk"]
    pool = T / "mnt/moodtop-media/pool/main"
    pkg = "grub-efi-amd64" if uefi else "grub-pc"
    sel = []
    if uefi:
        sel += ["grub-efi-amd64 grub2/force_efi_extra_removable boolean " + ("true" if c["mode"] == "erase" else "false"),
                "grub-efi-amd64 grub2/update_nvram boolean true"]
    else:
        sel += [f"grub-pc grub-pc/install_devices multiselect {by_id(disk)}", "grub-pc grub-pc/install_devices_empty boolean false"]
    chroot(["debconf-set-selections"], input="\n".join(sel) + "\n", check=False)
    debs = sorted(pool.glob(f"{pkg}_*.deb")) if pool.is_dir() else []
    ok = False
    if debs:
        ok = chroot(["dpkg", "-i", "/mnt/moodtop-media/pool/main/" + debs[-1].name], check=False).returncode == 0
    if not ok:
        log(f"{pkg} not in the offline pool (or failed); trying apt")
        ok = chroot(["apt-get", "install", "-y", "--no-install-recommends", pkg], check=False, timeout=1200).returncode == 0
    if not ok:
        log(f"warning: {pkg} package not installed; the bootloader works but won't be refreshed by updates")
        chroot(["dpkg", "--configure", "-a"], check=False)
    dg = T / "etc/default/grub"
    if not dg.exists() and (T / "usr/share/grub/default/grub").exists():
        shutil.copy(T / "usr/share/grub/default/grub", dg)
    set_kv(dg, "GRUB_CMDLINE_LINUX_DEFAULT", '"quiet splash loglevel=3"')
    set_kv(dg, "GRUB_DISTRIBUTOR", '"Moodtop OS"')
    dual = c["mode"] == "free"
    set_kv(dg, "GRUB_TIMEOUT", "8" if dual else "3")
    set_kv(dg, "GRUB_DISABLE_OS_PROBER", "false" if dual else "true")
    set_kv(dg, "GRUB_GFXMODE", "1920x1080,1600x900,1366x768,auto")
    set_kv(dg, "GRUB_GFXPAYLOAD_LINUX", "keep")
    set_kv(dg, "GRUB_DISABLE_LINUX_UUID", "false")
    theme_src = T / "mnt/moodtop-media/boot/grub/themes/moodtop"
    if theme_src.is_dir():
        shutil.copytree(theme_src, T / "boot/grub/themes/moodtop", dirs_exist_ok=True)
        set_kv(dg, "GRUB_THEME", '"/boot/grub/themes/moodtop/theme.txt"')
    if uefi:
        (T / "boot/efi").mkdir(parents=True, exist_ok=True)
        cmd = ["grub-install", "--target=x86_64-efi", "--efi-directory=/boot/efi", "--bootloader-id=debian", "--uefi-secure-boot", "--recheck"]
        if c["mode"] == "erase":
            cmd.append("--force-extra-removable")
        chroot(cmd)
    else:
        chroot(["grub-install", "--target=i386-pc", "--recheck", disk])
    chroot(["update-grub"])
    if uefi:
        relabel_nvram(disk, parts["esp"])


def check_boot(parts):
    """The installed disk must find its root by UUID (sdX names change once the USB is unplugged)."""
    T = TARGET
    uuid = blkid(parts["root"], "UUID")
    if not uuid:
        raise Fail("the new root partition has no UUID")
    cfg = T / "boot/grub/grub.cfg"
    t = cfg.read_text()
    fixed = re.sub(r"(\blinux\s+\S+\s+)root=\S+", rf"\g<1>root=UUID={uuid}", t)
    if fixed != t:
        cfg.write_text(fixed)
    n = len(re.findall(rf"root=UUID={uuid}\b", fixed))
    log(f"grub.cfg: {n} entries boot root=UUID={uuid}")
    if n == 0:
        raise Fail("the bootloader menu has no Moodtop entry")
    kvers = [k.name[len("vmlinuz-"):] for k in (T / "boot").glob("vmlinuz-*")]
    if not kvers:
        raise Fail("no kernel in /boot")
    for kv in kvers:
        rd = T / f"boot/initrd.img-{kv}"
        if not rd.exists():
            raise Fail(f"startup image missing for {kv}")
        ls = chroot(["lsinitramfs", f"/boot/initrd.img-{kv}"], check=False, timeout=600).stdout
        need = ["scripts/local", "ext4.ko", "nvme.ko", "ahci.ko"]
        missing = [x for x in need if x not in ls]
        bad = [x for x in ("scripts/live", "live-boot") if x in ls]
        log(f"initrd {kv}: {rd.stat().st_size // 1048576} MiB, missing={missing}, live={bad}")
        if missing or bad:
            raise Fail(f"startup image for {kv} is broken (missing {missing}, live bits {bad})")


def relabel_nvram(disk, esp):
    """grub-install registers 'debian'; show it as 'Moodtop OS' in the firmware boot menu."""
    try:
        partuuid = blkid(esp, "PARTUUID").lower()
        num = int(Path("/sys/class/block", Path(esp).resolve().name, "partition").read_text())
        out = sh(["efibootmgr"], check=False, quiet=True).stdout
        old = [m[1] for m in re.finditer(r"^Boot([0-9A-F]{4})\*? debian\b.*$", out, re.M | re.I)
               if partuuid in m[0].lower() or "HD(" not in m[0]]
        sh(["efibootmgr", "-q", "-c", "-d", disk, "-p", str(num), "-L", "Moodtop OS", "-l", r"\EFI\debian\shimx64.efi"], check=False)
        out2 = sh(["efibootmgr", "-v"], check=False, quiet=True).stdout
        for b in old:
            if re.search(rf"^Boot{b}\*? debian.*{re.escape(partuuid)}", out2, re.M | re.I):
                sh(["efibootmgr", "-q", "-b", b, "-B"], check=False)
    except Exception as e:  # cosmetic only
        log(f"nvram relabel skipped: {e}")


def run(cfg_path):
    STATE.mkdir(mode=0o755, exist_ok=True)
    LOG.write_text("")
    os.chmod(LOG, 0o644)
    c = json.loads(Path(cfg_path).read_text())
    Path(cfg_path).unlink(missing_ok=True)  # holds the password
    try:
        validate(c)
        progress("running", "prepare", "Getting ready", 1)
        info = scan()
        d = next((x for x in info["disks"] if x["path"] == c["disk"]), None)
        if not d:
            raise Fail("that disk is gone — is it still plugged in?")
        if c["mode"] == "erase" and not d["canErase"]:
            raise Fail(d["eraseWhyNot"])
        if c["mode"] == "free" and not d["canFree"]:
            raise Fail(d["freeWhyNot"])
        uefi = info["uefi"]
        keep = bool(c.get("keepStuff")) and info["persistence"]
        log(f"install: disk={c['disk']} ({d['model']}) mode={c['mode']} uefi={uefi} keepStuff={keep} user={c['username']} host={c['hostname']}")
        # source
        if keep:
            src = Path("/")
        else:
            src = Path("/run/live/rootfs/filesystem.squashfs")
            if not (src / "usr/bin").is_dir():
                sq = MEDIUM / "live/filesystem.squashfs"
                if not sq.exists():
                    raise Fail("can't find the Moodtop system image (filesystem.squashfs) — boot from the Moodtop USB")
                SQUASH_MNT.mkdir(parents=True, exist_ok=True)
                sh(["mount", "-t", "squashfs", "-o", "ro", sq, SQUASH_MNT])
                src = SQUASH_MNT
        total = info["sourceBytes"]
        if keep:
            r = subprocess.run(["df", "-B1", "--output=used", "/"], capture_output=True, text=True).stdout.split()
            total = int(r[-1]) if r and r[-1].isdigit() else total
        progress("running", "partition", "Preparing the disk", 3)
        parts = partition_erase(c["disk"]) if c["mode"] == "erase" else partition_free(c["disk"])
        log(f"partitions: {parts}")
        progress("running", "format", "Formatting", 7)
        if parts["espNew"]:
            sh(["mkfs.fat", "-F", "32", "-n", "MOODEFI", parts["esp"]])
        sh(["mkfs.ext4", "-F", "-q", "-L", "Moodtop", "-O", "^orphan_file", parts["root"]])
        sh(["udevadm", "trigger", "--settle", parts["root"], parts["esp"]], check=False)
        sh(["udevadm", "settle", "-t", "15"], check=False)
        TARGET.mkdir(parents=True, exist_ok=True)
        sh(["mount", parts["root"], TARGET])
        progress("running", "copy", "Copying Moodtop to your disk", 10, f"0 GB of {total / 1e9:.1f} GB")
        copy_system(src, total, keep)
        if src == SQUASH_MNT:
            sh(["umount", SQUASH_MNT], check=False)
        progress("running", "users", "Creating your account", 79)
        (TARGET / "boot/efi").mkdir(parents=True, exist_ok=True)
        mount_api(with_proc=False)
        configure_users(c, keep)  # before /proc is mounted: usermod refuses while the live 'student' is running
        progress("running", "configure", "Setting things up", 81)
        configure_system(c, parts, keep)
        (TARGET / "proc").mkdir(exist_ok=True)
        sh(["mount", "-t", "proc", "proc", TARGET / "proc"])
        sh(["mount", parts["esp"], TARGET / "boot/efi"])
        progress("running", "configure", "Removing live-USB tools", 83)
        remove_live_packages()
        progress("running", "bootloader", "Installing the bootloader", 89)
        bootloader(c, parts, uefi)
        progress("running", "initramfs", "Building the startup image", 94)
        chroot(["update-initramfs", "-u", "-k", "all"], timeout=1800)
        chroot(["update-grub"])
        check_boot(parts)
        progress("running", "finish", "Finishing up", 98)
        (TARGET / "var/log").mkdir(parents=True, exist_ok=True)
        (TARGET / "mnt/moodtop-media").exists() and sh(["umount", TARGET / "mnt/moodtop-media"], check=False)
        try:
            (TARGET / "mnt/moodtop-media").rmdir()
        except OSError:
            pass
        log("install finished")
        shutil.copy(LOG, TARGET / "var/log/mood-install.log")
        umount_all()
        progress("done", "done", "Moodtop is installed", 100)
    except Exception as e:
        msg = str(e) or e.__class__.__name__
        log(f"FAILED: {msg}")
        umount_all()
        if SQUASH_MNT.exists():
            subprocess.run(["umount", str(SQUASH_MNT)], capture_output=True)
        progress("failed", "error", "Installation failed", _last.get("pct", 0), error=msg)
        sys.exit(1)


if __name__ == "__main__":
    if os.getuid() != 0:
        sys.exit("mood-installer must run as root")
    if len(sys.argv) >= 2 and sys.argv[1] == "scan":
        print(json.dumps(scan()))
    elif len(sys.argv) >= 3 and sys.argv[1] == "run":
        run(sys.argv[2])
    else:
        sys.exit(__doc__)
