#!/usr/bin/python3
"""Frametop Remote Access: settings for seeing and using the Frametop desktop from another
computer on your tailnet, over VNC (session/remote-desktop.sh and session/vnc-bridge.sh).

A GTK 4 / libadwaita app on the Frame host's own Python (like the gaze probe). It turns remote
access on and off (REMOTE in ~/.config/frametop.conf, applied now through
session/remote-ctl.sh when this desktop allows it), shows the address to connect to, and shows,
copies, or replaces the VNC password (~/.config/frametop-remote/vnc-password). Nothing is
stored anywhere else, and no password is in the code: the first start makes a random one.
"""
import os
import secrets
import string
import subprocess
import sys

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk  # noqa: E402

REPO = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
CTL = os.path.join(REPO, "session", "remote-ctl.sh")
CONF = os.path.expanduser("~/.config/frametop.conf")
CREDS = os.path.expanduser("~/.config/frametop-remote")
VNC_PASSWORD = os.path.join(CREDS, "vnc-password")
HIDDEN = "••••••••"


def read_conf(key, default=""):
    value = default
    try:
        with open(CONF) as f:
            for line in f:
                line = line.split("#", 1)[0].strip()
                if line.startswith(key + "="):
                    value = line.split("=", 1)[1].strip()
    except OSError:
        pass
    return value


def write_conf(key, value):
    """Set KEY=value in frametop.conf, keeping its comments and the rest of the file."""
    try:
        with open(CONF) as f:
            lines = f.read().splitlines()
    except OSError:
        lines = []
    for i, line in enumerate(lines):
        if line.split("#", 1)[0].strip().startswith(key + "="):
            comment = line[line.index("#"):] if "#" in line else ""
            lines[i] = f"{key}={value}" + (f"   {comment}" if comment else "")
            break
    else:
        lines.append(f"{key}={value}")
    with open(CONF, "w") as f:
        f.write("\n".join(lines) + "\n")


def read_password():
    try:
        with open(VNC_PASSWORD) as f:
            return f.read().strip()
    except OSError:
        return ""


def new_password():
    """8 random letters and digits: VNC's own authentication takes at most 8 characters."""
    os.makedirs(CREDS, mode=0o700, exist_ok=True)
    pw = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
    fd = os.open(VNC_PASSWORD, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(pw + "\n")
    return pw


class Window(Adw.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title="Frametop Remote Access", default_width=560, default_height=620)
        self.status = {}
        self.revealed = False
        self.setting = False  # the switch is being set from the status, not by the user

        self.toasts = Adw.ToastOverlay()
        view = Adw.ToolbarView()
        view.add_top_bar(Adw.HeaderBar())
        view.set_content(self.toasts)
        self.set_content(view)
        page = Adw.PreferencesPage()
        self.toasts.set_child(page)

        access = Adw.PreferencesGroup(
            title="Remote access",
            description="See and use the Frametop desktop from another computer on your tailnet, with any VNC "
                        "viewer (RealVNC Viewer, TigerVNC, or macOS Screen Sharing). It shows the primary "
                        "screen, the one with the taskbar, and follows it when you change the layout.")
        page.add(access)
        self.switch = Adw.SwitchRow(title="Remote access over VNC")
        self.switch.connect("notify::active", self.on_switch)
        access.add(self.switch)
        self.address = Adw.ActionRow(title="Address", subtitle="…", subtitle_selectable=True)
        copy = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy the address")
        copy.add_css_class("flat")
        copy.connect("clicked", lambda *_: self.copy(self.address_text(), "Address copied"))
        self.address.add_suffix(copy)
        access.add(self.address)

        secret = Adw.PreferencesGroup(
            title="Password",
            description="VNC takes at most 8 characters. The connection itself is encrypted by the tailnet, and "
                        "only devices on it can reach the Frame.")
        page.add(secret)
        self.password = Adw.ActionRow(title="VNC password", subtitle=HIDDEN, subtitle_selectable=True)
        self.eye = Gtk.ToggleButton(icon_name="view-reveal-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Show")
        self.eye.add_css_class("flat")
        self.eye.connect("toggled", self.on_reveal)
        self.password.add_suffix(self.eye)
        copy_pw = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy the password")
        copy_pw.add_css_class("flat")
        copy_pw.connect("clicked", lambda *_: self.copy(read_password(), "Password copied")
                        if read_password() else self.toast("No password yet: turn remote access on"))
        self.password.add_suffix(copy_pw)
        secret.add(self.password)
        change = Adw.ActionRow(title="New password",
                               subtitle="Viewers connected now are disconnected and need the new one")
        change_button = Gtk.Button(label="Change…", valign=Gtk.Align.CENTER)
        change_button.connect("clicked", self.on_change)
        change.add_suffix(change_button)
        secret.add(change)

        about = Adw.PreferencesGroup(
            title="How it works",
            description="KDE's krdp captures the desktop on 127.0.0.1 only, and a VNC server on the tailnet "
                        "address shows its primary screen. Turning it on in a desktop that started with it off "
                        "takes a desktop restart (KWin allows the capture only from its start). Logs: "
                        "/tmp/frametop-remote.log and /tmp/frametop-vnc.log.")
        page.add(about)

        self.refresh()
        GLib.timeout_add_seconds(2, self.refresh)

    # --- status ---
    def refresh(self):
        proc = Gio.Subprocess.new([CTL, "status"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE)
        proc.communicate_utf8_async(None, None, self.on_status)
        return True

    def on_status(self, proc, result):
        try:
            _, out, _ = proc.communicate_utf8_finish(result)
        except GLib.Error:
            return
        self.status = dict(line.split("=", 1) for line in (out or "").splitlines() if "=" in line)
        wanted = read_conf("REMOTE", "0") == "1"
        running = self.status.get("running") == "1"
        self.setting = True
        self.switch.set_active(wanted)
        self.setting = False
        if not self.status.get("address"):
            state = "The tailnet (tailscale0) has no address: remote access can't start"
        elif running:
            state = "On: waiting for viewers"
        elif wanted and self.status.get("capable") != "1":
            state = "Turns on at the next desktop restart"
        elif wanted:
            state = "Starting…"
        else:
            state = "Off"
        self.switch.set_subtitle(state)
        self.address.set_subtitle(self.address_text() or "no tailnet address")
        pw = read_password()
        if not pw:
            self.password.set_subtitle("made when remote access first starts")
        else:
            self.password.set_subtitle(pw if self.revealed else HIDDEN)

    def address_text(self):
        port = self.status.get("port", "5900")
        name, addr = self.status.get("name"), self.status.get("address")
        if name and addr:
            return f"{name}:{port}  ({addr})" if port != "5900" else f"{name}  ({addr})"
        return f"{addr}:{port}" if addr and port != "5900" else addr or ""

    # --- actions ---
    def on_switch(self, row, _param):
        if self.setting:
            return
        on = row.get_active()
        write_conf("REMOTE", "1" if on else "0")
        if not on:
            subprocess.run([CTL, "stop"], stdin=subprocess.DEVNULL, capture_output=True)
            self.toast("Remote access off")
        elif subprocess.run([CTL, "start"], stdin=subprocess.DEVNULL, capture_output=True).returncode == 3:
            self.toast("Remote access turns on at the next desktop restart")
        else:
            self.toast("Remote access on")
        self.refresh()

    def on_reveal(self, button):
        self.revealed = button.get_active()
        button.set_icon_name("view-conceal-symbolic" if self.revealed else "view-reveal-symbolic")
        button.set_tooltip_text("Hide" if self.revealed else "Show")
        self.refresh()

    def on_change(self, _button):
        dialog = Adw.AlertDialog(heading="Change the VNC password?",
                                 body="Viewers connected now are disconnected and need the new password.")
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("change", "Change")
        dialog.set_response_appearance("change", Adw.ResponseAppearance.SUGGESTED)
        dialog.connect("response", self.on_change_response)
        dialog.present(self)

    def on_change_response(self, _dialog, response):
        if response != "change":
            return
        new_password()
        if self.status.get("running") == "1":
            subprocess.run([CTL, "restart"], stdin=subprocess.DEVNULL, capture_output=True)
        self.toast("New password set" + (": remote access restarted" if self.status.get("running") == "1" else ""))
        self.refresh()

    def copy(self, text, done):
        if text:
            Gdk.Display.get_default().get_clipboard().set(text)
            self.toast(done)

    def toast(self, text):
        self.toasts.add_toast(Adw.Toast(title=text, timeout=3))


class App(Adw.Application):
    def __init__(self):
        super().__init__(application_id="org.frametop.RemoteAccess", flags=Gio.ApplicationFlags.DEFAULT_FLAGS)

    def do_activate(self):
        (self.props.active_window or Window(self)).present()


if __name__ == "__main__":
    sys.exit(App().run(sys.argv))
