#!/usr/bin/env python3
"""Small desktop companion for the trusted Silo reference client.

The companion is deliberately a thin local shell. It never imports the Silo
crypto code, asks for a password, or implements a second API client. Every
operation is an argument-list subprocess call to the bundled
``havre_remote.py``. This keeps the desktop path aligned with the tested CLI
and makes the boundary easy to inspect before a signed installer exists.
"""

from __future__ import annotations

import argparse
import json
import os
import pathlib
import queue
import shlex
import shutil
import subprocess
import sys
import threading
import urllib.parse
import webbrowser


DEFAULT_SERVER = "https://havre.cloud"
DEFAULT_WATCH_INTERVAL = 30.0
DEFAULT_WATCH_RETRY_SECONDS = 15.0
DEFAULT_WATCH_MAX_FAILURES = 5


def _client_path() -> pathlib.Path:
    """Find the canonical client in a generated bundle or source checkout."""
    bundled = pathlib.Path(__file__).resolve().with_name("havre_remote.py")
    if bundled.is_file():
        return bundled
    source_checkout = pathlib.Path(__file__).resolve().parents[1] / "backend" / "havre_remote.py"
    if source_checkout.is_file():
        return source_checkout
    raise FileNotFoundError(
        "havre_remote.py is missing; use the complete Silo client bundle"
    )


def _server_args(server: str) -> list[str]:
    value = (server or DEFAULT_SERVER).strip().rstrip("/")
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise ValueError("Silo server must be an http:// or https:// URL")
    if parsed.username or parsed.password:
        raise ValueError("Silo server URLs cannot contain embedded credentials")
    return ["--server", value]


def _root_value(root: str | None, *, required: bool) -> str | None:
    value = (root or "").strip()
    if not value:
        if required:
            raise ValueError("Choose a local folder before starting sync")
        return None
    return str(pathlib.Path(value).expanduser())


def _include_prefix_values(values) -> list[str]:
    """Validate folder prefixes before placing them in the subprocess argv."""
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]
    normalized = []
    for value in values:
        prefix = str(value or "").strip().replace("\\", "/").strip("/")
        parts = prefix.split("/")
        if not prefix or any(part in ("", ".", "..") for part in parts):
            raise ValueError("Retention prefixes must be non-empty relative folders")
        if prefix not in normalized:
            normalized.append(prefix)
    return normalized


def _bundle_value(bundle: str | None) -> str:
    value = (bundle or "").strip()
    if not value:
        raise ValueError("Choose an encrypted Silo recovery bundle first")
    return str(pathlib.Path(value).expanduser())


def build_command(
    action: str,
    *,
    server: str,
    root: str | None = None,
    bundle: str | None = None,
    interval_seconds: float = DEFAULT_WATCH_INTERVAL,
    retry_seconds: float = DEFAULT_WATCH_RETRY_SECONDS,
    max_failures: int = DEFAULT_WATCH_MAX_FAILURES,
    public_library: bool = False,
    include_prefixes: list[str] | tuple[str, ...] | None = None,
    include_all: bool = False,
    client_path: pathlib.Path | None = None,
) -> list[str]:
    """Build a safe subprocess argv for one desktop action."""
    client = client_path or _client_path()
    command = [sys.executable, "-u", str(client), *_server_args(server)]

    if action == "doctor":
        command += ["silo-doctor", "--json"]
        checked_root = _root_value(root, required=False)
        if checked_root:
            command += ["--root", checked_root]
    elif action == "register":
        command += ["silo-device", "register", "--name", "Silo desktop"]
    elif action == "recovery-create":
        command += ["silo-device", "recovery-create", "--name", "Silo desktop recovery"]
    elif action == "recovery-import":
        command += ["silo-device", "recovery-import", "--bundle", _bundle_value(bundle)]
    elif action in {"service-install", "service-status", "service-uninstall"}:
        if action == "service-install":
            checked_root = _root_value(root, required=True)
            selected_prefixes = _include_prefix_values(include_prefixes)
            if include_all and selected_prefixes:
                raise ValueError("Choose retained folders or the full remote library, not both")
            command += ["silo-service", "install", checked_root]
            for prefix in selected_prefixes:
                command += ["--include-prefix", prefix]
            if include_all:
                command.append("--include-all")
            if public_library:
                command.append("--public-library")
            if interval_seconds <= 0:
                raise ValueError("Watch interval must be greater than zero")
            if retry_seconds <= 0:
                raise ValueError("Watch retry delay must be greater than zero")
            if max_failures <= 0:
                raise ValueError("Watch retry limit must be greater than zero")
            command += [
                "--interval-seconds", str(interval_seconds),
                "--retry-seconds", str(retry_seconds),
                "--max-failures", str(max_failures),
            ]
        else:
            command += ["silo-service", "status" if action == "service-status" else "uninstall"]
    elif action in {"sync", "watch"}:
        checked_root = _root_value(root, required=True)
        command += ["silo-sync" if action == "sync" else "silo-watch", checked_root]
        selected_prefixes = _include_prefix_values(include_prefixes)
        if include_all and selected_prefixes:
            raise ValueError("Choose retained folders or the full remote library, not both")
        for prefix in selected_prefixes:
            command += ["--include-prefix", prefix]
        if include_all:
            command.append("--include-all")
        if public_library:
            command.append("--public-library")
        if action == "watch":
            if interval_seconds <= 0:
                raise ValueError("Watch interval must be greater than zero")
            if retry_seconds <= 0:
                raise ValueError("Watch retry delay must be greater than zero")
            if max_failures <= 0:
                raise ValueError("Watch retry limit must be greater than zero")
            command += [
                "--interval-seconds", str(interval_seconds),
                "--retry-seconds", str(retry_seconds),
                "--max-failures", str(max_failures),
            ]
    else:
        raise ValueError(f"Unknown desktop action: {action}")
    return command


def _applescript_string(value: str) -> str:
    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'


def open_terminal_command(command: list[str]) -> None:
    """Open the canonical client in a real terminal so getpass stays out of the GUI."""
    if not command or len(command) < 3:
        raise ValueError("A complete Silo client command is required")
    client = pathlib.Path(command[2]).resolve()
    if not client.is_file():
        raise FileNotFoundError(f"Silo client is missing: {client}")

    if sys.platform.startswith("win"):
        subprocess.Popen(
            command,
            cwd=str(client.parent),
            creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0),
        )
        return

    if sys.platform == "darwin":
        command_text = " ".join(shlex.quote(str(part)) for part in command)
        script = (
            'tell application "Terminal"\n'
            "activate\n"
            f"do script {_applescript_string(command_text)}\n"
            "end tell"
        )
        osascript = shutil.which("osascript")
        if osascript:
            subprocess.Popen([osascript, "-e", script])
            return

    terminal_specs = (
        ("gnome-terminal", ("--",)),
        ("konsole", ("-e",)),
        ("x-terminal-emulator", ("-e",)),
        ("xterm", ("-e",)),
    )
    for name, prefix in terminal_specs:
        terminal = shutil.which(name)
        if terminal:
            subprocess.Popen([terminal, *prefix, *command], cwd=str(client.parent))
            return
    raise OSError(
        "No terminal application was found. Run the displayed Silo client command in a terminal."
    )


def _doctor_summary(output: str) -> tuple[str, str]:
    """Return a short UI status and next action from doctor JSON."""
    try:
        report = json.loads(output)
    except (TypeError, ValueError):
        return "Setup check finished", "Read the detailed output below."
    if report.get("ready"):
        return "Silo is ready", "The selected folder passed the required checks."
    next_action = str(report.get("next_action") or "complete the blocked setup step")
    return "Silo setup needs attention", f"Next action: {next_action}"


def _selftest() -> int:
    """Exercise the desktop boundary without opening a window or contacting Havre."""
    fake_client = pathlib.Path("havre_remote.py")
    tests: list[tuple[str, bool]] = []

    sync = build_command(
        "sync",
        server="https://havre.cloud/",
        root=r"C:\Users\Pilot\My Silo",
        client_path=fake_client,
    )
    sync_index = sync.index("silo-sync")
    tests.append(("server flag precedes the CLI subcommand", sync.index("--server") < sync_index))
    tests.append(("folder paths remain one argv item", r"C:\Users\Pilot\My Silo" in sync))
    tests.append(("sync uses the canonical client", sync[2] == str(fake_client)))

    watch = build_command(
        "watch",
        server="https://havre.cloud",
        root="/tmp/My Silo",
        interval_seconds=45,
        public_library=True,
        client_path=fake_client,
    )
    tests.append(("watch exposes an explicit interval", "45" in watch))
    tests.append(("watch exposes bounded offline retry", "--max-failures" in watch and "5" in watch))
    tests.append(("public-library mode is opt-in", "--public-library" in watch))

    service_install = build_command(
        "service-install",
        server="https://havre.cloud",
        root="/tmp/My Silo",
        interval_seconds=45,
        retry_seconds=20,
        max_failures=4,
        include_prefixes=["Documents"],
        client_path=fake_client,
    )
    tests.append((
        "background install delegates to the service command",
        "silo-service" in service_install and "install" in service_install
        and any(value.replace("\\", "/").endswith("/My Silo") for value in service_install),
    ))
    tests.append((
        "background install carries bounded retry settings",
        "--retry-seconds" in service_install and "20" in service_install
        and "--max-failures" in service_install and "4" in service_install,
    ))
    service_status = build_command(
        "service-status", server="https://havre.cloud", client_path=fake_client)
    tests.append((
        "background status does not require a local folder",
        service_status[-2:] == ["silo-service", "status"],
    ))

    selective = build_command(
        "sync",
        server="https://havre.cloud",
        root="/tmp/My Silo",
        include_prefixes=["Documents", "Photos/2026"],
        client_path=fake_client,
    )
    tests.append((
        "selective sync keeps each retained prefix as one argument",
        selective.count("--include-prefix") == 2
        and "Documents" in selective and "Photos/2026" in selective,
    ))
    try:
        build_command(
            "sync", server="https://havre.cloud", root="x",
            include_prefixes=["../outside"], client_path=fake_client)
    except ValueError:
        invalid_prefix_rejected = True
    else:
        invalid_prefix_rejected = False
    tests.append(("retention path traversal is rejected", invalid_prefix_rejected))

    recovery_create = build_command(
        "recovery-create",
        server="https://havre.cloud",
        client_path=fake_client,
    )
    tests.append((
        "recovery creation delegates to the canonical client",
        recovery_create[-4:] == [
            "silo-device", "recovery-create", "--name", "Silo desktop recovery"
        ],
    ))
    recovery_import = build_command(
        "recovery-import",
        server="https://havre.cloud",
        bundle=r"C:\Users\Pilot\silo-recovery.json",
        client_path=fake_client,
    )
    tests.append((
        "recovery import keeps the bundle path in one argv item",
        recovery_import[-2:] == ["--bundle", r"C:\Users\Pilot\silo-recovery.json"],
    ))
    try:
        build_command("recovery-import", server="https://havre.cloud", client_path=fake_client)
    except ValueError:
        missing_bundle_rejected = True
    else:
        missing_bundle_rejected = False
    tests.append(("recovery import requires an explicit bundle", missing_bundle_rejected))

    try:
        build_command("sync", server="https://user:secret@havre.cloud", root="x", client_path=fake_client)
    except ValueError:
        invalid_server_rejected = True
    else:
        invalid_server_rejected = False
    tests.append(("embedded server credentials are rejected", invalid_server_rejected))

    ready_status, ready_detail = _doctor_summary('{"ready": true}')
    blocked_status, blocked_detail = _doctor_summary('{"ready": false, "next_action": "email-verification"}')
    tests.append(("ready doctor output is summarized", ready_status == "Silo is ready" and "passed" in ready_detail))
    tests.append(("blocked doctor output keeps the next action", "email-verification" in blocked_detail and "attention" in blocked_status))

    print("silo desktop companion selftest")
    for label, passed in tests:
        print(f"  [{'PASS' if passed else 'FAIL'}] {label}")
    return 0 if all(passed for _, passed in tests) else 1


class DesktopApp:
    """Tk wrapper around the canonical reference client."""

    def __init__(self, window, tk, ttk, filedialog, messagebox, args):
        self.window = window
        self.tk = tk
        self.ttk = ttk
        self.filedialog = filedialog
        self.messagebox = messagebox
        self.queue: queue.Queue[tuple] = queue.Queue()
        self.process: subprocess.Popen[str] | None = None
        self.current_action = ""
        self.current_output: list[str] = []
        self.stop_requested = False

        self.server_var = tk.StringVar(value=args.server or os.environ.get("HAVRE_SERVER", DEFAULT_SERVER))
        self.folder_var = tk.StringVar(value=args.root or "")
        self.interval_var = tk.StringVar(value=f"{args.interval_seconds:g}")
        self.include_var = tk.StringVar(value=", ".join(args.include_prefix or []))
        self.public_library_var = tk.BooleanVar(value=False)
        self.include_all_var = tk.BooleanVar(value=bool(args.include_all))
        self.status_var = tk.StringVar(value="Choose a folder, then run the setup check.")
        self.detail_var = tk.StringVar(value="Private files stay sealed on this computer before upload.")

        self._build()
        self.window.after(100, self._drain_queue)

    def _build(self) -> None:
        tk, ttk = self.tk, self.ttk
        self.window.title("Silo by Havre")
        self.window.geometry("900x650")
        self.window.minsize(760, 540)
        self.window.protocol("WM_DELETE_WINDOW", self._close)

        style = ttk.Style(self.window)
        try:
            style.theme_use("clam")
        except tk.TclError:
            pass
        style.configure("Title.TLabel", font=("Segoe UI", 22, "bold"))
        style.configure("Status.TLabel", font=("Segoe UI", 13, "bold"))

        outer = ttk.Frame(self.window, padding=24)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="Silo", style="Title.TLabel").pack(anchor="w")
        ttk.Label(
            outer,
            text="Your encrypted Havre folder. Select one local folder, check setup, then sync it or keep it current.",
            wraplength=820,
            justify="left",
        ).pack(anchor="w", pady=(4, 18))

        settings = ttk.LabelFrame(outer, text="Connection and folder", padding=14)
        settings.pack(fill="x")
        settings.columnconfigure(1, weight=1)

        ttk.Label(settings, text="Silo server").grid(row=0, column=0, sticky="w", padx=(0, 10), pady=5)
        ttk.Entry(settings, textvariable=self.server_var).grid(row=0, column=1, columnspan=2, sticky="ew", pady=5)
        ttk.Label(settings, text="Local folder").grid(row=1, column=0, sticky="w", padx=(0, 10), pady=5)
        ttk.Entry(settings, textvariable=self.folder_var).grid(row=1, column=1, sticky="ew", pady=5)
        ttk.Button(settings, text="Browse...", command=self._browse).grid(row=1, column=2, padx=(10, 0), pady=5)

        options = ttk.Frame(settings)
        options.grid(row=2, column=1, columnspan=2, sticky="w", pady=(7, 0))
        ttk.Checkbutton(
            options,
            text="Rights-cleared books or music only (public-library mode)",
            variable=self.public_library_var,
        ).pack(side="left")
        ttk.Label(options, text="Watch interval (seconds)").pack(side="left", padx=(18, 6))
        ttk.Entry(options, width=7, textvariable=self.interval_var).pack(side="left")

        ttk.Label(
            settings,
            text="Retain remote folders",
        ).grid(row=3, column=0, sticky="w", padx=(0, 10), pady=(10, 5))
        retention = ttk.Frame(settings)
        retention.grid(row=3, column=1, columnspan=2, sticky="ew", pady=(10, 5))
        retention.columnconfigure(0, weight=1)
        ttk.Entry(
            retention,
            textvariable=self.include_var,
        ).grid(row=0, column=0, sticky="ew")
        ttk.Label(
            retention,
            text="comma-separated prefixes, e.g. Documents, Photos/2026",
        ).grid(row=1, column=0, sticky="w", pady=(3, 0))
        ttk.Checkbutton(
            retention,
            text="Retain full remote library (clear saved selection)",
            variable=self.include_all_var,
        ).grid(row=2, column=0, sticky="w", pady=(3, 0))

        status = ttk.LabelFrame(outer, text="Silo status", padding=14)
        status.pack(fill="x", pady=(16, 12))
        ttk.Label(status, textvariable=self.status_var, style="Status.TLabel", wraplength=820).pack(anchor="w")
        ttk.Label(status, textvariable=self.detail_var, wraplength=820, justify="left").pack(anchor="w", pady=(4, 0))

        actions = ttk.Frame(outer)
        actions.pack(fill="x", pady=(0, 12))
        self.action_buttons = []
        for label, callback in (
            ("Open Silo", self._open_silo),
            ("Setup check", self._doctor),
            ("Register device", self._register),
            ("Create recovery", self._recovery_create),
            ("Import recovery", self._recovery_import),
            ("Sync now", self._sync),
            ("Start watch", self._watch),
            ("Install background sync", self._service_install),
            ("Background status", self._service_status),
            ("Remove background sync", self._service_uninstall),
        ):
            button = ttk.Button(actions, text=label, command=callback)
            button.pack(side="left", padx=(0, 8))
            self.action_buttons.append(button)
        self.stop_button = ttk.Button(actions, text="Stop", command=self._stop, state="disabled")
        self.stop_button.pack(side="left")

        log_frame = ttk.LabelFrame(outer, text="Client output", padding=8)
        log_frame.pack(fill="both", expand=True)
        self.log = tk.Text(log_frame, height=12, wrap="word", state="disabled", font=("Consolas", 10))
        scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log.yview)
        self.log.configure(yscrollcommand=scrollbar.set)
        self.log.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        ttk.Label(
            outer,
            text="This is a technical-pilot companion. It does not collect passwords, hold a second private key, or bypass checkout and account recovery gates. Background sync is installed as a user-level operating-system task and can be removed from this window.",
            wraplength=820,
            justify="left",
        ).pack(anchor="w", pady=(10, 0))

    def _append(self, text: str) -> None:
        self.log.configure(state="normal")
        self.log.insert("end", text.rstrip() + "\n")
        self.log.see("end")
        self.log.configure(state="disabled")

    def _show_error(self, title: str, detail: str) -> None:
        self.status_var.set(title)
        self.detail_var.set(detail)
        self.messagebox.showerror(title, detail, parent=self.window)

    def _browse(self) -> None:
        selected = self.filedialog.askdirectory(parent=self.window, title="Choose the local Silo folder")
        if selected:
            self.folder_var.set(selected)

    def _server(self) -> str:
        server = self.server_var.get().strip()
        _server_args(server)
        return server

    def _interval(self) -> float:
        try:
            value = float(self.interval_var.get().strip())
        except ValueError as exc:
            raise ValueError("Watch interval must be a number of seconds") from exc
        if value <= 0:
            raise ValueError("Watch interval must be greater than zero")
        return value

    def _include_prefixes(self) -> list[str] | None:
        raw = self.include_var.get().strip()
        if not raw:
            return None
        return [value.strip() for value in raw.split(",") if value.strip()]

    def _start(self, action: str, command: list[str]) -> None:
        if self.process is not None:
            self._show_error("Silo is busy", "Stop the current client operation before starting another one.")
            return
        self.current_action = action
        self.current_output = []
        self.stop_requested = False
        self.status_var.set(f"{action} is running...")
        self.detail_var.set("The trusted client is handling the operation. Private keys remain local.")
        self._append(f"> {' '.join(command[2:])}")
        self._set_busy(True)
        threading.Thread(target=self._run, args=(action, command), daemon=True).start()

    def _run(self, action: str, command: list[str]) -> None:
        try:
            client = pathlib.Path(command[2]).resolve()
            process = subprocess.Popen(
                command,
                cwd=str(client.parent),
                stdin=subprocess.DEVNULL,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                encoding="utf-8",
                errors="replace",
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            )
            self.process = process
            if process.stdout is not None:
                for line in process.stdout:
                    self.queue.put(("line", line.rstrip("\r\n")))
            return_code = process.wait()
            self.queue.put(("done", action, return_code))
        except (OSError, ValueError) as exc:
            self.queue.put(("error", action, str(exc)))

    def _drain_queue(self) -> None:
        try:
            while True:
                event = self.queue.get_nowait()
                kind = event[0]
                if kind == "line":
                    line = event[1]
                    self.current_output.append(line)
                    self._append(line)
                elif kind == "done":
                    self._finished(event[1], int(event[2]))
                elif kind == "error":
                    self.process = None
                    self._set_busy(False)
                    self._show_error(f"{event[1]} failed", event[2])
        except queue.Empty:
            pass
        if self.window.winfo_exists():
            self.window.after(100, self._drain_queue)

    def _finished(self, action: str, return_code: int) -> None:
        self.process = None
        self._set_busy(False)
        output = "\n".join(self.current_output)
        if self.stop_requested:
            self.status_var.set("Watcher stopped")
            self.detail_var.set("Start watch again when you want the selected folder to stay current.")
        elif action == "Setup check":
            status, detail = _doctor_summary(output)
            self.status_var.set(status)
            self.detail_var.set(detail)
        elif return_code == 0:
            self.status_var.set(f"{action} complete")
            self.detail_var.set("The trusted client finished without reporting an error.")
        else:
            self.status_var.set(f"{action} needs attention")
            self.detail_var.set("Read the client output for the blocked check or failed transfer.")

    def _set_busy(self, busy: bool) -> None:
        state = "disabled" if busy else "normal"
        for button in self.action_buttons:
            button.configure(state=state)
        self.stop_button.configure(state="normal" if busy else "disabled")

    def _doctor(self) -> None:
        try:
            command = build_command("doctor", server=self._server(), root=self.folder_var.get())
            self._start("Setup check", command)
        except ValueError as exc:
            self._show_error("Setup check cannot start", str(exc))

    def _register(self) -> None:
        try:
            command = build_command("register", server=self._server())
            self._start("Device registration", command)
        except ValueError as exc:
            self._show_error("Device registration cannot start", str(exc))

    def _open_recovery_terminal(self, action: str, command: list[str]) -> None:
        if self.process is not None:
            self._show_error("Silo is busy", "Stop the current client operation before opening recovery.")
            return
        self._append(f"> {' '.join(command[2:])}")
        try:
            open_terminal_command(command)
        except (OSError, ValueError) as exc:
            self._show_error(f"{action} cannot open", str(exc))
            return
        self.status_var.set(f"{action} terminal opened")
        self.detail_var.set(
            "Enter the recovery password only in the trusted client terminal. "
            "This window never receives it."
        )

    def _recovery_create(self) -> None:
        try:
            command = build_command("recovery-create", server=self._server())
            self._open_recovery_terminal("Recovery creation", command)
        except ValueError as exc:
            self._show_error("Recovery creation cannot start", str(exc))

    def _recovery_import(self) -> None:
        selected = self.filedialog.askopenfilename(
            parent=self.window,
            title="Choose the encrypted Silo recovery bundle",
            filetypes=[("Silo recovery bundle", "*.json"), ("All files", "*.*")],
        )
        if not selected:
            return
        try:
            command = build_command(
                "recovery-import", server=self._server(), bundle=selected)
            self._open_recovery_terminal("Recovery import", command)
        except ValueError as exc:
            self._show_error("Recovery import cannot start", str(exc))

    def _sync(self) -> None:
        try:
            command = build_command(
                "sync",
                server=self._server(),
                root=self.folder_var.get(),
                public_library=self.public_library_var.get(),
                include_prefixes=self._include_prefixes(),
                include_all=self.include_all_var.get(),
            )
            self._start("Sync", command)
        except ValueError as exc:
            self._show_error("Sync cannot start", str(exc))

    def _watch(self) -> None:
        try:
            command = build_command(
                "watch",
                server=self._server(),
                root=self.folder_var.get(),
                interval_seconds=self._interval(),
                public_library=self.public_library_var.get(),
                include_prefixes=self._include_prefixes(),
                include_all=self.include_all_var.get(),
            )
            self._start("Folder watcher", command)
        except ValueError as exc:
            self._show_error("Watcher cannot start", str(exc))

    def _service_install(self) -> None:
        try:
            command = build_command(
                "service-install",
                server=self._server(),
                root=self.folder_var.get(),
                interval_seconds=self._interval(),
                public_library=self.public_library_var.get(),
                include_prefixes=self._include_prefixes(),
                include_all=self.include_all_var.get(),
            )
            self._start("Background sync install", command)
        except ValueError as exc:
            self._show_error("Background sync cannot be installed", str(exc))

    def _service_status(self) -> None:
        try:
            command = build_command("service-status", server=self._server())
            self._start("Background sync status", command)
        except ValueError as exc:
            self._show_error("Background sync status cannot start", str(exc))

    def _service_uninstall(self) -> None:
        try:
            command = build_command("service-uninstall", server=self._server())
            self._start("Background sync removal", command)
        except ValueError as exc:
            self._show_error("Background sync cannot be removed", str(exc))

    def _stop(self) -> None:
        if self.process is None:
            return
        self.stop_requested = True
        self.status_var.set("Stopping the trusted client...")
        try:
            self.process.terminate()
        except OSError:
            pass

    def _open_silo(self) -> None:
        try:
            server = self._server()
        except ValueError as exc:
            self._show_error("Silo cannot open", str(exc))
            return
        webbrowser.open(server.rstrip("/") + "/app/silo/")

    def _close(self) -> None:
        if self.process is not None:
            self.stop_requested = True
            try:
                self.process.terminate()
            except OSError:
                pass
        self.window.destroy()


def _run_gui(args: argparse.Namespace) -> int:
    try:
        import tkinter as tk
        from tkinter import filedialog, messagebox, ttk
    except ImportError as exc:
        print(
            "The desktop companion needs Python Tk support. Run the reference client launcher "
            "or install the platform's Tk package, then try again.",
            file=sys.stderr,
        )
        print(str(exc), file=sys.stderr)
        return 1

    window = tk.Tk()
    DesktopApp(window, tk, ttk, filedialog, messagebox, args)
    window.mainloop()
    return 0


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Open the Havre Silo desktop companion")
    parser.add_argument("--selftest", action="store_true", help="run offline wrapper checks")
    parser.add_argument("--server", default=None, help=f"Silo server URL (default {DEFAULT_SERVER})")
    parser.add_argument("--root", help="initial local Silo folder")
    parser.add_argument(
        "--include-prefix",
        action="append",
        default=None,
        help="initial retained remote folder prefix; repeat for multiple folders",
    )
    parser.add_argument(
        "--include-all",
        action="store_true",
        help="initially retain the full remote library and clear a saved selection",
    )
    parser.add_argument(
        "--interval-seconds",
        type=float,
        default=DEFAULT_WATCH_INTERVAL,
        help="initial foreground watcher interval",
    )
    args = parser.parse_args(argv)
    if args.selftest:
        return _selftest()
    if args.interval_seconds <= 0:
        parser.error("--interval-seconds must be greater than zero")
    return _run_gui(args)


if __name__ == "__main__":
    raise SystemExit(main())
