"""Cortaly Surfbar Restarter — GUI.

A settings window around restarter_core's watchdog logic: username,
which browser to reopen the surf link in, check interval, "start
automatically at login", and a guided browser-extension setup — plus
Start/Stop and a live log.
"""

import sys
import os

# Must happen BEFORE `import tkinter`: works around a real, documented
# PyInstaller issue where a frozen onefile Linux build can fail to find
# its own bundled Tcl/Tk data directories at runtime. Harmless no-op when
# not frozen (the `if` is false) or on Windows/macOS (PyInstaller's own
# Tkinter hook already handles those there).
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
    os.environ.setdefault("TCL_LIBRARY", os.path.join(sys._MEIPASS, "tcl8.6"))
    os.environ.setdefault("TK_LIBRARY", os.path.join(sys._MEIPASS, "tk8.6"))

import json
import queue
import tempfile
import threading
import time
import tkinter as tk
import tkinter.font as tkfont
from tkinter import messagebox, ttk

import restarter_core as core

CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".cortaly-restarter")
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")

DEFAULT_CONFIG = {
    "username": "",
    "browser": "System Default",
    "interval": 30,
    "cooldown": 90,
    "base_url": core.DEFAULT_BASE_URL,
    "autostart": False,
    "extension_prompted": False,
}

# ------------------------------------------------------------------ theme
# Same dark "Exchange Terminal" palette as the web app's app.css :root
# tokens — kept as plain hex here since this is a separate Tk process with
# no access to the site's CSS custom properties.
COLOR_BG = "#07080b"
COLOR_SURFACE = "#12151b"
COLOR_SURFACE_ALT = "#1b1f27"
COLOR_SURFACE_HI = "#232833"
COLOR_BORDER = "#2b313b"
COLOR_BORDER_STRONG = "#444c5a"
COLOR_TEXT = "#f2f4f8"
COLOR_TEXT_MUTED = "#a4aebc"
COLOR_PRIMARY = "#f7a81e"
COLOR_PRIMARY_HOVER = "#ffbb33"
COLOR_PRIMARY_DEEP = "#a86a0c"
COLOR_ON_PRIMARY = "#140f04"
COLOR_SUCCESS = "#c2f24d"
COLOR_DANGER = "#ff4d57"

ICON_FILENAME = "icon.png"


def load_config():
    if os.path.isfile(CONFIG_PATH):
        try:
            with open(CONFIG_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            merged = dict(DEFAULT_CONFIG)
            merged.update(data)
            return merged
        except (json.JSONDecodeError, OSError):
            pass
    return dict(DEFAULT_CONFIG)


def save_config(config):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_PATH, "w", encoding="utf-8") as f:
        json.dump(config, f, indent=2)


def _pick_font(preferred, fallback):
    """First font from `preferred` that's actually installed, else a Tk
    named-font alias that always resolves to something reasonable — avoids
    hardcoding e.g. "Menlo" (macOS-only) and silently getting Tk's ugly
    default serif substitute on Windows/Linux."""
    available = set(tkfont.families())
    for name in preferred:
        if name in available:
            return name
    return fallback


class RestarterApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Cortaly Restarter")
        self.root.geometry("620x640")
        self.root.minsize(560, 560)

        self.sans_font = _pick_font(["Helvetica Neue", "Helvetica", "Segoe UI", "DejaVu Sans"], "TkDefaultFont")
        self.mono_font = _pick_font(["Menlo", "Consolas", "DejaVu Sans Mono", "Courier New"], "TkFixedFont")

        self.config = load_config()
        self.log_queue = queue.Queue()
        self.watchdog_thread = None
        self.stop_event = threading.Event()
        # A quick Stop -> Start could otherwise race: the old thread may be
        # blocked inside an in-flight check_status() call when Stop sets
        # stop_event, and if Start clears it again before the old thread
        # gets back around to checking, that stale thread would wrongly
        # read "not stopped" and keep looping alongside the new one. Each
        # thread captures its own generation number at creation and keeps
        # running only while it still matches the current one.
        self.watchdog_generation = 0

        self._configure_style()
        self._set_window_icon()
        self._build_ui()
        self._sync_autostart_checkbox()
        self._refresh_extension_status()
        self._poll_log_queue()
        self.root.after(400, self._maybe_show_first_run_extension_prompt)
        self.root.after(900, self._check_for_updates_async)

    # ------------------------------------------------------------ theme

    def _configure_style(self):
        self.root.configure(background=COLOR_BG)
        style = ttk.Style(self.root)
        # 'clam' is a pure-Tk (non-native) theme available identically on
        # every platform — needed because the native themes (aqua on macOS,
        # vista on Windows) largely ignore custom widget colors, which
        # would leave this dark palette only half-applied.
        style.theme_use("clam")

        style.configure("TFrame", background=COLOR_BG)
        style.configure("Header.TFrame", background=COLOR_SURFACE)
        style.configure("TLabel", background=COLOR_BG, foreground=COLOR_TEXT, font=(self.sans_font, 11))
        style.configure("Muted.TLabel", background=COLOR_BG, foreground=COLOR_TEXT_MUTED, font=(self.sans_font, 9))
        style.configure("Section.TLabel", background=COLOR_BG, foreground=COLOR_PRIMARY,
                         font=(self.mono_font, 10, "bold"))
        style.configure("Header.TLabel", background=COLOR_SURFACE, foreground=COLOR_TEXT,
                         font=(self.mono_font, 14, "bold"))
        style.configure("Status.TLabel", background=COLOR_BG, foreground=COLOR_TEXT_MUTED, font=(self.mono_font, 9))
        style.configure("StatusDotRunning.TLabel", background=COLOR_BG, foreground=COLOR_SUCCESS, font=(self.sans_font, 12, "bold"))
        style.configure("StatusDotStopped.TLabel", background=COLOR_BG, foreground=COLOR_TEXT_MUTED, font=(self.sans_font, 12, "bold"))

        style.configure(
            "TEntry",
            fieldbackground=COLOR_SURFACE_ALT, foreground=COLOR_TEXT,
            insertcolor=COLOR_TEXT, bordercolor=COLOR_BORDER_STRONG,
            lightcolor=COLOR_SURFACE_ALT, darkcolor=COLOR_SURFACE_ALT,
            borderwidth=1, relief="flat", padding=6,
        )
        style.map("TEntry", bordercolor=[("focus", COLOR_PRIMARY)])

        style.configure(
            "TCombobox",
            fieldbackground=COLOR_SURFACE_ALT, background=COLOR_SURFACE_ALT,
            foreground=COLOR_TEXT, arrowcolor=COLOR_TEXT_MUTED,
            bordercolor=COLOR_BORDER_STRONG, lightcolor=COLOR_SURFACE_ALT,
            darkcolor=COLOR_SURFACE_ALT, borderwidth=1, relief="flat", padding=6,
        )
        style.map(
            "TCombobox",
            fieldbackground=[("readonly", COLOR_SURFACE_ALT)],
            foreground=[("readonly", COLOR_TEXT)],
            bordercolor=[("focus", COLOR_PRIMARY)],
        )
        # ttk doesn't expose the combobox popdown listbox via style options —
        # it's a plain Tk Listbox underneath, styled through the option db.
        self.root.option_add("*TCombobox*Listbox.background", COLOR_SURFACE_ALT)
        self.root.option_add("*TCombobox*Listbox.foreground", COLOR_TEXT)
        self.root.option_add("*TCombobox*Listbox.selectBackground", COLOR_PRIMARY)
        self.root.option_add("*TCombobox*Listbox.selectForeground", COLOR_ON_PRIMARY)
        self.root.option_add("*TCombobox*Listbox.font", (self.sans_font, 10))

        style.configure("TCheckbutton", background=COLOR_BG, foreground=COLOR_TEXT, font=(self.sans_font, 10))
        style.map(
            "TCheckbutton",
            background=[("active", COLOR_BG)],
            indicatorcolor=[("selected", COLOR_PRIMARY), ("!selected", COLOR_SURFACE_ALT)],
        )

        style.configure(
            "Primary.TButton",
            background=COLOR_PRIMARY, foreground=COLOR_ON_PRIMARY,
            font=(self.sans_font, 10, "bold"), borderwidth=0, padding=(18, 9),
        )
        style.map(
            "Primary.TButton",
            background=[("disabled", COLOR_BORDER), ("active", COLOR_PRIMARY_HOVER)],
            foreground=[("disabled", COLOR_TEXT_MUTED)],
        )

        style.configure(
            "Secondary.TButton",
            background=COLOR_SURFACE_ALT, foreground=COLOR_TEXT,
            font=(self.sans_font, 10, "bold"), borderwidth=1,
            bordercolor=COLOR_BORDER_STRONG, padding=(18, 9),
        )
        style.map(
            "Secondary.TButton",
            background=[("disabled", COLOR_SURFACE), ("active", COLOR_SURFACE_HI)],
            foreground=[("disabled", COLOR_TEXT_MUTED)],
        )

        style.configure(
            "Vertical.TScrollbar",
            background=COLOR_SURFACE_ALT, troughcolor=COLOR_BG,
            bordercolor=COLOR_BORDER, arrowcolor=COLOR_TEXT_MUTED, relief="flat",
        )

    def _icon_path(self):
        candidates = [
            os.path.join(getattr(sys, "_MEIPASS", ""), ICON_FILENAME) if getattr(sys, "frozen", False) else None,
            os.path.join(os.path.dirname(os.path.abspath(__file__)), ICON_FILENAME),
        ]
        for path in candidates:
            if path and os.path.isfile(path):
                return path
        return None

    def _set_window_icon(self):
        path = self._icon_path()
        if not path:
            return
        try:
            # Kept as an attribute — PhotoImage is garbage-collected (and the
            # icon silently disappears) the moment nothing still references it.
            self._icon_image = tk.PhotoImage(file=path)
            self.root.iconphoto(True, self._icon_image)
        except tk.TclError:
            pass

    # ------------------------------------------------------------- UI

    def _section_label(self, parent, text):
        ttk.Label(parent, text=f"// {text}", style="Section.TLabel").pack(anchor="w", pady=(14, 4))

    def _build_ui(self):
        # ---- header bar: logo mark + wordmark, on the lighter surface tone
        header = ttk.Frame(self.root, style="Header.TFrame", padding=(14, 12))
        header.pack(fill="x")
        icon_path = self._icon_path()
        if icon_path:
            try:
                self._header_icon = tk.PhotoImage(file=icon_path).subsample(4, 4)
                ttk.Label(header, image=self._header_icon, background=COLOR_SURFACE).pack(side="left", padx=(0, 10))
            except tk.TclError:
                pass
        ttk.Label(header, text="CORTALY RESTARTER", style="Header.TLabel").pack(side="left")

        body = ttk.Frame(self.root, padding=16)
        body.pack(fill="both", expand=True)
        body.columnconfigure(1, weight=1)

        self._section_label(body, "ACCOUNT")
        form = ttk.Frame(body)
        form.pack(fill="x")
        form.columnconfigure(1, weight=1)

        ttk.Label(form, text="Cortaly username").grid(row=0, column=0, sticky="w", pady=4)
        self.username_var = tk.StringVar(value=self.config["username"])
        ttk.Entry(form, textvariable=self.username_var).grid(row=0, column=1, sticky="ew", pady=4, padx=(10, 0))

        ttk.Label(form, text="Open surf link in").grid(row=1, column=0, sticky="w", pady=4)
        self.browser_var = tk.StringVar(value=self.config["browser"])
        browsers = core.detect_installed_browsers()
        if self.config["browser"] not in browsers:
            browsers = browsers + [self.config["browser"]]
        self.browser_combo = ttk.Combobox(form, textvariable=self.browser_var, values=browsers, state="readonly")
        self.browser_combo.grid(row=1, column=1, sticky="ew", pady=4, padx=(10, 0))
        self.browser_combo.bind("<<ComboboxSelected>>", lambda _e: self._refresh_extension_status())

        self._section_label(body, "WATCHDOG")
        watchdog_form = ttk.Frame(body)
        watchdog_form.pack(fill="x")
        watchdog_form.columnconfigure(1, weight=1)

        ttk.Label(watchdog_form, text="Check interval (seconds)").grid(row=0, column=0, sticky="w", pady=4)
        self.interval_var = tk.StringVar(value=str(self.config["interval"]))
        ttk.Entry(watchdog_form, textvariable=self.interval_var, width=10).grid(row=0, column=1, sticky="w", pady=4, padx=(10, 0))

        ttk.Label(watchdog_form, text="Cooldown between restarts (seconds)").grid(row=1, column=0, sticky="w", pady=4)
        self.cooldown_var = tk.StringVar(value=str(self.config["cooldown"]))
        ttk.Entry(watchdog_form, textvariable=self.cooldown_var, width=10).grid(row=1, column=1, sticky="w", pady=4, padx=(10, 0))

        self.autostart_var = tk.BooleanVar(value=self.config["autostart"])
        ttk.Checkbutton(
            watchdog_form, text="Start automatically when I log in",
            variable=self.autostart_var, command=self._on_autostart_toggle,
        ).grid(row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))

        controls = ttk.Frame(body, padding=(0, 14, 0, 0))
        controls.pack(fill="x")
        self.start_btn = ttk.Button(controls, text="START", style="Primary.TButton", command=self.start_watchdog)
        self.start_btn.pack(side="left")
        self.stop_btn = ttk.Button(controls, text="STOP", style="Secondary.TButton", state="disabled", command=self.stop_watchdog)
        self.stop_btn.pack(side="left", padx=(8, 0))

        status_frame = ttk.Frame(controls)
        status_frame.pack(side="right")
        self.status_dot_var = tk.StringVar(value="●")
        self.status_dot_label = ttk.Label(status_frame, textvariable=self.status_dot_var, style="StatusDotStopped.TLabel")
        self.status_dot_label.pack(side="left")
        self.status_var = tk.StringVar(value="Stopped.")
        ttk.Label(status_frame, textvariable=self.status_var, style="Status.TLabel").pack(side="left", padx=(6, 0))

        self._section_label(body, "BROWSER EXTENSION")
        ext_frame = ttk.Frame(body)
        ext_frame.pack(fill="x")
        self.extension_status_var = tk.StringVar(value="Checking...")
        ttk.Label(ext_frame, textvariable=self.extension_status_var, style="Muted.TLabel", wraplength=440, justify="left").pack(side="left", fill="x", expand=True)
        self.extension_btn = ttk.Button(ext_frame, text="Set up", style="Secondary.TButton", command=self._setup_extension)
        self.extension_btn.pack(side="right")

        self._section_label(body, "LOG")
        log_frame = ttk.Frame(body)
        log_frame.pack(fill="both", expand=True, pady=(0, 4))
        self.log_text = tk.Text(
            log_frame, height=10, state="disabled", wrap="word",
            background=COLOR_SURFACE, foreground=COLOR_TEXT_MUTED,
            insertbackground=COLOR_TEXT, font=(self.mono_font, 9),
            relief="flat", borderwidth=0, highlightthickness=1,
            highlightbackground=COLOR_BORDER, highlightcolor=COLOR_BORDER_STRONG,
            padx=8, pady=8,
        )
        self.log_text.pack(side="left", fill="both", expand=True)
        scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview)
        scrollbar.pack(side="right", fill="y")
        self.log_text["yscrollcommand"] = scrollbar.set

    def _sync_autostart_checkbox(self):
        # The registered state (registry/plist/.desktop file) is the
        # source of truth, not just whatever config.json last said — in
        # case it was changed outside the app or the file was removed.
        actual = core.is_autostart_enabled()
        if actual != self.autostart_var.get():
            self.autostart_var.set(actual)

    # ---------------------------------------------------------- logging

    def _append_log(self, message):
        self.log_text["state"] = "normal"
        self.log_text.insert("end", f"{time.strftime('%H:%M:%S')}  {message}\n")
        self.log_text.see("end")
        self.log_text["state"] = "disabled"

    def _poll_log_queue(self):
        try:
            while True:
                message = self.log_queue.get_nowait()
                self._append_log(message)
        except queue.Empty:
            pass
        self.root.after(200, self._poll_log_queue)

    # ------------------------------------------------------- start/stop

    def _current_settings(self):
        try:
            interval = max(5, int(self.interval_var.get()))
        except ValueError:
            interval = DEFAULT_CONFIG["interval"]
        try:
            cooldown = max(0, int(self.cooldown_var.get()))
        except ValueError:
            cooldown = DEFAULT_CONFIG["cooldown"]
        return {
            "username": self.username_var.get().strip(),
            "browser": self.browser_var.get(),
            "interval": interval,
            "cooldown": cooldown,
            "base_url": self.config.get("base_url", core.DEFAULT_BASE_URL),
            "autostart": self.autostart_var.get(),
            "extension_prompted": self.config.get("extension_prompted", False),
        }

    def start_watchdog(self):
        settings = self._current_settings()
        if not settings["username"]:
            messagebox.showerror("Cortaly Restarter", "Please enter your Cortaly username first.")
            return

        self.config = settings
        save_config(self.config)

        self.stop_event.clear()
        self.watchdog_generation += 1
        my_generation = self.watchdog_generation
        self.watchdog_thread = threading.Thread(target=self._watchdog_loop, args=(settings, my_generation), daemon=True)
        self.watchdog_thread.start()

        self.start_btn["state"] = "disabled"
        self.stop_btn["state"] = "normal"
        self.status_dot_label.configure(style="StatusDotRunning.TLabel")
        self.status_var.set(f"Watching {settings['base_url']}/surf/{settings['username']} ...")
        self.log_queue.put(f"Started — checking every {settings['interval']}s, restarting via {settings['browser']}.")

    def stop_watchdog(self):
        self.stop_event.set()
        self.watchdog_generation += 1  # invalidates any thread still in flight, even if Start clears stop_event again right after
        self.start_btn["state"] = "normal"
        self.stop_btn["state"] = "disabled"
        self.status_dot_label.configure(style="StatusDotStopped.TLabel")
        self.status_var.set("Stopped.")
        self.log_queue.put("Stopped.")

    def _watchdog_loop(self, settings, my_generation):
        # Runs on a background thread — must never touch a Tkinter widget
        # directly (only self.log_queue.put, which is thread-safe); the
        # main thread's _poll_log_queue is what actually updates the UI.
        last_restart_at = None
        while self.watchdog_generation == my_generation:
            try:
                status = core.check_status(settings["base_url"], settings["username"])
                if not status.get("ok"):
                    self.log_queue.put(f"Server rejected the status check ({status.get('error')}) — check the username.")
                elif status.get("stalled"):
                    now = time.monotonic()
                    if last_restart_at is None or now - last_restart_at >= settings["cooldown"]:
                        surf_url = f"{settings['base_url']}/surf/{settings['username']}"
                        self.log_queue.put(f"Stalled — opening {surf_url} in {settings['browser']}.")
                        core.open_url(surf_url, settings["browser"])
                        last_restart_at = now
                    else:
                        remaining = int(settings["cooldown"] - (now - last_restart_at))
                        self.log_queue.put(f"Stalled, but still in cooldown ({remaining}s left).")
                else:
                    self.log_queue.put(f"Active (last activity {status.get('seconds_since_activity')}s ago).")
            except Exception as error:  # a watchdog must never die from one bad cycle
                self.log_queue.put(f"Check failed: {error}")

            # Interruptible sleep so Stop takes effect immediately instead
            # of waiting out the rest of the current interval.
            self.stop_event.wait(settings["interval"])

    # ------------------------------------------------------------ misc

    def _on_autostart_toggle(self):
        enabled = self.autostart_var.get()
        try:
            core.set_autostart(enabled)
            self.config["autostart"] = enabled
            save_config(self.config)
            self.log_queue.put(f"Autostart {'enabled' if enabled else 'disabled'}.")
        except Exception as error:
            self.autostart_var.set(not enabled)
            messagebox.showerror("Cortaly Restarter", f"Could not update autostart setting:\n{error}")

    # ---------------------------------------------------- browser extension

    def _extension_target_browser(self):
        """Which browser to drive the guided-install flow for — the user's
        "Open surf link in" choice if it's one the flow actually supports,
        otherwise ask (covers "System Default"/Safari/an unrecognized
        leftover value, and the case where nothing is selected yet)."""
        current = self.browser_var.get()
        if current in core.EXTENSION_SUPPORTED_BROWSERS:
            return current

        candidates = [b for b in core.detect_installed_browsers() if b in core.EXTENSION_SUPPORTED_BROWSERS]
        if not candidates:
            messagebox.showinfo(
                "Cortaly Restarter",
                "The browser extension needs Chrome, Edge, or Firefox — none of those were "
                "found installed. Install one of them, then try again.",
            )
            return None
        if len(candidates) == 1:
            return candidates[0]
        return self._ask_browser_choice(candidates)

    def _ask_browser_choice(self, candidates):
        """Small modal picker — only shown when there's a genuine choice to
        make (2+ supported browsers installed and none clearly selected)."""
        dialog = tk.Toplevel(self.root)
        dialog.title("Which browser?")
        dialog.configure(background=COLOR_SURFACE)
        dialog.transient(self.root)
        dialog.grab_set()
        ttk.Label(dialog, text="Set up the extension for which browser?", padding=(16, 16, 16, 8)).pack()
        chosen = tk.StringVar(value=candidates[0])
        combo = ttk.Combobox(dialog, textvariable=chosen, values=candidates, state="readonly")
        combo.pack(padx=16, fill="x")
        result = {}

        def confirm():
            result["choice"] = chosen.get()
            dialog.destroy()

        ttk.Button(dialog, text="Continue", style="Primary.TButton", command=confirm).pack(pady=16)
        dialog.wait_window()
        return result.get("choice")

    def _refresh_extension_status(self):
        target = self.browser_var.get()
        if target not in core.EXTENSION_SUPPORTED_BROWSERS and target != "System Default":
            pass  # leftover custom value from an old config — still show the generic message below
        source = core.bundled_extension_source_dir()
        if not source:
            self.extension_status_var.set("Extension files not found in this build.")
            self.extension_btn["state"] = "disabled"
            return
        already = os.path.isdir(core.EXTENSION_INSTALL_DIR)
        if already:
            self.extension_status_var.set(
                "Extracted and ready. Click Set up again any time to reopen the browser's extensions page."
            )
        else:
            self.extension_status_var.set(
                "Surfs in a real tab instead of an embedded frame, so it also works on sites that "
                "block framing. One-time setup, about 30 seconds."
            )
        self.extension_btn["state"] = "normal"

    def _setup_extension(self):
        browser_choice = self._extension_target_browser()
        if not browser_choice:
            return

        paths = core.install_extension_files()
        if not paths:
            messagebox.showerror("Cortaly Restarter", "Couldn't find the bundled extension files in this build.")
            return

        target_dir = core.extension_dir_for_browser(paths, browser_choice)
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(target_dir)
        except tk.TclError:
            pass

        page_url = core.extensions_page_url(browser_choice)
        if page_url:
            core.open_url(page_url, browser_choice)

        self.config["extension_prompted"] = True
        save_config(self.config)
        self.log_queue.put(f"Extension extracted to {target_dir} — folder path copied to clipboard.")
        self._refresh_extension_status()

        step_two = (
            'Click "Load Temporary Add-on…" and pick manifest.json from the folder'
            if browser_choice == "Firefox"
            else 'Click "Load unpacked" and select the folder'
        )
        messagebox.showinfo(
            "Finish setting up the extension",
            f"The extension folder is ready and its path is already on your clipboard:\n\n{target_dir}\n\n"
            f"In the {browser_choice} tab that just opened:\n"
            f"1. Turn on Developer mode (top-right switch)\n"
            f"2. {step_two}\n"
            f"3. Paste the path (⌘V / Ctrl+V) and confirm\n\n"
            "That's it — browsers don't allow installing extensions silently, this last click is "
            "the only manual step.",
        )

    def _maybe_show_first_run_extension_prompt(self):
        if self.config.get("extension_prompted"):
            return
        if not core.bundled_extension_source_dir():
            return
        self.config["extension_prompted"] = True
        save_config(self.config)
        if messagebox.askyesno(
            "Cortaly Restarter",
            "Set up the Cortaly browser extension now? It surfs in a real tab instead of an "
            "embedded frame, so it also works on sites that block framing — takes about 30 seconds.\n\n"
            "You can always do this later from the Browser Extension section.",
        ):
            self._setup_extension()

    # -------------------------------------------------------- self-update

    def _check_for_updates_async(self):
        # Network I/O — never on the main/UI thread. root.after(0, ...)
        # hands the result back to the main thread, same pattern as the
        # watchdog's log_queue, since Tk widgets aren't thread-safe.
        def worker():
            manifest = core.check_for_update(self.config.get("base_url", core.DEFAULT_BASE_URL))
            self.root.after(0, self._on_update_check_result, manifest)

        threading.Thread(target=worker, daemon=True).start()

    def _on_update_check_result(self, manifest):
        if not manifest:
            return
        if not core.can_self_update():
            # Linux (no single artifact to swap in — see restarter_core.py's
            # can_self_update()) and a macOS dev-mode run both land here:
            # just point at the download page, no auto-download attempted.
            self.log_queue.put(
                f"Version {manifest['version']} is available: {manifest.get('info_url', '')}"
            )
            return
        self.log_queue.put(f"Version {manifest['version']} is available — downloading in the background...")
        self._update_tmp_dir = tempfile.mkdtemp(prefix="cortaly-restarter-update-")

        def worker():
            try:
                downloaded_path = core.download_update(manifest, self._update_tmp_dir)
                self.root.after(0, self._on_update_downloaded, manifest, downloaded_path)
            except Exception as error:
                # A failed background download is not worth interrupting the
                # user over — same "best-effort, quietly skip" stance as
                # check_for_update() itself. It'll just try again next launch.
                self.log_queue.put(f"Update download failed (will retry next launch): {error}")

        threading.Thread(target=worker, daemon=True).start()

    def _on_update_downloaded(self, manifest, downloaded_path):
        self.log_queue.put(f"Version {manifest['version']} downloaded and ready to install.")
        if not messagebox.askyesno(
            "Update available",
            f"Cortaly Restarter {manifest['version']} has been downloaded and is ready to install.\n\n"
            f"{manifest.get('notes', '')}\n\n"
            "Install it now? The app will close for a few seconds and reopen on the new version. "
            "You can also do this later — it'll ask again next time you start the app.",
        ):
            return
        self._apply_update(manifest, downloaded_path)

    def _apply_update(self, manifest, downloaded_path):
        if self.watchdog_thread and self.watchdog_thread.is_alive():
            self.stop_watchdog()
        try:
            core.apply_update(downloaded_path, self._update_tmp_dir)
        except Exception as error:
            messagebox.showerror(
                "Update failed",
                f"Couldn't install the update — the current version keeps running as-is.\n\n{error}",
            )
            return
        self.stop_event.set()
        self.root.destroy()

    def on_close(self):
        self.stop_event.set()
        self.root.destroy()


def main():
    root = tk.Tk()
    app = RestarterApp(root)
    root.protocol("WM_DELETE_WINDOW", app.on_close)
    root.mainloop()


if __name__ == "__main__":
    main()
