#!/usr/bin/env python3
"""NexusOS network popup daemon — toggle via SIGUSR1, instant open."""
# PyGObject (gi.repository) is dynamically generated and its API is Optional-heavy
# (e.g. Gdk.Display.get_default() is typed Display|None). These are fine at runtime,
# so silence the type-checker noise for this GTK desktop script.
# pyright: reportMissingModuleSource=false, reportOptionalMemberAccess=false, reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false

import os, sys, signal, threading, subprocess
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
from nexus_menu_base import apply_css, get_panel_bottom, get_mouse_position

PID_FILE     = "/tmp/nexus-network-popup.pid"
VISIBLE_FILE = "/tmp/nexus-network-popup-visible"

POPUP_CSS = b"""
list { background-color: transparent; }
list row { background-color: transparent; padding: 0; border: none; }
list row:hover { background-color: rgba(140,198,63,0.12); }
list row:selected { background-color: rgba(140,198,63,0.18); }
.net-header { padding: 10px 12px; }
.net-ssid { font-weight: bold; }
.net-ip { color: #a8a8a8; font-size: 0.85em; }
.net-check { color: #8cc63f; font-weight: bold; }
.net-lock { color: #a8a8a8; }
.net-row-box { padding: 7px 8px; }
.footer-btn { padding: 6px 12px; border-radius: 0; border: none;
              background-color: transparent; color: #a8a8a8; }
.footer-btn:hover { background-color: rgba(140,198,63,0.12); color: #f2f2f2; }
.vpn-row { padding: 8px 12px; }
.vpn-label { font-size: 0.9em; }
.vpn-status { color: #a8a8a8; font-size: 0.8em; }
switch { background-color: #3a3d41; border-radius: 14px; border: 1px solid #4d3461;
         min-width: 42px; min-height: 22px; }
switch:checked { background-color: #8cc63f; border-color: #6ba62a; }
switch slider { background-color: #f2f2f2; border-radius: 50%;
                min-width: 16px; min-height: 16px; margin: 2px; }
"""


def nmcli(*args):
    try:
        return subprocess.check_output(
            ["nmcli", "-t", "--escape", "no"] + list(args),
            text=True, stderr=subprocess.DEVNULL
        ).strip()
    except Exception:
        return ""


# This file lives at <repo>/bin/panel/network-popup.py, so the repo root is
# three parents up (panel -> bin -> repo). Using only two parents pointed
# ICON_DIR at the nonexistent bin/assets/panel-icons, so every signal-icon
# load failed and each SSID row fell back to a "?" label.
ICON_DIR = str(Path(__file__).resolve().parent.parent.parent / "assets" / "panel-icons")

def wg_connection():
    """(name, is_up) for the WireGuard tunnel, or (None, False) if there is
    none. Asked of NetworkManager rather than hardcoded: this was a literal
    'wgs_client', so reissuing the tunnel under a new name left the switch
    silently dead - it read Disconnected forever and only errored on click.
    Prefers an active tunnel, else the first configured one."""
    wgs = [p for p in (line.split(":") for line
                       in nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines())
           if len(p) >= 3 and p[1] == "wireguard"]
    for name, _, state in wgs:
        if state == "activated":
            return name, True
    return (wgs[0][0], False) if wgs else (None, False)


def signal_icon(sig):
    if sig >= 80: return f"{ICON_DIR}/network-wireless-signal-excellent.png"
    if sig >= 60: return f"{ICON_DIR}/network-wireless-signal-good.png"
    if sig >= 40: return f"{ICON_DIR}/network-wireless-signal-ok.png"
    if sig >= 20: return f"{ICON_DIR}/network-wireless-signal-weak.png"
    return f"{ICON_DIR}/network-wireless-signal-none.png"


class NetworkPopup:
    def __init__(self):
        self.visible = False
        self._click_x = None
        self.vpn_switch = None
        self.vpn_status_lbl = None
        self.vpn_conn = None
        self._build_window()

        def _on_sig(s, f):
            # Capture mouse position NOW (signal handler runs in main thread)
            try:
                self._click_x = get_mouse_position()[0]
            except Exception:
                self._click_x = None
            GLib.idle_add(self.toggle)

        signal.signal(signal.SIGUSR1, _on_sig)
        with open(PID_FILE, 'w') as f:
            f.write(str(os.getpid()))

    def _build_window(self):
        self.win = Gtk.Window(type=Gtk.WindowType.POPUP)
        self.win.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU)
        self.win.set_decorated(False)
        self.win.set_skip_taskbar_hint(True)
        self.win.set_keep_above(True)
        self.win.set_default_size(190, -1)

        self.win.connect('key-press-event',
                         lambda w, e: self.hide() if e.keyval == Gdk.KEY_Escape else None)

        def on_button_press(w, event):
            wx, wy = w.get_position()
            ww, wh = w.get_allocated_width(), w.get_allocated_height()
            if (int(event.x_root) < wx or int(event.x_root) >= wx + ww or
                    int(event.y_root) < wy or int(event.y_root) >= wy + wh):
                self.hide()
            return False
        self.win.connect('button-press-event', on_button_press)

        def on_map(w, _):
            gdk_win = w.get_window()
            if gdk_win:
                Gdk.Display.get_default().get_default_seat().grab(
                    gdk_win, Gdk.SeatCapabilities.ALL, True, None, None, None)
            return False
        self.win.connect('map-event', on_map)

        def on_unmap(w, _):
            Gdk.Display.get_default().get_default_seat().ungrab()
        self.win.connect('unmap-event', on_unmap)

    def toggle(self):
        if self.visible:
            self.hide()
        else:
            self.show()

    def show(self):
        for child in self.win.get_children():
            self.win.remove(child)
        self.win.add(self._build_content())
        self.win.show_all()
        self._position()
        self.win.present()
        self.visible = True
        open(VISIBLE_FILE, 'w').close()

    def hide(self):
        self.win.hide()
        self.visible = False
        try:
            os.remove(VISIBLE_FILE)
        except FileNotFoundError:
            pass

    def _position(self):
        mx = self._click_x if self._click_x is not None else get_mouse_position()[0]
        screen = Gdk.Screen.get_default()
        sw = screen.get_width()
        w = 190
        panel_bottom = get_panel_bottom()
        x = max(4, min(mx - w // 2, sw - w - 4))
        self.win.move(x, panel_bottom + 2)

    def _build_content(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # ── Header (current connection) ──────────────────────────────
        self.header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.header.get_style_context().add_class("net-header")
        spin = Gtk.Spinner(); spin.start()
        self.header.pack_start(spin, False, False, 0)
        self.header.pack_start(Gtk.Label(label="Loading…"), True, True, 0)
        outer.pack_start(self.header, False, False, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        # ── Network list ─────────────────────────────────────────────
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self.listbox.connect('row-activated', self._on_row_activated)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        sw.set_min_content_height(280)
        sw.set_max_content_height(480)
        sw.set_propagate_natural_height(True)
        sw.add(self.listbox)
        outer.pack_start(sw, True, True, 0)

        outer.pack_start(Gtk.Separator(), False, False, 0)

        # ── VPN (WireGuard) toggle ────────────────────────────────────
        vpn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        vpn_row.get_style_context().add_class("vpn-row")

        vpn_icon = Gtk.Label(label="🔒")
        vpn_row.pack_start(vpn_icon, False, False, 0)

        vpn_text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vpn_name = Gtk.Label(label="WireGuard", xalign=0.0)
        vpn_name.get_style_context().add_class("vpn-label")
        self.vpn_status_lbl = Gtk.Label(label="Checking…", xalign=0.0)
        self.vpn_status_lbl.get_style_context().add_class("vpn-status")
        vpn_text.pack_start(vpn_name, False, False, 0)
        vpn_text.pack_start(self.vpn_status_lbl, False, False, 0)
        vpn_row.pack_start(vpn_text, True, True, 0)

        self.vpn_switch = Gtk.Switch()
        self.vpn_switch.set_valign(Gtk.Align.CENTER)
        self._vpn_handler = self.vpn_switch.connect('state-set', self._on_vpn_toggle)
        vpn_row.pack_start(self.vpn_switch, False, False, 0)

        # show() rebuilds this content every time the popup opens, so the
        # tunnel is re-resolved each time rather than cached at startup.
        self.vpn_conn, vpn_up = wg_connection()
        self.vpn_switch.handler_block(self._vpn_handler)
        self.vpn_switch.set_active(vpn_up)
        self.vpn_switch.set_sensitive(self.vpn_conn is not None)
        self.vpn_switch.handler_unblock(self._vpn_handler)
        self.vpn_status_lbl.set_text(
            "Connected" if vpn_up
            else "Disconnected" if self.vpn_conn
            else "No tunnel configured")

        outer.pack_start(vpn_row, False, False, 0)
        outer.pack_start(Gtk.Separator(), False, False, 0)

        # ── Footer ───────────────────────────────────────────────────
        btn = Gtk.Button(label="Network Settings")
        btn.get_style_context().add_class("footer-btn")
        btn.set_relief(Gtk.ReliefStyle.NONE)
        btn.connect('clicked', lambda _: (self.hide(),
                                          subprocess.Popen(['nm-connection-editor'])))
        outer.pack_start(btn, False, False, 0)

        threading.Thread(target=self._fetch, daemon=True).start()
        return outer

    def _fetch(self):
        conn_type, device, current_ssid, ip = "", "", "", ""

        devs = nmcli("-f", "TYPE,STATE,DEVICE", "dev")
        for line in devs.splitlines():
            parts = line.split(":")
            if len(parts) >= 2 and parts[1] == "connected":
                conn_type = parts[0]
                device    = parts[2] if len(parts) > 2 else ""
                break

        if conn_type == "wifi":
            raw = nmcli("-f", "ACTIVE,SSID,SIGNAL", "dev", "wifi")
            for line in raw.splitlines():
                parts = line.split(":")
                if parts[0] == "yes" and len(parts) >= 2:
                    current_ssid = parts[1]
                    break
            ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show")
                  .split("\n")[0].split(":")[-1].split("/")[0].strip())
        elif conn_type == "ethernet":
            ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show")
                  .split("\n")[0].split(":")[-1].split("/")[0].strip())

        raw = nmcli("-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list", "--rescan", "no")
        seen, networks = set(), []
        for line in raw.splitlines():
            parts = line.split(":")
            if len(parts) < 2: continue
            ssid = parts[0].strip()
            if not ssid or ssid in seen: continue
            seen.add(ssid)
            try:    sig = int(parts[1])
            except: sig = 0
            sec    = parts[2].strip() if len(parts) > 2 else "Open"
            in_use = (parts[3].strip() == "*") if len(parts) > 3 else False
            networks.append((ssid, sig, sec, in_use))
        networks.sort(key=lambda r: (not r[3], -r[1]))

        GLib.idle_add(self._populate, conn_type, device, current_ssid, ip, networks[:20])

    def _populate(self, conn_type, device, current_ssid, ip, networks):
        # Rebuild header
        for child in self.header.get_children():
            self.header.remove(child)

        if conn_type == "wifi" and current_ssid:
            vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            lbl_ssid = Gtk.Label(label=current_ssid, xalign=0.0)
            lbl_ssid.get_style_context().add_class("net-ssid")
            lbl_ip = Gtk.Label(label=ip or "no IP", xalign=0.0)
            lbl_ip.get_style_context().add_class("net-ip")
            vbox.pack_start(lbl_ssid, False, False, 0)
            vbox.pack_start(lbl_ip,   False, False, 0)
            self.header.pack_start(vbox, True, True, 0)

            btn_dis = Gtk.Button(label="Disconnect")
            btn_dis.get_style_context().add_class("footer-btn")
            btn_dis.set_relief(Gtk.ReliefStyle.NONE)
            btn_dis.connect('clicked', lambda _, d=device: (
                self.hide(),
                subprocess.Popen(["bash", "-c",
                    f"nmcli dev disconnect {d}; notify-send Network Disconnected"])
            ))
            self.header.pack_start(btn_dis, False, False, 0)

        elif conn_type == "ethernet":
            lbl = Gtk.Label(label=f"Wired  {ip or ''}", xalign=0.0)
            self.header.pack_start(lbl, True, True, 0)
        else:
            lbl = Gtk.Label(label="Not connected", xalign=0.0)
            lbl.get_style_context().add_class("net-ip")
            self.header.pack_start(lbl, True, True, 0)

        self.header.show_all()

        for ssid, sig, sec, in_use in networks:
            row = Gtk.ListBoxRow()
            row.ssid   = ssid
            row.sec    = sec
            row.in_use = in_use

            box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            box.get_style_context().add_class("net-row-box")

            try:
                pb = GdkPixbuf.Pixbuf.new_from_file_at_size(signal_icon(sig), 16, 16)
                img = Gtk.Image.new_from_pixbuf(pb)
            except Exception:
                img = Gtk.Label(label="?")
            box.pack_start(img, False, False, 0)

            ssid_lbl = Gtk.Label(label=ssid, xalign=0.0)
            ssid_lbl.set_ellipsize(3)
            if in_use:
                ssid_lbl.get_style_context().add_class("net-ssid")
            box.pack_start(ssid_lbl, True, True, 0)

            if sec and sec.lower() not in ("open", "--", ""):
                lock = Gtk.Label(label="")
                lock.get_style_context().add_class("net-lock")
                box.pack_start(lock, False, False, 0)

            if in_use:
                chk = Gtk.Label(label="✓")
                chk.get_style_context().add_class("net-check")
                box.pack_start(chk, False, False, 0)

            row.add(box)
            self.listbox.add(row)

        self.listbox.show_all()

        # Re-position now that height is known
        GLib.idle_add(self._position)

    def _on_vpn_toggle(self, switch, state):
        threading.Thread(target=self._do_vpn_toggle, args=(state,), daemon=True).start()
        return False  # let GTK move the switch immediately; revert on failure

    def _do_vpn_toggle(self, enable):
        if not self.vpn_conn:
            subprocess.Popen(["notify-send", "-u", "critical", "WireGuard",
                              "No WireGuard connection configured"])
            GLib.idle_add(self._apply_vpn_state, False, "No tunnel configured")
            return
        action = "up" if enable else "down"
        try:
            subprocess.check_output(
                ["nmcli", "connection", action, self.vpn_conn],
                stderr=subprocess.STDOUT, text=True
            )
            label = "Connected" if enable else "Disconnected"
            subprocess.Popen(["notify-send", "WireGuard", label])
            GLib.idle_add(self._apply_vpn_state, enable, label)
        except subprocess.CalledProcessError as e:
            msg = (e.output.strip().split("\n")[-1] if e.output else "Unknown error")
            subprocess.Popen(["notify-send", "-u", "critical", "WireGuard",
                              f"Failed: {msg}"])
            # Revert switch on failure
            GLib.idle_add(self._apply_vpn_state, not enable,
                          "Connected" if not enable else "Disconnected")

    def _apply_vpn_state(self, active, label):
        if self.vpn_switch:
            self.vpn_switch.handler_block(self._vpn_handler)
            self.vpn_switch.set_active(active)
            self.vpn_switch.handler_unblock(self._vpn_handler)
        if self.vpn_status_lbl:
            self.vpn_status_lbl.set_text(label)

    def _on_row_activated(self, lb, row):
        if row.in_use:
            return
        self.hide()
        if row.sec and row.sec.lower() not in ("open", "--", ""):
            self._show_password_prompt(row.ssid)
        else:
            self._do_connect(row.ssid)

    def _do_connect(self, ssid, password=None):
        def run():
            cmd = ["nmcli", "dev", "wifi", "connect", ssid]
            if password:
                cmd += ["password", password]
            try:
                subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
                subprocess.Popen(["notify-send", "Network", f"Connected to {ssid}"])
            except subprocess.CalledProcessError as e:
                msg = (e.output.strip().split("\n")[-1]
                       if e.output else "Unknown error")
                subprocess.Popen(["notify-send", "-u", "critical", "Network",
                                   f"Failed to connect to {ssid}:\n{msg}"])
        subprocess.Popen(["notify-send", "Network", f"Connecting to {ssid}…"])
        threading.Thread(target=run, daemon=True).start()

    def _show_password_prompt(self, ssid):
        dlg = Gtk.Window()
        dlg.set_title(f"Connect to {ssid}")
        dlg.set_default_size(300, -1)
        dlg.set_position(Gtk.WindowPosition.CENTER)
        dlg.set_keep_above(True)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        box.set_margin_top(16); box.set_margin_bottom(16)
        box.set_margin_start(16); box.set_margin_end(16)

        lbl = Gtk.Label(xalign=0.0)
        lbl.set_markup(f'<b>Password for "{ssid}"</b>')
        entry = Gtk.Entry()
        entry.set_visibility(False)
        entry.set_placeholder_text("Enter password…")

        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        btn_box.set_halign(Gtk.Align.END)
        btn_cancel  = Gtk.Button(label="Cancel")
        btn_connect = Gtk.Button(label="Connect")
        btn_box.pack_start(btn_cancel,  False, False, 0)
        btn_box.pack_start(btn_connect, False, False, 0)

        box.pack_start(lbl,     False, False, 0)
        box.pack_start(entry,   False, False, 0)
        box.pack_start(btn_box, False, False, 0)
        dlg.add(box)

        def on_confirm(_):
            pw = entry.get_text()
            dlg.destroy()
            if pw:
                self._do_connect(ssid, pw)

        entry.connect("activate", on_confirm)
        btn_connect.connect("clicked", on_confirm)
        btn_cancel.connect("clicked",  lambda _: dlg.destroy())
        dlg.connect("key-press-event",
                    lambda w, e: w.destroy() if e.keyval == Gdk.KEY_Escape else None)
        dlg.show_all()
        dlg.present()


def main():
    # If already running, toggle it and exit
    try:
        with open(PID_FILE) as f:
            pid = int(f.read().strip())
        os.kill(pid, signal.SIGUSR1)
        sys.exit(0)
    except (FileNotFoundError, ProcessLookupError, ValueError):
        pass

    provider = Gtk.CssProvider()
    provider.load_from_data(POPUP_CSS)
    Gtk.StyleContext.add_provider_for_screen(
        Gdk.Screen.get_default(), provider,
        Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    apply_css()
    NetworkPopup()
    Gtk.main()


if __name__ == "__main__":
    main()
