#!/usr/bin/env python3
"""dokki-studio: open Dokki Studio on Linux.

  dokki-studio               open Studio for the host already running
  dokki-studio --with-host   also start the host (if none is listening) and own it
  dokki-studio --with-host --background
                             the login item: start and own the host, open nothing
  dokki-studio --browser     skip the embedded window and use the system browser

The window is GTK 4 + WebKitGTK 6.0 through PyGObject, with an ephemeral
website data store. Without them (or without a display, or when WebKitGTK's
bubblewrap sandbox cannot start) Studio opens in the default browser. One
launcher runs per session (D-Bus, io.dokki.Studio): a later `dokki-studio`
asks it to open Studio, with a ticket from the secret only it holds.

Tickets (src/studio.js): for a host this launcher starts, it makes the launch
secret, hands it over only on the host's stdin (one line, DOKKI_STUDIO_STDIN=1) and
mints a one-time ticket per window from it. The secret is never written to
disk, printed, or passed on a command line. A development host started by
hand (`DOKKI_STUDIO_DEV=1 npm start`) hands out the window URL on
GET /api/studio/open instead; other hosts refuse that route.
"""

import argparse
import os
import shutil
import signal
import socket
import subprocess
import sys
import threading
import urllib.error
import urllib.request
from contextlib import suppress

HERE = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, HERE)
import dokki_studio_core as core  # noqa: E402


# Studio screens the tray and the quick panel may open Studio at.
LINK_SCREENS = ("dev", "updates", "account")

def tell(message):
    """Errors reach a terminal, or a desktop notification when launched from the menu."""
    with suppress(OSError, ValueError):
        print("dokki-studio: " + message, file=sys.stderr)
    interactive = False
    with suppress(OSError, ValueError, AttributeError):
        interactive = sys.stderr.isatty()
    if not interactive and shutil.which("notify-send"):
        with suppress(OSError):
            subprocess.Popen(["notify-send", "--app-name=Dokki Studio", "Dokki Studio", message],
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def load_gtk():
    """(Gtk, WebKit, GLib, Gio) or None when GTK 4 / WebKitGTK 6.0 or a display is missing."""
    try:
        import gi
        gi.require_version("Gtk", "4.0")
        gi.require_version("WebKit", "6.0")
        from gi.repository import Gio, GLib, Gtk, WebKit
    except (ImportError, ValueError):
        return None
    # WM_CLASS comes from the program name: "io.dokki.Studio" matches the .desktop
    # entry's StartupWMClass, so docks and taskbars show Dokki's icon (X11).
    GLib.set_prgname(core.APP_ID)
    try:
        if not Gtk.init_check():
            return None
    except Exception:  # noqa: BLE001 - any GTK start-up failure means "use the browser"
        return None
    return Gtk, WebKit, GLib, Gio


def webkit_sandbox_ok():
    """WebKitGTK runs its web process in bubblewrap and aborts the whole app when that fails
    (no user namespaces: a container, a hardened kernel, Ubuntu 24.04's AppArmor rule for
    unconfined programs). Try the same kind of sandbox first, so Studio can open in the
    browser instead. Without bwrap there is nothing to try: WebKit was built without it."""
    if os.environ.get("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS") == "1":
        return True
    bwrap = shutil.which("bwrap")
    if not bwrap:
        return True
    if not shutil.which("xdg-dbus-proxy"):
        return False  # WebKitGTK's sandbox needs it too, and aborts without it
    try:
        return subprocess.run([bwrap] + core.SANDBOX_PROBE_ARGS, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL, timeout=10).returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def host_listening(port):
    try:
        with socket.create_connection((core.LOOPBACK_HOST, port), timeout=0.5):
            return True
    except OSError:
        return False


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):  # noqa: D401 - urllib hook
        return None


def fetch_open_url(port):
    """GET /api/studio/open on loopback, as a native caller: no Origin header, no proxy, no redirects."""
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
    request = urllib.request.Request(core.open_route_url(port), headers={"Accept": "application/json"})
    try:
        with opener.open(request, timeout=5) as response:
            body = response.read(8193)
    except urllib.error.HTTPError as error:
        code = core.open_refusal(error.read(8193))
        # 401: a shell's secret turned bare loopback trust off, so the route never answers at all.
        if code == "studio-shell" or error.code == 401:
            raise RuntimeError("this host belongs to the Dokki app that started it; "
                               "open Studio from its tray or menu bar (or from the `dokki-studio --with-host` "
                               "that started it)") from None
        if code == "studio-dev-off":
            raise RuntimeError("this host wasn't started by dokki-studio. Open Studio from the Dokki tray or "
                               "menu bar app, start the host with `dokki-studio --with-host`, or (development "
                               "only) start it with DOKKI_STUDIO_DEV=1") from None
        raise RuntimeError("the host refused to open Studio (HTTP %d)" % error.code) from None
    url = core.parse_open_response(body, port)
    if url is None:
        raise RuntimeError("the host returned an unexpected Studio address")
    return url


def open_external(url, port):
    """Links leaving Studio go to the default browser: web and mail links only, never this computer."""
    if core.decide(url, port) != core.EXTERNAL or not shutil.which("xdg-open"):
        return False
    try:
        subprocess.Popen(["xdg-open", url], stdin=subprocess.DEVNULL,
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        return True
    except OSError:
        return False


def find_host_dir(explicit):
    candidates = [explicit, os.environ.get("DOKKI_HOST_DIR"),
                  os.path.normpath(os.path.join(HERE, "..", "..")), "/usr/lib/dokki", "/opt/dokki"]
    for candidate in candidates:
        if candidate and os.path.isfile(os.path.join(candidate, "server.js")):
            return os.path.abspath(candidate)
    return None


class Host:
    """A host this launcher started. Its stdout is forwarded with the secret redacted."""

    def __init__(self, host_dir, node, port):
        self.secret = core.new_secret()
        self.ready = threading.Event()
        self.on_exit = None
        # setpriv --pdeathsig: the host ends with this launcher even when it is killed or
        # crashes, instead of staying behind with a secret nobody holds (Studio could never open).
        self.proc = subprocess.Popen(
            core.host_argv(node, os.path.join(host_dir, "server.js"), shutil.which("setpriv")), cwd=host_dir,
            env=core.host_env(os.environ, port),
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, encoding="utf-8", errors="replace", bufsize=1)
        # The secret goes to the host's stdin, one line, then EOF (src/studio.js).
        core.send_secret(self.proc.stdin, self.secret)
        threading.Thread(target=self._pump, daemon=True).start()

    def _pump(self):
        for line in self.proc.stdout:
            if core.banner_ready(line):
                self.ready.set()
            # Keep draining even when our own stdout is gone (menu launch, closed terminal).
            with suppress(OSError, ValueError):
                sys.stdout.write(core.redact(line, self.secret))
                sys.stdout.flush()
        self.proc.wait()
        self.ready.set()
        if self.on_exit:
            self.on_exit()

    def alive(self):
        return self.proc.poll() is None

    def stop(self):
        if self.alive():
            self.proc.terminate()
            try:
                self.proc.wait(5)
            except subprocess.TimeoutExpired:
                self.proc.kill()


def window_opener(gtk, port, studio_url, devtools, host, state):
    """activate -> the Studio window (or the one already open, brought forward)."""
    Gtk, WebKit, _GLib, Gio = gtk

    def follow_system_theme():
        try:
            source = Gio.SettingsSchemaSource.get_default()
            schema = source.lookup("org.gnome.desktop.interface", True) if source else None
            if not schema or not schema.has_key("color-scheme"):
                return
            settings = Gio.Settings.new("org.gnome.desktop.interface")

            def apply(*_):
                Gtk.Settings.get_default().set_property(
                    "gtk-application-prefer-dark-theme", settings.get_string("color-scheme") == "prefer-dark")

            settings.connect("changed::color-scheme", apply)
            state["theme"] = settings
            apply()
        except Exception:  # noqa: BLE001 - theming is cosmetic
            pass

    def on_policy(_view, decision, kind):
        if kind == WebKit.PolicyDecisionType.RESPONSE:
            return False
        action = decision.get_navigation_action()
        uri = action.get_request().get_uri()
        verdict = core.decide(uri, port)
        if kind == WebKit.PolicyDecisionType.NAVIGATION_ACTION and verdict == core.ALLOW:
            return False  # default policy: load it
        # Never a second window; only web links leave, to the default browser (as on the Mac).
        decision.ignore()
        if verdict == core.EXTERNAL and action.is_user_gesture():
            open_external(uri, port)
        return True

    def on_permission(view, request):
        clipboard = getattr(WebKit, "ClipboardPermissionRequest", None)
        page = view.get_uri()
        if (clipboard is not None and isinstance(request, clipboard)
                and page != "about:blank" and core.is_allowed_navigation(page, port)):
            request.allow()
        else:
            request.deny()
        return True

    def on_close(window):
        # Tear the WebView down now; its web process exits with it. The host keeps running.
        window.set_child(None)
        state["window"] = None
        if host is not None and host.alive():
            with suppress(OSError, ValueError):
                print("Dokki Studio closed; the host keeps running. Run dokki-studio to reopen it, "
                      "or press Ctrl+C here to stop the host.", flush=True)
        return False

    def on_activate(application):
        if state.get("window") is not None:
            # The quick panel asked for a screen (Developer mode, Actualizaciones): the open window switches to it.
            screen = state.pop("screen", None)
            if screen in LINK_SCREENS:
                with suppress(Exception):
                    state["window"].get_child().evaluate_javascript(
                        'window.dokkiStudioShow && window.dokkiStudioShow("%s")' % screen, -1, None, None, None, None, None)
            state["window"].present()
            return
        try:
            url = studio_url()
        except Exception as error:  # noqa: BLE001
            tell("couldn't open Studio: %s" % error)
            if not state.get("opened"):
                state["failed"] = True  # nothing to show: leave, even when holding an owned host
                application.quit()
            return
        state["opened"] = True
        # Ephemeral, like the Mac's non-persistent data store: the Studio cookie never reaches disk.
        session = WebKit.NetworkSession.new_ephemeral()
        view = WebKit.WebView(network_session=session)
        settings = view.get_settings()
        settings.set_enable_developer_extras(devtools)
        settings.set_javascript_can_open_windows_automatically(False)
        view.connect("decide-policy", on_policy)
        view.connect("permission-request", on_permission)
        view.connect("context-menu", lambda *_: not devtools)  # True suppresses the menu
        window = Gtk.ApplicationWindow(application=application, title="Dokki Studio")
        window.set_default_size(1100, 720)
        window.set_size_request(900, 600)
        window.set_icon_name("dokki")
        window.set_child(view)
        window.connect("close-request", on_close)
        state["window"] = window
        view.load_uri(url)
        window.present()

    follow_system_theme()
    return on_activate


def browser_opener(studio_url, host, state):
    """activate -> Studio in the default browser, with a fresh one-time ticket."""

    def on_activate(application):
        try:
            url = studio_url()
        except Exception as error:  # noqa: BLE001
            tell("couldn't open Studio: %s" % error)
            if not state.get("opened"):
                state["failed"] = True
                application.quit()
            return
        if not open_in_browser(url):
            tell("xdg-open is missing; install xdg-utils, or GTK 4 and WebKitGTK 6.0 for the Studio window")
            if not state.get("opened"):
                state["failed"] = True
                application.quit()
            return
        state["opened"] = True
        if host is not None:
            with suppress(OSError, ValueError):
                print("Dokki Studio opened in your browser; the host keeps running. Run dokki-studio to open it "
                      "again, or press Ctrl+C here to stop the host.", flush=True)

    return on_activate


def open_in_browser(url):
    """The one-time ticket rides in xdg-open's argv for a moment: it works once, for two minutes."""
    if not shutil.which("xdg-open"):
        return False
    try:
        subprocess.Popen(["xdg-open", url], stdin=subprocess.DEVNULL,
                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        return True
    except OSError:
        return False


def panel_opener(gtk, port, host, state, open_studio, quit_app):
    """open_panel(pair) -> the quick panel (panel.html) in a small WebKitGTK window, presented
    again when open. It hides when it loses focus (the page closes the pairing code then), and
    nothing keeps it out of captures here, so the page says so next to the code.
    docs/plans/2026-10-26-quick-panel-design.md"""
    Gtk, WebKit, GLib, _Gio = gtk
    import dokki_tray_core as tray

    def shown(view, pair):
        script = "window.dokkiPanelShown && window.dokkiPanelShown(%s)" % ("true" if pair else "false")
        with suppress(Exception):
            view.evaluate_javascript(script, -1, None, None, None, None, None)

    def on_policy(_view, decision, kind):
        if kind == WebKit.PolicyDecisionType.RESPONSE:
            return False
        uri = decision.get_navigation_action().get_request().get_uri()
        if kind == WebKit.PolicyDecisionType.NAVIGATION_ACTION and core.decide(uri, port) == core.ALLOW:
            return False
        decision.ignore()  # the panel never leaves Studio's origin and never opens a window
        return True

    def on_message(view, window, value):
        try:
            raw = value.to_string()
        except Exception:  # noqa: BLE001 - not a string: ignored
            return
        message = tray.parse_panel_message(raw, port, view.get_uri())
        if message is None:
            return
        op, args = message
        if op == "close":
            window.set_visible(False)
        elif op == "openStudio":
            window.set_visible(False)
            open_studio(args.get("screen"))
        elif op == "quit":
            quit_app()
        elif op == "size":
            window.set_default_size(360, args["height"])

    def open_panel(pair=False):
        window = state.get("panel")
        if window is not None:
            if "panel_pair" in state:  # still loading: the load's end opens the code
                state["panel_pair"] = pair
            window.present()
            shown(state["panel_view"], pair)
            return
        if host is None or not host.alive():
            return
        session = WebKit.NetworkSession.new_ephemeral()
        view = WebKit.WebView(network_session=session)
        view.get_settings().set_javascript_can_open_windows_automatically(False)
        view.connect("decide-policy", on_policy)
        view.connect("context-menu", lambda *_: True)
        window = Gtk.Window(title="Dokki")
        window.set_default_size(360, 560)
        window.set_resizable(False)
        window.set_icon_name("dokki")
        window.set_hide_on_close(True)
        window.set_child(view)
        manager = view.get_user_content_manager()
        manager.register_script_message_handler("dokkiPanel", None)
        manager.connect("script-message-received::dokkiPanel", lambda _m, value: on_message(view, window, value))
        # A utility panel: it goes away when you click elsewhere, like the Mac's and Windows'.
        def on_active(w, _p):
            if not w.is_active():
                if "panel_pair" in state:
                    state["panel_pair"] = False  # gone before it loaded: no code when it ends
                w.set_visible(False)

        def on_load(v, event):
            if event == WebKit.LoadEvent.FINISHED:
                pair_now = state.pop("panel_pair", False)
                if window.get_visible():
                    shown(v, pair_now)

        window.connect("notify::is-active", on_active)
        view.connect("load-changed", on_load)
        state["panel"], state["panel_view"], state["panel_pair"] = window, view, pair
        view.load_uri(tray.panel_url(port, core.mint_ticket(host.secret)))
        window.present()

    return open_panel


def start_indicator(gio, port, host, actions, panel_available):
    """The StatusNotifier indicator for a host this launcher owns (it needs the secret), or None."""
    if host is None:
        return None
    try:
        import dokki_tray
        indicator = dokki_tray.Indicator(gio[0], gio[1], port, host.secret, actions, panel_available,
                                         icon_dir=dokki_tray.icon_theme_dir())
        return indicator if indicator.start() else None
    except Exception as error:  # noqa: BLE001 - the indicator is optional; Studio still opens
        tell("no indicator: %s" % error)
        return None


def serve(app, GLib, host, on_activate, background, state):
    """Run the single-instance application: every `dokki-studio` started later
    activates this one (D-Bus), which opens Studio. In the background (the login
    item) the first activation opens nothing."""
    pending = {"skip": background}

    def activate(application):
        if pending["skip"]:
            pending["skip"] = False
            state["opened"] = True  # from here on, a failed open never ends the launcher
            return
        on_activate(application)

    app.connect("activate", activate)

    def on_signal(*_):
        app.quit()
        return False

    for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
        GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signum, on_signal)
    if host is not None:
        # We own the host: stay alive after the window closes so `dokki-studio`
        # can reopen it, and quit only when the host exits or on Ctrl+C.
        host.on_exit = lambda: GLib.idle_add(app.quit)
        if not host.alive():  # it may have gone before on_exit was attached
            return False
        app.hold()
    app.run([sys.argv[0]])
    return not state.get("failed")


def load_gio():
    """(Gio, GLib) for single-instance without a window, or None without PyGObject."""
    try:
        import gi  # noqa: F401
        from gi.repository import Gio, GLib
    except (ImportError, ValueError):
        return None
    return Gio, GLib


def _interrupt(*_):
    raise KeyboardInterrupt


def main(argv=None):
    parser = argparse.ArgumentParser(prog="dokki-studio", description="Open Dokki Studio.")
    parser.add_argument("--port", help="host port (default: $PORT, else %d)" % core.DEFAULT_PORT)
    parser.add_argument("--with-host", action="store_true",
                        help="start the Dokki host too when none is listening, and keep it running")
    parser.add_argument("--background", action="store_true",
                        help="with --with-host: start the host and open nothing (the login item); "
                             "a later `dokki-studio` opens Studio")
    parser.add_argument("--host-dir", help="folder with server.js (default: this checkout)")
    parser.add_argument("--node", default=os.environ.get("DOKKI_NODE", "node"), help="Node.js binary")
    parser.add_argument("--browser", action="store_true", help="use the system browser instead of a window")
    args = parser.parse_args(argv)
    try:
        port = core.resolve_port(args.port, os.environ)
    except ValueError as error:
        parser.error(str(error))
    if args.background and not args.with_host:
        parser.error("--background needs --with-host")
    devtools = os.environ.get("DOKKI_STUDIO_DEVTOOLS") == "1"

    gtk = None if args.browser else load_gtk()
    if gtk is not None and not webkit_sandbox_ok():
        tell("WebKitGTK's sandbox (bubblewrap) can't start here, so Studio opens in the browser. "
             "On Ubuntu 24.04 this is usually the AppArmor restriction on user namespaces.")
        gtk = None
    gio = (gtk[3], gtk[2]) if gtk is not None else load_gio()
    app = None
    if gio is not None:
        Gio = gio[0]
        flags = getattr(Gio.ApplicationFlags, "DEFAULT_FLAGS", None) or Gio.ApplicationFlags.FLAGS_NONE
        make = gtk[0].Application if gtk is not None else Gio.Application
        # One launcher per host port: a `--port` launch never activates another port's launcher.
        app = make(application_id=core.app_id(port), flags=flags)
        try:
            app.register(None)  # without a session bus GLib registers it as a lone instance
        except Exception:  # noqa: BLE001 - any other failure: run without single-instance
            app = make(application_id=core.app_id(port), flags=flags | Gio.ApplicationFlags.NON_UNIQUE)
        if app.get_is_remote():
            if not args.background:
                app.activate()  # single instance: the running launcher opens (or raises) Studio
            return 0

    host = None
    if args.with_host and not host_listening(port):
        host_dir = find_host_dir(args.host_dir)
        if host_dir is None:
            tell("couldn't find server.js; pass --host-dir")
            return 1
        try:
            host = Host(host_dir, args.node, port)
        except OSError as error:
            tell("couldn't start the host with %s: %s" % (args.node, error.strerror or error))
            return 1
        if not host.ready.wait(30) or not host.alive():
            tell("the host didn't start on port %d" % port)
            host.stop()
            return 1
    elif not host_listening(port):
        tell("Dokki isn't running on port %d. Start it with `dokki-studio --with-host`." % port)
        return 1
    elif args.background:
        return 0  # a host is already running (not ours): nothing to own

    state = {}

    def studio_url():
        # Our own host: mint a one-time ticket from its secret. A host started by hand hands one out.
        if host is not None and host.alive():
            # The quick panel's agents row opens Studio at Developer mode, the tray's update item at
            # Actualizaciones (fixed names only: public/studio/studio-model.js LINK_SCREENS).
            screen = state.pop("screen", None)
            if screen in LINK_SCREENS:
                return core.launch_url(port, core.mint_ticket(host.secret)) + "&s=" + screen
            return core.launch_url(port, core.mint_ticket(host.secret))
        return fetch_open_url(port)

    if app is not None:
        opener = (window_opener(gtk, port, studio_url, devtools, host, state) if gtk is not None
                  else browser_opener(studio_url, host, state))

        def open_studio(screen=None):
            state["screen"] = screen
            opener(app)

        open_panel = panel_opener(gtk, port, host, state, open_studio, app.quit) if gtk is not None else None
        indicator = start_indicator(gio, port, host, {
            "open_panel": (lambda pair: open_panel(pair)) if open_panel else (lambda _pair: open_studio()),
            "open_studio": open_studio,
            "quit": app.quit,
        }, panel_available=open_panel is not None)
        ok = serve(app, gio[1], host, opener, args.background, state)
        if indicator is not None:
            indicator.stop()
        if host is not None:
            host.stop()  # the loop ends only on Ctrl+C, a failed start, or when the host is gone
        return 0 if ok else 1

    # No PyGObject at all: open once, then keep the host this launcher owns until Ctrl+C.
    if not args.background:
        try:
            url = studio_url()
        except (OSError, RuntimeError, ValueError) as error:
            tell("couldn't open Studio: %s" % error)
            if host is not None:
                host.stop()
            return 1
        if not open_in_browser(url):
            tell("xdg-open is missing; install xdg-utils, or GTK 4 and WebKitGTK 6.0 for the Studio window")
            if host is not None:
                host.stop()
            return 1
    if host is None:
        return 0
    if not args.background:
        with suppress(OSError, ValueError):
            print("Dokki Studio opened in your browser; the host keeps running. Press Ctrl+C to stop it.", flush=True)
    signal.signal(signal.SIGTERM, _interrupt)
    signal.signal(signal.SIGHUP, _interrupt)
    try:
        return host.proc.wait()
    except KeyboardInterrupt:
        host.stop()
        return 0


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