#!/usr/bin/env python3
"""
Alfred Update — checks, downloads, verifies, snapshots and installs Alfred Linux updates.

GUI (default when launched from the desktop) and CLI (--cli, or when run in a terminal).
  * Manifest: https://alfredlinux.com/ota/latest.json (Ed25519-signed, SHA-256 of the bundle)
  * Downloads through Tor (127.0.0.1:9050) when Tor is running, otherwise directly.
    The firewall / Tor kill switch is never changed.
  * The signature is really verified against /etc/alfred/keys/ota-ed25519.pub
    (here and again by the root installer before anything is applied).
  * Before any file is modified, automatically creates a System Restore Snapshot:
    - Native ZFS snapshot (`rpool/ROOT/alfred@pre-ota-...`) on ZFS
    - Native Btrfs read-only subvolume snapshot (`/.alfred-snapshots/pre-ota-...`) on Btrfs
    - Plus a universal file-level rollback archive (`/var/backups/alfred-ota/pre-ota-....tar.gz`)
      on ALL filesystems (ZFS, Btrfs, Ext4, XFS, F2FS, Live USB) so 1-click rollback works everywhere!
"""
import hashlib
import json
import os
import shutil
import socket
import subprocess
import sys
import time

MANIFEST_URL = "https://alfredlinux.com/ota/latest.json"
PUBKEY_PATH = "/etc/alfred/keys/ota-ed25519.pub"
APPLY_HELPER = "/usr/local/libexec/alfred-update-apply"
HISTORY_PATH = "/var/lib/alfred-update/history.json"
SNAPSHOTS_PATH = "/var/lib/alfred-update/snapshots.json"
PRODUCT = "Alfred Linux 7.77"
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "alfred-update")
STATE_FILE = os.path.join(os.path.expanduser("~"), ".local", "state", "alfred-update", "state.json")
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


# --------------------------------------------------------------------------- backend
def installed_version():
    for p in ("/etc/alfred-version", "/usr/local/lib/alfred-wallet/OTA_VERSION"):
        try:
            v = open(p).read().strip()
            if v:
                return v
        except OSError:
            pass
    return "unknown"


def vtuple(v):
    out = []
    for part in str(v).split("."):
        num = "".join(ch for ch in part if ch.isdigit())
        out.append(int(num) if num else 0)
    return tuple(out)


def tor_available():
    try:
        with socket.create_connection(("127.0.0.1", 9050), timeout=1.5):
            return True
    except OSError:
        return False


def curl_base(use_tor):
    cmd = ["curl", "-fL", "-sS", "--http1.1", "--retry", "6", "--retry-delay", "2", "--retry-all-errors", "--connect-timeout", "30"]
    if use_tor:
        cmd += ["--socks5-hostname", "127.0.0.1:9050"]
    return cmd


def fetch_manifest():
    """Return (manifest, route) — tries Tor first if running, then a direct connection."""
    routes = (["tor"] if tor_available() else []) + ["direct"]
    last_err = "no route"
    for route in routes:
        r = subprocess.run(curl_base(route == "tor") + ["--max-time", "90", MANIFEST_URL],
                           capture_output=True, text=True)
        if r.returncode == 0:
            try:
                return json.loads(r.stdout), route
            except ValueError:
                last_err = "the update server returned an invalid response"
        else:
            last_err = (r.stderr or "").strip().splitlines()[-1:] or ["connection failed"]
            last_err = last_err[0]
    raise RuntimeError(f"Couldn't reach the update server ({last_err}).")


def b58decode(s):
    n = 0
    for ch in s:
        n = n * 58 + B58.index(ch)
    raw = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
    return b"\x00" * (len(s) - len(s.lstrip("1"))) + raw


def verify_manifest(m):
    """Return (ok: bool, message). Fails closed when no key is installed."""
    sec = m.get("__security") or {}
    payload = {k: v for k, v in m.items() if k not in ("__security", "signature")}
    digest = hashlib.sha256(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).digest()
    if sec.get("hash") and sec["hash"] != digest.hex():
        return False, "The update information was altered (hash mismatch)."
    try:
        pub = bytes.fromhex(open(PUBKEY_PATH).read().strip())
    except (OSError, ValueError):
        return False, "No update signing key is installed on this system."
    try:
        from nacl.signing import VerifyKey
        VerifyKey(pub).verify(digest, b58decode(sec.get("signature", "")))
        return True, "Signature verified"
    except Exception:
        return False, "The update signature is not valid."


def sha256_file(path, chunk=1 << 20):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for block in iter(lambda: f.read(chunk), b""):
            h.update(block)
    return h.hexdigest()


def privileged_cmd(args):
    if os.geteuid() == 0:
        return [APPLY_HELPER] + args
    if shutil.which("sudo") and subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0:
        return ["sudo", "-n", APPLY_HELPER] + args
    return ["pkexec", APPLY_HELPER] + args


def load_history():
    try:
        h = json.load(open(HISTORY_PATH))
        return h if isinstance(h, list) else []
    except (OSError, ValueError):
        return []


def load_snapshots():
    try:
        s = json.load(open(SNAPSHOTS_PATH))
        return s if isinstance(s, list) else []
    except (OSError, ValueError):
        return []


def save_state(**kw):
    try:
        os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
        st = {}
        if os.path.exists(STATE_FILE):
            st = json.load(open(STATE_FILE))
        st.update(kw)
        json.dump(st, open(STATE_FILE, "w"))
    except Exception:
        pass


def load_state():
    try:
        return json.load(open(STATE_FILE))
    except Exception:
        return {}


def human_size(n):
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024.0


def notes_list(m):
    raw = m.get("notes_list") or m.get("notes") or ""
    if isinstance(raw, list):
        return [str(x) for x in raw]
    parts = [p.strip(" -•\t") for p in str(raw).replace("(", "\n").replace(")", "").replace(",", "\n").split("\n")]
    return [p for p in parts if p]


# --------------------------------------------------------------------------- CLI
def run_cli():
    if "--snapshots" in sys.argv:
        snaps = load_snapshots()
        if not snaps:
            print("No pre-update snapshots recorded yet.")
            return 0
        print(f"Available pre-update restore snapshots ({len(snaps)}):")
        for s in reversed(snaps):
            print(f"  • {s.get('id')}  |  v{s.get('from_version')} -> v{s.get('to_version')}  |  {s.get('type')}  |  {s.get('time')}")
        return 0

    if "--rollback" in sys.argv:
        idx = sys.argv.index("--rollback")
        snap_id = sys.argv[idx + 1] if (idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("--")) else None
        cmd_args = ["--rollback"] + ([snap_id] if snap_id else [])
        if "--full-fs-rollback" in sys.argv:
            cmd_args.append("--full-fs-rollback")
        p = subprocess.Popen(privileged_cmd(cmd_args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        for line in p.stdout:
            kind, _, rest = line.rstrip("\n").partition(" ")
            if kind in ("STEP", "DONE", "ERROR"):
                print(f"  {rest}")
        return p.wait()

    print(f"{PRODUCT} — installed version {installed_version()}")
    print("Checking for updates…")
    try:
        m, route = fetch_manifest()
    except RuntimeError as e:
        print(e)
        return 1
    ok, msg = verify_manifest(m)
    print(f"  Available: {m.get('version')}  ({human_size(int(m.get('size') or 0))})  via {route}")
    print(f"  {msg}")
    if not ok:
        print("Update refused: it could not be verified.")
        return 1
    if vtuple(m.get("version")) <= vtuple(installed_version()) and "--force" not in sys.argv:
        print("You're up to date.  (use --force to reinstall, or --rollback to restore previous snapshot)")
        return 0
    os.makedirs(CACHE_DIR, exist_ok=True)
    mpath, bpath = os.path.join(CACHE_DIR, "latest.json"), os.path.join(CACHE_DIR, "bundle.tar.gz")
    json.dump(m, open(mpath, "w"))
    print("Downloading…")
    exp_size = int(m.get("size") or 0)
    if os.path.exists(bpath) and exp_size and os.path.getsize(bpath) >= exp_size:
        if sha256_file(bpath) != m.get("sha256"):
            os.remove(bpath)
    rc = 1
    for try_route in ([route, "direct"] if route == "tor" else [route]):
        for _attempt in range(3):
            rc = subprocess.call(curl_base(try_route == "tor") + ["-C", "-", "--progress-bar", "-o", bpath, m["url"]])
            if rc == 0:
                break
        if rc == 0:
            break
    if rc != 0:
        print("Download failed.")
        return 1
    if sha256_file(bpath) != m.get("sha256"):
        print("Download is corrupt (SHA-256 mismatch). Nothing was installed.")
        return 1
    print("Verified. Creating pre-update snapshot & installing (administrator rights needed)…")
    p = subprocess.Popen(privileged_cmd(["--manifest", mpath, "--bundle", bpath]), stdout=subprocess.PIPE,
                         stderr=subprocess.STDOUT, text=True)
    for line in p.stdout:
        kind, _, rest = line.rstrip("\n").partition(" ")
        if kind in ("STEP", "SNAPSHOT", "DONE", "ERROR"):
            print(f"  {rest}")
    return p.wait()


# --------------------------------------------------------------------------- GUI
def run_gui():
    if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("ALFRED_KEEP_WAYLAND"):
        os.environ["QT_QPA_PLATFORM"] = "xcb"
    from PyQt6.QtCore import QPointF, QRectF, Qt, QThread, QTimer, pyqtSignal
    from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QIcon
    from PyQt6.QtWidgets import (QApplication, QDialog, QFrame, QHBoxLayout, QLabel, QMainWindow,
                                 QPlainTextEdit, QProgressBar, QPushButton, QTableWidget,
                                 QTableWidgetItem, QVBoxLayout, QWidget, QHeaderView, QMessageBox)

    GOLD, GREEN, RED, MUTED, TEXT = "#E8B83A", "#2ECC71", "#E5534B", "#8A94A7", "#E6EAF2"

    QSS = f"""
    QMainWindow, QWidget#root {{ background: #0B0E14; }}
    QLabel {{ color: {TEXT}; background: transparent; }}
    QLabel#title {{ font-size: 26px; font-weight: 600; }}
    QLabel#headline {{ font-size: 20px; font-weight: 600; }}
    QLabel#sub {{ color: {MUTED}; font-size: 13px; }}
    QLabel#section {{ font-size: 15px; font-weight: 600; }}
    QLabel#footer {{ color: {MUTED}; font-size: 12px; }}
    QFrame#card {{ background: #121722; border: 1px solid #1E2636; border-radius: 10px; }}
    QPushButton {{ background: #1A2130; color: {TEXT}; border: 1px solid #2A3448; border-radius: 6px;
                   padding: 9px 20px; font-size: 13px; }}
    QPushButton:hover {{ background: #222B3D; }}
    QPushButton#primary {{ background: {GOLD}; color: #0B0E14; border: none; font-weight: 600; }}
    QPushButton#primary:hover {{ background: #F5C74E; }}
    QPushButton#primary:disabled {{ background: #3A3420; color: #7D7350; }}
    QPushButton#link {{ background: transparent; border: none; color: {GOLD}; padding: 4px 0; text-align: left; }}
    QPushButton#link:hover {{ color: #F5C74E; text-decoration: underline; }}
    QProgressBar {{ background: #1A2130; border: none; border-radius: 3px; height: 6px; }}
    QProgressBar::chunk {{ background: {GOLD}; border-radius: 3px; }}
    QPlainTextEdit {{ background: #0E121A; color: #B8C2D6; border: 1px solid #1E2636; border-radius: 6px;
                      font-family: 'DejaVu Sans Mono'; font-size: 11px; }}
    QTableWidget {{ background: #121722; color: {TEXT}; border: none; gridline-color: #1E2636; }}
    QHeaderView::section {{ background: #1A2130; color: {MUTED}; border: none; padding: 6px; }}
    """

    class StatusIcon(QWidget):
        """Large status glyph drawn with QPainter: ok / update / busy (spinner) / error."""
        def __init__(self):
            super().__init__()
            self.setFixedSize(72, 72)
            self.mode, self.angle = "busy", 0
            self.t = QTimer(self)
            self.t.timeout.connect(self._tick)
            self.t.start(30)

        def set_mode(self, mode):
            self.mode = mode
            self.update()

        def _tick(self):
            if self.mode == "busy":
                self.angle = (self.angle + 8) % 360
                self.update()

        def paintEvent(self, _):
            p = QPainter(self)
            p.setRenderHint(QPainter.RenderHint.Antialiasing)
            r = QRectF(6, 6, 60, 60)
            color = {"ok": GREEN, "update": GOLD, "busy": GOLD, "error": RED}[self.mode]
            if self.mode == "busy":
                p.setPen(QPen(QColor("#2A3448"), 5))
                p.drawEllipse(r)
                p.setPen(QPen(QColor(color), 5, cap=Qt.PenCapStyle.RoundCap))
                p.drawArc(r, -self.angle * 16, 100 * 16)
                return
            p.setPen(Qt.PenStyle.NoPen)
            p.setBrush(QColor(color))
            p.drawEllipse(r)
            p.setPen(QPen(QColor("#0B0E14"), 5, cap=Qt.PenCapStyle.RoundCap, join=Qt.PenJoinStyle.RoundJoin))
            if self.mode == "ok":
                p.drawPolyline([QPointF(22, 37), QPointF(32, 47), QPointF(50, 27)])
            elif self.mode == "update":
                p.drawLine(QPointF(36, 20), QPointF(36, 46))
                p.drawPolyline([QPointF(25, 36), QPointF(36, 47), QPointF(47, 36)])
                p.drawLine(QPointF(24, 53), QPointF(48, 53))
            else:
                p.drawLine(QPointF(26, 26), QPointF(46, 46))
                p.drawLine(QPointF(46, 26), QPointF(26, 46))

    class StepRow(QWidget):
        def __init__(self, text):
            super().__init__()
            lay = QHBoxLayout(self)
            lay.setContentsMargins(0, 2, 0, 2)
            self.dot = QLabel("○")
            self.dot.setFixedWidth(20)
            self.lbl = QLabel(text)
            lay.addWidget(self.dot)
            lay.addWidget(self.lbl, 1)
            self.set("pending")

        def set(self, state):
            glyph, col = {"pending": ("○", MUTED), "active": ("●", GOLD), "done": ("✓", GREEN), "error": ("✕", RED)}[state]
            self.dot.setText(glyph)
            self.dot.setStyleSheet(f"color: {col}; font-size: 15px; font-weight: 700;")
            self.lbl.setStyleSheet(f"color: {TEXT if state != 'pending' else MUTED};")

    class CheckWorker(QThread):
        result = pyqtSignal(dict, str, bool, str)
        failed = pyqtSignal(str)

        def run(self):
            try:
                m, route = fetch_manifest()
                ok, msg = verify_manifest(m)
                self.result.emit(m, route, ok, msg)
            except Exception as e:
                self.failed.emit(str(e))

    class DownloadWorker(QThread):
        finished_ok = pyqtSignal(str, str)
        failed = pyqtSignal(str)

        def __init__(self, manifest, route):
            super().__init__()
            self.m, self.route, self.proc, self.cancelled = manifest, route, None, False
            os.makedirs(CACHE_DIR, exist_ok=True)
            self.mpath = os.path.join(CACHE_DIR, "latest.json")
            self.bpath = os.path.join(CACHE_DIR, "bundle.tar.gz")

        def run(self):
            json.dump(self.m, open(self.mpath, "w"))
            exp_size = int(self.m.get("size") or 0)
            if os.path.exists(self.bpath):
                if exp_size and os.path.getsize(self.bpath) == exp_size and sha256_file(self.bpath) == self.m.get("sha256"):
                    return self.finished_ok.emit(self.mpath, self.bpath)
                if exp_size and os.path.getsize(self.bpath) >= exp_size:
                    os.remove(self.bpath)
            routes = [self.route, "direct"] if self.route == "tor" else [self.route]
            last_err = ""
            ok_dl = False
            for r_try in routes:
                for _pass in range(4):
                    if self.cancelled:
                        return self.failed.emit("cancelled")
                    self.proc = subprocess.Popen(
                        curl_base(r_try == "tor") + ["-C", "-", "-o", self.bpath, (self.m.get("url") or self.m.get("bundle_url"))],
                        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
                    )
                    _, err = self.proc.communicate()
                    if self.cancelled:
                        return self.failed.emit("cancelled")
                    if self.proc.returncode == 0:
                        ok_dl = True
                        break
                    last_err = (err.strip().splitlines() or [""])[-1]
                if ok_dl:
                    break
            if not ok_dl:
                return self.failed.emit("The download failed. " + last_err)
            if sha256_file(self.bpath) != self.m.get("sha256"):
                try:
                    os.remove(self.bpath)
                except OSError:
                    pass
                return self.failed.emit("The downloaded update is corrupt (checksum mismatch). Click Try again to re-download.")
            self.finished_ok.emit(self.mpath, self.bpath)

        def cancel(self):
            self.cancelled = True
            if self.proc and self.proc.poll() is None:
                self.proc.terminate()

    class InstallWorker(QThread):
        line = pyqtSignal(str, str)
        done = pyqtSignal(int)

        def __init__(self, mpath, bpath):
            super().__init__()
            self.mpath, self.bpath = mpath, bpath

        def run(self):
            try:
                p = subprocess.Popen(privileged_cmd(["--manifest", self.mpath, "--bundle", self.bpath]),
                                     stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
                for raw in p.stdout:
                    kind, _, rest = raw.rstrip("\n").partition(" ")
                    self.line.emit(
                        kind if kind in ("STEP", "PROGRESS", "SNAPSHOT", "DONE", "ERROR") else "LOG",
                        rest if kind in ("STEP", "PROGRESS", "SNAPSHOT", "DONE", "ERROR") else raw.rstrip("\n"),
                    )
                self.done.emit(p.wait())
            except Exception as e:
                self.line.emit("ERROR", str(e))
                self.done.emit(1)

    class RollbackWorker(QThread):
        line = pyqtSignal(str, str)
        done = pyqtSignal(int, str)

        def __init__(self, snap_id):
            super().__init__()
            self.snap_id = snap_id
            self.restored_ver = "?"

        def run(self):
            try:
                cmd_args = ["--rollback"] + ([self.snap_id] if self.snap_id else [])
                p = subprocess.Popen(privileged_cmd(cmd_args),
                                     stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
                for raw in p.stdout:
                    kind, _, rest = raw.rstrip("\n").partition(" ")
                    if kind == "DONE":
                        self.restored_ver = rest.strip()
                    self.line.emit(
                        kind if kind in ("STEP", "PROGRESS", "DONE", "ERROR") else "LOG",
                        rest if kind in ("STEP", "PROGRESS", "DONE", "ERROR") else raw.rstrip("\n"),
                    )
                self.done.emit(p.wait(), self.restored_ver)
            except Exception as e:
                self.line.emit("ERROR", str(e))
                self.done.emit(1, "?")

    class HistoryDialog(QDialog):
        def __init__(self, parent):
            super().__init__(parent)
            self.parent_win = parent
            self.setWindowTitle("Update history & System snapshots")
            self.resize(740, 440)
            self.setStyleSheet(QSS + "QDialog { background: #0B0E14; }")
            lay = QVBoxLayout(self)

            t = QLabel("Update history")
            t.setObjectName("section")
            lay.addWidget(t)
            hist = list(reversed(load_history()))
            tbl = QTableWidget(len(hist), 4)
            tbl.setHorizontalHeaderLabels(["Update", "Installed", "Snapshot / Backup", "Result"])
            tbl.verticalHeader().setVisible(False)
            tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
            tbl.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
            for i, h in enumerate(hist):
                act = "Rolled back to" if h.get("action") == "rollback" else "Version"
                tbl.setItem(i, 0, QTableWidgetItem(f"{PRODUCT} — {act} {h.get('version', '?')}"))
                tbl.setItem(i, 1, QTableWidgetItem(h.get("time", "")))
                tbl.setItem(i, 2, QTableWidgetItem(h.get("snapshot_type") or h.get("snapshot_id") or "—"))
                tbl.setItem(i, 3, QTableWidgetItem("Succeeded" if h.get("ok") else "Failed"))
            lay.addWidget(tbl, 1)
            if not hist:
                lay.addWidget(QLabel("No updates have been installed with Alfred Update yet."))

            snaps = list(reversed(load_snapshots()))
            st = QLabel(f"Pre-update restore snapshots ({len(snaps)} available — ZFS / Btrfs / Archive)")
            st.setObjectName("section")
            lay.addWidget(st)
            self.snap_tbl = QTableWidget(len(snaps), 4)
            self.snap_tbl.setHorizontalHeaderLabels(["Snapshot ID", "Versions", "Type", "Created"])
            self.snap_tbl.verticalHeader().setVisible(False)
            self.snap_tbl.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
            self.snap_tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
            self.snaps = snaps
            for i, s in enumerate(snaps):
                self.snap_tbl.setItem(i, 0, QTableWidgetItem(s.get("id", "")))
                self.snap_tbl.setItem(i, 1, QTableWidgetItem(f"v{s.get('from_version', '?')} → v{s.get('to_version', '?')}"))
                self.snap_tbl.setItem(i, 2, QTableWidgetItem(s.get("type", "")))
                self.snap_tbl.setItem(i, 3, QTableWidgetItem(s.get("time", "")))
            if snaps:
                self.snap_tbl.selectRow(0)
            lay.addWidget(self.snap_tbl, 1)

            btn_row = QHBoxLayout()
            self.restore_btn = QPushButton("Restore selected snapshot")
            self.restore_btn.setEnabled(bool(snaps))
            self.restore_btn.clicked.connect(self.on_restore)
            btn_row.addWidget(self.restore_btn)
            btn_row.addStretch(1)
            b = QPushButton("Close")
            b.clicked.connect(self.accept)
            btn_row.addWidget(b)
            lay.addLayout(btn_row)

        def on_restore(self):
            row = self.snap_tbl.currentRow()
            if row < 0 or row >= len(self.snaps):
                return
            snap = self.snaps[row]
            ans = QMessageBox.question(
                self,
                "Restore previous version",
                f"Restore snapshot '{snap.get('id')}' (version {snap.get('from_version')})?\n\n"
                f"Snapshot type: {snap.get('type')}\n"
                f"Target: {snap.get('fs_target')}",
            )
            if ans == QMessageBox.StandardButton.Yes:
                self.accept()
                self.parent_win.start_rollback(snap.get("id"))

    class UpdateWindow(QMainWindow):
        def __init__(self, demo=None):
            super().__init__()
            self.setWindowTitle("Alfred Update")
            self.resize(920, 640)
            self.setMinimumSize(760, 560)
            self.setStyleSheet(QSS)
            self.manifest, self.route, self.worker = None, "direct", None
            self.dl_start, self.dl_total = 0.0, 0

            root = QWidget()
            root.setObjectName("root")
            self.setCentralWidget(root)
            outer = QVBoxLayout(root)
            outer.setContentsMargins(36, 28, 36, 20)
            outer.setSpacing(16)

            title = QLabel("Alfred Update")
            title.setObjectName("title")
            outer.addWidget(title)

            # status card
            card = QFrame()
            card.setObjectName("card")
            cl = QHBoxLayout(card)
            cl.setContentsMargins(24, 22, 24, 22)
            cl.setSpacing(20)
            self.icon = StatusIcon()
            cl.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop)
            txt = QVBoxLayout()
            txt.setSpacing(4)
            self.headline = QLabel("Checking for updates…")
            self.headline.setObjectName("headline")
            self.headline.setWordWrap(True)
            self.sub = QLabel("")
            self.sub.setObjectName("sub")
            self.sub.setWordWrap(True)
            self.progress = QProgressBar()
            self.progress.setTextVisible(False)
            self.progress.setFixedHeight(6)
            self.progress.hide()
            self.progress_txt = QLabel("")
            self.progress_txt.setObjectName("sub")
            self.progress_txt.hide()
            txt.addWidget(self.headline)
            txt.addWidget(self.sub)
            txt.addSpacing(8)
            txt.addWidget(self.progress)
            txt.addWidget(self.progress_txt)
            cl.addLayout(txt, 1)
            btns = QVBoxLayout()
            self.primary = QPushButton("Check for updates")
            self.primary.setObjectName("primary")
            self.primary.setMinimumWidth(190)
            self.primary.clicked.connect(self.on_primary)
            self.secondary = QPushButton("Cancel")
            self.secondary.clicked.connect(self.on_cancel)
            self.secondary.hide()
            btns.addWidget(self.primary)
            btns.addWidget(self.secondary)
            btns.addStretch(1)
            cl.addLayout(btns)
            outer.addWidget(card)

            # steps (visible while updating)
            self.steps_card = QFrame()
            self.steps_card.setObjectName("card")
            sl = QVBoxLayout(self.steps_card)
            sl.setContentsMargins(24, 16, 24, 16)
            self.steps = [StepRow(s) for s in (
                "Download the update",
                "Verify Ed25519 signature and SHA-256 checksum",
                "Create pre-update system snapshot (ZFS / Btrfs / Archive)",
                "Install the update",
                "Finish and clean up",
            )]
            for s in self.steps:
                sl.addWidget(s)
            self.steps_card.hide()
            outer.addWidget(self.steps_card)

            # what's new
            self.notes_card = QFrame()
            self.notes_card.setObjectName("card")
            nl = QVBoxLayout(self.notes_card)
            nl.setContentsMargins(24, 16, 24, 16)
            nt = QLabel("What's new")
            nt.setObjectName("section")
            self.notes = QLabel("")
            self.notes.setWordWrap(True)
            self.notes.setObjectName("sub")
            nl.addWidget(nt)
            nl.addWidget(self.notes)
            self.notes_card.hide()
            outer.addWidget(self.notes_card)

            # technical details
            self.log = QPlainTextEdit()
            self.log.setReadOnly(True)
            self.log.hide()
            outer.addWidget(self.log, 1)
            outer.addStretch(1)

            links = QHBoxLayout()
            hb = QPushButton("Update history & snapshots")
            hb.setObjectName("link")
            hb.clicked.connect(lambda: HistoryDialog(self).exec())
            self.rollback_link = QPushButton("Restore previous version")
            self.rollback_link.setObjectName("link")
            self.rollback_link.clicked.connect(lambda: HistoryDialog(self).exec())
            self.details_btn = QPushButton("Show technical details")
            self.details_btn.setObjectName("link")
            self.details_btn.clicked.connect(self.toggle_details)
            links.addWidget(hb)
            links.addSpacing(24)
            links.addWidget(self.rollback_link)
            links.addSpacing(24)
            links.addWidget(self.details_btn)
            links.addStretch(1)
            outer.addLayout(links)

            kernel = "7.2.1" if demo else os.uname().release
            self.foot = QLabel(f"This device: {PRODUCT}  ·  Version {installed_version()}  ·  Kernel {kernel}")
            self.foot.setObjectName("footer")
            outer.addWidget(self.foot)

            self.poll = QTimer(self)
            self.poll.timeout.connect(self.poll_download)
            if demo:
                self.apply_demo(demo)
            else:
                QTimer.singleShot(200, self.check)

        # ---- helpers
        def logline(self, s):
            self.log.appendPlainText(time.strftime("%H:%M:%S  ") + s)

        def toggle_details(self):
            vis = not self.log.isVisible()
            self.log.setVisible(vis)
            self.details_btn.setText("Hide technical details" if vis else "Show technical details")

        def set_state(self, mode, headline, sub="", primary=None, secondary=None):
            self.icon.set_mode(mode)
            self.headline.setText(headline)
            self.sub.setText(sub)
            self.primary.setVisible(primary is not None)
            if primary:
                self.primary.setText(primary)
                self.primary.setEnabled(True)
            self.secondary.setVisible(secondary is not None)
            if secondary:
                self.secondary.setText(secondary)

        def last_checked(self):
            st = load_state()
            return time.strftime("Last checked: today, %H:%M", time.localtime(st["checked"])) if st.get("checked") else ""

        def show_notes(self, m):
            items = notes_list(m)
            if items:
                self.notes.setText("\n".join("•  " + i for i in items[:12]))
                self.notes_card.show()

        # ---- flow
        def on_primary(self):
            label = self.primary.text()
            if label in ("Check for updates", "Try again"):
                self.check()
            elif label in ("Download and install", "Reinstall"):
                self.download()
            elif label == "Done":
                self.close()

        def on_cancel(self):
            if isinstance(self.worker, DownloadWorker):
                self.worker.cancel()

        def check(self):
            self.steps_card.hide()
            self.progress.hide()
            self.progress_txt.hide()
            self.set_state("busy", "Checking for updates…", "Contacting alfredlinux.com" + (" through Tor" if tor_available() else ""))
            self.logline("Checking " + MANIFEST_URL)
            self.worker = CheckWorker()
            self.worker.result.connect(self.on_checked)
            self.worker.failed.connect(lambda e: (self.logline(e), self.set_state(
                "error", "Couldn't check for updates", e + " Check your internet connection or Tor status, then try again.",
                primary="Try again")))
            self.worker.start()

        def on_checked(self, m, route, ok, msg):
            self.manifest, self.route = m, route
            save_state(checked=time.time())
            self.logline(f"Manifest: version {m.get('version')} via {route}; {msg}")
            if not ok:
                return self.set_state("error", "Update can't be trusted", msg + " Nothing will be installed.", primary="Try again")
            avail, cur = m.get("version", "?"), installed_version()
            size = human_size(int(m.get("size") or 0))
            self.show_notes(m)
            if vtuple(avail) > vtuple(cur):
                self.set_state("update", f"Update available: {PRODUCT} — version {avail}",
                               f"Download size {size}  ·  Signed by Alfred Linux  ·  Auto-snapshot enabled  ·  {self.last_checked()}",
                               primary="Download and install")
            else:
                self.set_state("ok", "You're up to date",
                               f"Version {cur} is the latest version.  {self.last_checked()}",
                               primary="Check for updates", secondary="Reinstall")
                try:
                    self.secondary.clicked.disconnect()
                except TypeError:
                    pass
                self.secondary.clicked.connect(self.download)

        def download(self):
            try:
                self.secondary.clicked.disconnect()
            except TypeError:
                pass
            self.secondary.clicked.connect(self.on_cancel)
            self.steps_card.show()
            for s in self.steps:
                s.set("pending")
            self.steps[0].set("active")
            self.dl_total = int(self.manifest.get("size") or 0)
            self.dl_start = time.time()
            self.progress.setRange(0, 1000)
            self.progress.setValue(0)
            self.progress.show()
            self.progress_txt.show()
            self.set_state("busy", f"Downloading version {self.manifest.get('version')}…",
                           "You can keep using your computer.", secondary="Cancel")
            self.logline(f"Downloading {self.manifest.get('url')} via {self.route}")
            self.worker = DownloadWorker(self.manifest, self.route)
            self.worker.finished_ok.connect(self.on_downloaded)
            self.worker.failed.connect(self.on_download_failed)
            self.worker.start()
            self.poll.start(300)

        def poll_download(self):
            if not isinstance(self.worker, DownloadWorker):
                return
            got = os.path.getsize(self.worker.bpath) if os.path.exists(self.worker.bpath) else 0
            el = max(time.time() - self.dl_start, 0.1)
            speed = got / el
            if self.dl_total:
                frac = min(got / self.dl_total, 1.0)
                self.progress.setValue(int(frac * 1000))
                left = (self.dl_total - got) / speed if speed > 1 else 0
                eta = f"about {int(left // 60)} min left" if left >= 90 else f"about {int(left)} s left"
                self.progress_txt.setText(f"{human_size(got)} of {human_size(self.dl_total)}  ·  {human_size(speed)}/s  ·  {eta}  ·  {int(frac * 100)}%")
            else:
                self.progress_txt.setText(f"{human_size(got)} downloaded")

        def on_download_failed(self, e):
            self.poll.stop()
            self.progress.hide()
            self.progress_txt.hide()
            self.steps[0].set("error")
            if e == "cancelled":
                self.logline("Download cancelled")
                return self.set_state("update", "Update paused", "The download was cancelled. Nothing was installed.",
                                      primary="Download and install")
            self.logline(e)
            self.set_state("error", "The update couldn't be downloaded", e, primary="Try again")

        def on_downloaded(self, mpath, bpath):
            self.poll.stop()
            self.steps[0].set("done")
            self.steps[1].set("done")
            self.logline("Download complete; SHA-256 and signature verified")
            self.progress.setRange(0, 100)
            self.progress.setValue(0)
            self.progress_txt.setText("Creating restore snapshot & preparing to install…")
            self.set_state("busy", f"Installing version {self.manifest.get('version')}…",
                           "Creating a pre-update restore snapshot first. Please don't turn off your computer.")
            self.steps[2].set("active")
            self.worker = InstallWorker(mpath, bpath)
            self.worker.line.connect(self.on_install_line)
            self.worker.done.connect(self.on_installed)
            self.worker.start()

        def on_install_line(self, kind, text):
            if kind == "PROGRESS":
                try:
                    val = int(float(text))
                    self.progress.setValue(val)
                    if val >= 50 and self.steps[2].dot.text() != "✓":
                        self.steps[2].set("done")
                        self.steps[3].set("active")
                except ValueError:
                    pass
            elif kind == "SNAPSHOT":
                self.steps[2].set("done")
                self.steps[3].set("active")
                self.logline("Snapshot created: " + text)
            elif kind == "STEP":
                self.progress_txt.setText(text)
                self.logline(text)
            elif kind == "ERROR":
                self.logline("ERROR: " + text)
                self.progress_txt.setText(text)
            elif text.strip():
                self.logline(text)

        def on_installed(self, rc):
            self.progress.hide()
            self.progress_txt.hide()
            if rc == 0:
                self.steps[2].set("done")
                self.steps[3].set("done")
                self.steps[4].set("done")
                self.foot.setText(f"This device: {PRODUCT}  ·  Version {installed_version()}  ·  Kernel {os.uname().release}")
                self.set_state("ok", "Update installed",
                               f"{PRODUCT} is now at version {self.manifest.get('version')}. "
                               "A pre-update restore snapshot was saved automatically.", primary="Done")
            else:
                self.steps[3].set("error")
                hint = " Administrator permission was not granted." if rc in (126, 127) else ""
                self.set_state("error", "The update couldn't be installed",
                               "Your pre-update snapshot is intact." + hint +
                               " Open technical details or click 'Restore previous version' if needed.", primary="Try again")

        def start_rollback(self, snap_id):
            self.steps_card.hide()
            self.progress.setRange(0, 100)
            self.progress.setValue(10)
            self.progress.show()
            self.progress_txt.setText(f"Restoring snapshot {snap_id}…")
            self.progress_txt.show()
            self.set_state("busy", "Restoring previous version…", f"Rolling back to snapshot {snap_id}…")
            self.worker = RollbackWorker(snap_id)
            self.worker.line.connect(self.on_install_line)
            self.worker.done.connect(self.on_rolled_back)
            self.worker.start()

        def on_rolled_back(self, rc, ver):
            self.progress.hide()
            self.progress_txt.hide()
            if rc == 0:
                self.foot.setText(f"This device: {PRODUCT}  ·  Version {installed_version()}  ·  Kernel {os.uname().release}")
                self.set_state("ok", f"Restored to version {ver}",
                               "Your system and audio settings have been rolled back to the selected snapshot.",
                               primary="Check for updates")
            else:
                self.set_state("error", "Rollback failed",
                               "Open technical details for more information.", primary="Check for updates")

        # ---- demo states for screenshots (no network)
        def apply_demo(self, which):
            m = {"version": "7.5.14", "size": 61992148,
                 "notes_list": ["Installer: ZFS + encryption installs fixed", "Visible Encrypt system option",
                                "Full graphical GRUB menu on installed systems", "Automatic ZFS / Btrfs / Archive snapshot before update",
                                "Audio volume never above 100%"]}
            self.manifest = m
            if which == "uptodate":
                self.set_state("ok", "You're up to date", "Version 7.5.14 is the latest version.  Last checked: today, 09:20",
                               primary="Check for updates", secondary="Reinstall")
            elif which == "available":
                self.show_notes(m)
                self.set_state("update", f"Update available: {PRODUCT} — version 7.5.14",
                               "Download size 59.1 MB  ·  Signed by Alfred Linux  ·  Auto-snapshot enabled",
                               primary="Download and install")
            elif which == "downloading":
                self.steps_card.show()
                self.steps[0].set("active")
                self.progress.setRange(0, 1000)
                self.progress.setValue(420)
                self.progress.show()
                self.progress_txt.setText("24.8 MB of 59.1 MB  ·  1.8 MB/s  ·  about 17 s left  ·  42%")
                self.progress_txt.show()
                self.set_state("busy", "Downloading version 7.5.14…", "You can keep using your computer.", secondary="Cancel")
            elif which == "done":
                self.steps_card.show()
                for s in self.steps:
                    s.set("done")
                self.set_state("ok", "Update installed", f"{PRODUCT} is now at version 7.5.14. "
                               "A pre-update restore snapshot was saved automatically.", primary="Done")

    app = QApplication(sys.argv)
    app.setApplicationName("Alfred Update")
    app.setFont(QFont("Noto Sans", 10))
    if os.path.exists("/usr/share/icons/hicolor/256x256/apps/alfred-logo.png"):
        app.setWindowIcon(QIcon("/usr/share/icons/hicolor/256x256/apps/alfred-logo.png"))
    if "--demo-screens" in sys.argv:
        out = sys.argv[sys.argv.index("--demo-screens") + 1]
        os.makedirs(out, exist_ok=True)
        for state in ("uptodate", "available", "downloading", "done"):
            w = UpdateWindow(demo=state)
            w.show()
            app.processEvents()
            w.icon.t.stop()
            w.grab().save(os.path.join(out, f"update_{state}.png"))
            w.close()
        return 0
    w = UpdateWindow()
    w.show()
    return app.exec()


def main():
    if "--check" in sys.argv:
        try:
            m, route = fetch_manifest()
            ok, msg = verify_manifest(m)
            print(json.dumps({"ok": ok, "message": msg, "route": route, "installed": installed_version(),
                              "available": m.get("version"), "snapshots": len(load_snapshots())}, indent=2))
            return 0 if ok else 1
        except RuntimeError as e:
            print(json.dumps({"ok": False, "message": str(e)}))
            return 1
    if "--cli" in sys.argv or "--snapshots" in sys.argv or "--rollback" in sys.argv or (
        sys.stdout.isatty() and "--gui" not in sys.argv and "--demo-screens" not in sys.argv
    ):
        return run_cli()
    return run_gui()


if __name__ == "__main__":
    sys.exit(main())
