#!/usr/bin/python3
"""Manual Garry USB updates. GUI never decides trust or writes the USB itself."""
import json
import os
from pathlib import Path
import queue
import re
import stat
import subprocess
import tempfile
import threading
import time

CORE = Path("/usr/local/bin/garry-usb-update-core")
PKEXEC = Path("/usr/bin/pkexec")
MAX_OUTPUT = 1024 * 1024
TIMEOUT = {"check": 90, "stage": 900, "apply": 900}
SUCCESS = {"available", "already_current", "pending_reboot", "downloaded", "installed_pending_reboot"}


def inert(value, limit=5000):
    text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(value))
    return "".join(c if c.isprintable() or c in "\n\t" else " " for c in text)[:limit]


def trusted_executable(path):
    try:
        if not path.is_absolute():
            return False
        for ancestor in (path.parent, *path.parent.parents):
            parent_meta = ancestor.lstat()
            if not stat.S_ISDIR(parent_meta.st_mode) or parent_meta.st_uid != 0 or parent_meta.st_mode & 0o022:
                return False
        meta = path.lstat()
        return stat.S_ISREG(meta.st_mode) and meta.st_uid == 0 and not meta.st_mode & 0o022 and os.access(path, os.X_OK)
    except OSError:
        return False


def result_from_output(code, output):
    if len(output) > MAX_OUTPUT:
        return {"ok": False, "status": "error", "message": "The updater returned more output than expected. No completion is confirmed."}
    try:
        result = json.loads(output)
    except (ValueError, UnicodeError):
        message = "Administrator authentication was cancelled or declined." if code in (126, 127) else "No valid result was received. Installation status is unconfirmed; check again before retrying."
        return {"ok": False, "status": "error", "message": message}
    if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
        return {"ok": False, "status": "error", "message": "The updater result was not recognized. No completion is confirmed."}
    if code != 0 or not result["ok"]:
        return {"ok": False, "status": "error", "message": inert(result.get("message", "The updater could not complete this step.")), "code": inert(result.get("code", ""), 100)}
    if result.get("status") not in SUCCESS:
        return {"ok": False, "status": "error", "message": "Unknown update state. No completion is confirmed."}
    return result


def run_core(action, stage_dir=None):
    if action not in TIMEOUT or not trusted_executable(CORE):
        return {"ok": False, "status": "error", "message": "The installed updater is missing or its permissions need repair. Run the reviewed first-install kit."}
    args = [str(CORE), action]
    if action == "apply":
        if not stage_dir or not Path(stage_dir).is_absolute():
            return {"ok": False, "status": "error", "message": "Download and verify the update first."}
        if not trusted_executable(PKEXEC):
            return {"ok": False, "status": "error", "message": "The administrator prompt is unavailable. This step needs pkexec; installation has not started."}
        args = [str(PKEXEC), *args, "--stage-dir", str(stage_dir)]
    try:
        with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
            process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=output, stderr=errors, close_fds=True)
            deadline = time.monotonic() + TIMEOUT[action]
            while process.poll() is None:
                too_large = os.fstat(output.fileno()).st_size > MAX_OUTPUT or os.fstat(errors.fileno()).st_size > MAX_OUTPUT
                if time.monotonic() > deadline or too_large:
                    if action == "apply":
                        # Never kill a privileged media transaction. The core
                        # owns its rollback and mount cleanup. Its inherited
                        # output descriptors remain valid after this UI stops
                        # waiting, and the next check can read durable state.
                        return {"ok": False, "status": "error", "message": "Installation is taking longer than expected and was left running. Do not restart or unplug the USB. Use Check for updates to establish the result before retrying."}
                    process.terminate()
                    try:
                        process.wait(timeout=5)
                    except subprocess.TimeoutExpired:
                        process.kill()
                        process.wait(timeout=5)
                    return {"ok": False, "status": "error", "message": "The operation exceeded its time or output limit. Its final state is unconfirmed; use Check for updates before retrying."}
                time.sleep(0.2)
            output.seek(0)
            return result_from_output(process.returncode, output.read(MAX_OUTPUT + 1))
    except (OSError, subprocess.SubprocessError):
        return {"ok": False, "status": "error", "message": "The updater could not be contacted. No completion is confirmed."}


def release_text(result):
    parts = []
    if result.get("release"):
        parts.append("Release: " + inert(result["release"], 150))
    if isinstance(result.get("sequence"), int):
        parts.append("Release sequence: " + str(result["sequence"]))
    if isinstance(result.get("payload_bytes"), int) and result["payload_bytes"] >= 0:
        parts.append(f'Download: {result["payload_bytes"] / (1024 * 1024):.1f} MiB')
    if result.get("notes"):
        notes = result["notes"]
        parts.append("\n" + inert("\n".join(str(n) for n in notes) if isinstance(notes, list) else notes))
    return "\n".join(parts)


def ui_state(result):
    """Conservative presentation: downloaded is never installed."""
    if not result.get("ok"):
        return "Needs attention", inert(result.get("message", "Update could not complete.")), False, False
    status = result["status"]
    if status == "available":
        return "Update available", "Review this release, then download it. The core verifies its signature and payload.", True, False
    if status == "downloaded":
        ready = isinstance(result.get("stage_dir"), str) and Path(result["stage_dir"]).is_absolute()
        return ("Downloaded and verified", "Ready to install for your next restart. Administrator authentication is required." if ready else "The downloaded stage location is missing. Check again.", False, ready)
    if status in {"installed_pending_reboot", "pending_reboot"}:
        if status == "installed_pending_reboot" and not result.get("receipt"):
            return "Installation needs verification", "No installation receipt was returned. Check again before restarting or retrying.", False, False
        return "Restart when ready", "An update is installed for a later boot. Save your work and restart when convenient. This app will not restart the computer.", False, False
    return "Already current", "The updater reports the current release is already installed. This is not a full hardware or boot-health test.", False, False


def main():
    import tkinter as tk
    from tkinter import messagebox, ttk
    window = tk.Tk()
    window.title("Garry Updates")
    window.geometry("760x590")
    window.minsize(580, 470)
    window.configure(bg="#071425")
    style = ttk.Style(window)
    style.theme_use("clam")
    style.configure("Garry.TFrame", background="#071425")
    style.configure("Garry.TLabel", background="#071425", foreground="#e5f1ff", font=("DejaVu Sans", 11))
    style.configure("Title.TLabel", background="#071425", foreground="#7cdbff", font=("DejaVu Sans", 23, "bold"))
    body = ttk.Frame(window, padding=24, style="Garry.TFrame")
    body.pack(fill="both", expand=True)
    ttk.Label(body, text="GARRY  /  UPDATES", style="Title.TLabel").pack(anchor="w")
    ttk.Label(body, text="Your portable desktop, maintained from here.", style="Garry.TLabel").pack(anchor="w", pady=(4, 16))
    state_label = ttk.Label(body, text="Ready to check", style="Garry.TLabel")
    state_label.pack(anchor="w")
    explanation = ttk.Label(body, text="Connect to the internet, then check for an update. Nothing installs automatically.", wraplength=680, style="Garry.TLabel")
    explanation.pack(anchor="w", fill="x", pady=(8, 12))
    details = tk.Text(body, height=11, wrap="word", bg="#102239", fg="#e5f1ff", insertbackground="#e5f1ff", relief="flat", padx=12, pady=12, font=("DejaVu Sans", 10))
    details.pack(fill="both", expand=True)
    details.insert("1.0", "This updater changes only the supported Garry USB components. It does not repartition disks or modify Windows. Writable full-system persistence and supported writable boot media are required.")
    details.configure(state="disabled")
    progress = ttk.Progressbar(body, mode="indeterminate")
    progress.pack(fill="x", pady=(14, 10))
    controls = ttk.Frame(body, style="Garry.TFrame")
    controls.pack(fill="x")
    results = queue.Queue()
    state = {"busy": False, "result": None, "stage_dir": None}

    def start(action):
        if state["busy"]:
            return
        if action == "apply" and not messagebox.askokcancel("Install Garry update", "Install this downloaded update for your next restart?\n\nThe trusted updater will reverify the signed files and may ask for administrator authentication. Your computer will not restart automatically.", parent=window):
            return
        state["busy"] = True
        for button in (check_button, download_button, install_button):
            button.configure(state="disabled")
        progress.start(12)
        state_label.configure(text={"check": "Checking signed release…", "stage": "Downloading and verifying…", "apply": "Installing for your next restart…"}[action])
        explanation.configure(text="Keep this window open. Your desktop remains usable while this step runs.")
        threading.Thread(target=lambda: results.put(run_core(action, state["stage_dir"])), daemon=True).start()

    check_button = ttk.Button(controls, text="Check for updates", command=lambda: start("check"))
    download_button = ttk.Button(controls, text="Download update", command=lambda: start("stage"), state="disabled")
    install_button = ttk.Button(controls, text="Install for next restart", command=lambda: start("apply"), state="disabled")
    for button in (check_button, download_button, install_button):
        button.pack(side="left", padx=(0, 8), pady=4)

    def poll():
        try:
            result = results.get_nowait()
        except queue.Empty:
            pass
        else:
            state["busy"] = False
            state["result"] = result
            state["stage_dir"] = result.get("stage_dir") if result.get("status") == "downloaded" else None
            progress.stop()
            title, description, download, install = ui_state(result)
            state_label.configure(text=title)
            explanation.configure(text=description)
            check_button.configure(state="normal")
            download_button.configure(state="normal" if download else "disabled")
            install_button.configure(state="normal" if install else "disabled")
            details.configure(state="normal")
            details.delete("1.0", "end")
            details.insert("1.0", release_text(result) or description)
            details.configure(state="disabled")
        window.after(150, poll)

    def close():
        if state["busy"]:
            messagebox.showinfo("Update in progress", "Keep this window open until the current step finishes. Closing it now would hide the outcome.", parent=window)
            return
        window.destroy()

    window.protocol("WM_DELETE_WINDOW", close)
    window.after(150, poll)
    window.mainloop()


if __name__ == "__main__":
    main()
