# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Connection screen - shown when no device is connected."""

import os

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk
from meshy.ble import BleDevice
from meshy.host_integration import is_flatpak, run_host_command
from meshy.platforms import is_linux
from meshy.storage import Storage
from meshy.utils import clear_children


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/connection-view.ui")
class ConnectionView(Gtk.Box):
    """Connection screen with Bluetooth and USB connection options."""

    __gtype_name__ = "MeshyConnectionView"

    _paired_list = Gtk.Template.Child("paired_list")
    _pair_btn = Gtk.Template.Child("pair_btn")
    _usb_list = Gtk.Template.Child("usb_list")
    _tcp_list = Gtk.Template.Child("tcp_list")
    _tcp_add_btn = Gtk.Template.Child("tcp_add_btn")

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._bt_poll_id = None

        self._pair_btn.connect("clicked", self._on_pair_new)
        self._tcp_add_btn.connect("clicked", self._on_tcp_add_new)

    def _add_remove_context_menu(self, row, on_remove):
        """Add a right-click / long-press context menu with Remove option."""
        menu_model = Gio.Menu()
        menu_model.append(_("Remove"), "row.remove")

        popover = Gtk.PopoverMenu(menu_model=menu_model)
        popover.set_parent(row)
        popover.set_has_arrow(False)
        row._context_popover = popover

        remove_action = Gio.SimpleAction.new("remove", None)
        remove_action.connect("activate", lambda *_: on_remove())
        action_group = Gio.SimpleActionGroup()
        action_group.add_action(remove_action)
        row.insert_action_group("row", action_group)

        def _show_menu(x, y):
            rect = Gdk.Rectangle()
            rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
            popover.set_pointing_to(rect)
            popover.popup()

        right_click = Gtk.GestureClick(button=3)
        right_click.connect("pressed", lambda g, n, x, y: _show_menu(x, y))
        row.add_controller(right_click)

        long_press = Gtk.GestureLongPress()
        long_press.connect("pressed", lambda g, x, y: _show_menu(x, y))
        row.add_controller(long_press)

    def refresh_paired_devices(self):
        """Refresh the list of paired BLE devices, USB serial devices, and TCP companions."""
        self._refresh_tcp_companions()
        # Refresh BLE paired devices
        self._clear_list(self._paired_list)

        # Check if BLE transport is available
        if not self._window.ble_transport:
            placeholder = Adw.ActionRow(
                title=_("Bluetooth not available"),
                subtitle=_("BLE requires running from app bundle on macOS"),
                sensitive=False,
            )
            placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic"))
            self._paired_list.append(placeholder)
            self._pair_btn.set_sensitive(False)
            return

        bt_enabled = self._window.ble_transport.is_bluetooth_enabled()
        self._pair_btn.set_sensitive(bt_enabled)

        if not bt_enabled:
            placeholder = Adw.ActionRow(
                title=_("Bluetooth is disabled"),
                subtitle=_("Enable Bluetooth in system settings to connect"),
                sensitive=False,
            )
            placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic"))
            self._paired_list.append(placeholder)
        else:
            devices = self._window.ble_transport.get_paired_devices()

            if not devices:
                placeholder = Adw.ActionRow(
                    title=_("No paired devices"),
                    subtitle=_("Use the button below to pair a new companion"),
                    sensitive=False,
                )
                placeholder.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-disabled-symbolic"))
                self._paired_list.append(placeholder)
            else:
                for device in devices:
                    row = Adw.ActionRow(
                        title=device.name,
                        subtitle=device.address,
                    )
                    row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-active-symbolic"))
                    connect_btn = Gtk.Button(
                        label=_("Connect"),
                        valign=Gtk.Align.CENTER,
                    )
                    connect_btn.add_css_class("suggested-action")
                    connect_btn.connect(
                        "clicked",
                        lambda b, d=device: self._window.connect_to_device(d),
                    )
                    row.add_suffix(connect_btn)
                    self._add_remove_context_menu(
                        row,
                        lambda d=device: self._remove_ble_device(d),
                    )
                    self._paired_list.append(row)

        self._bt_was_enabled = bt_enabled
        self._start_bt_polling()

        # Refresh USB serial devices
        clear_children(self._usb_list)

        from meshy.usb_serial import UsbSerialTransport

        usb_transport = UsbSerialTransport()
        usb_devices = usb_transport.get_serial_devices()

        if not usb_devices:
            placeholder = Adw.ActionRow(
                title=_("No USB devices detected"),
                subtitle=_("Connect a MeshCore companion via USB"),
                sensitive=False,
            )
            placeholder.add_prefix(Gtk.Image.new_from_icon_name("drive-removable-media-symbolic"))
            self._usb_list.append(placeholder)
        else:
            for device in usb_devices:
                row = Adw.ActionRow(
                    title=device.name,
                    subtitle=device.path,
                )
                row.add_prefix(Gtk.Image.new_from_icon_name("drive-removable-media-symbolic"))
                connect_btn = Gtk.Button(
                    label=_("Connect"),
                    valign=Gtk.Align.CENTER,
                )
                connect_btn.add_css_class("suggested-action")
                connect_btn.connect(
                    "clicked",
                    lambda b, d=device, t=usb_transport: self._connect_usb(d, t),
                )
                row.add_suffix(connect_btn)
                self._usb_list.append(row)

    def _remove_ble_device(self, device):
        """Remove (unpair) a Bluetooth device with confirmation."""
        dialog = Adw.AlertDialog(
            heading=_("Remove Companion"),
            body=_("Remove {} ({})? You will need to pair again to reconnect.").format(
                device.name, device.address
            ),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("remove", _("Remove"))
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)

        delete_check = Gtk.CheckButton(label=_("Delete stored data"))
        dialog.set_extra_child(delete_check)

        def _on_response(d, response):
            if response == "remove":
                if delete_check.get_active():
                    Storage.delete_device_db(device.address)
                self._window.ble_transport.remove_device(device.path)
                self.refresh_paired_devices()

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _connect_usb(self, device, transport):
        """Connect to a USB device with error handling."""
        self._window.connect_to_device(device, transport=transport)
        # Check if connection failed with an error
        if hasattr(transport, "_connection_error") and transport._connection_error:
            error_msg = transport._connection_error
            is_perm = getattr(transport, "_is_permission_error", False)
            transport._connection_error = None
            transport._is_permission_error = False
            if is_perm:
                self._show_permission_dialog(device, transport)
            else:
                dialog = Adw.AlertDialog(
                    heading=_("USB Connection Failed"),
                    body=error_msg,
                )
                dialog.add_response("ok", _("OK"))
                dialog.present(self._window)

    def _can_spawn_host(self):
        """Check if we can use flatpak-spawn --host (not in sandbox, or have permission)."""
        try:
            kf = GLib.KeyFile()
            kf.load_from_file("/.flatpak-info", GLib.KeyFileFlags.NONE)
            try:
                talk_names = kf.get_string_list("Session Bus Policy", "org.freedesktop.Flatpak")
                return "talk" in talk_names or "own" in talk_names
            except GLib.Error:
                return False
        except GLib.Error:
            return True

    def _show_permission_dialog(self, device, transport):
        """Show permission error with option to install udev rule (Linux only)."""
        if not is_linux():
            # macOS doesn't need udev rules - /dev/cu.* devices are accessible by default
            return

        if self._can_spawn_host():
            self._show_install_rule_dialog(device, transport)
        else:
            self._show_manual_rule_dialog(device)

    def _show_install_rule_dialog(self, device, transport):
        """Show dialog with automatic udev rule installation."""
        dialog = Adw.AlertDialog(
            heading=_("USB Permission Denied"),
            body=_(
                "Cannot access {}.\n\n"
                "A udev rule is needed to grant access to USB serial "
                'devices. Click "Install Rule" to install it '
                "(requires administrator password)."
            ).format(device.path),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("install", _("Install Rule"))
        dialog.set_response_appearance("install", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "install":
                self._install_udev_rule(device, transport)

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _show_manual_rule_dialog(self, device):
        """Show dialog with manual udev rule installation instructions."""
        command = (
            'echo \'SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", MODE="0666"\n'
            'SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", MODE="0666"\''
            " | sudo tee /etc/udev/rules.d/60-meshy-serial.rules"
            " && sudo udevadm control --reload-rules"
            " && sudo udevadm trigger"
        )
        dialog = Adw.AlertDialog(
            heading=_("USB Permission Denied"),
            body=_(
                "Cannot access {}.\n\n"
                "A udev rule is needed to grant access to USB serial "
                "devices. Run the following command in a terminal:\n\n"
                "{}"
            ).format(device.path, command),
        )
        dialog.add_response("close", _("Close"))
        dialog.add_response("copy", _("Copy Command"))
        dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "copy":
                clipboard = self._window.get_clipboard()
                clipboard.set(command)
                clipboard.store_async(None, None)
                self._window.show_toast(_("Command copied to clipboard"))

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _install_udev_rule(self, device, transport):
        """Install udev rule via pkexec and retry connection."""
        import tempfile

        rule_content = (
            "# Meshy - MeshCore companion USB serial access\n"
            'SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", MODE="0666"\n'
            'SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", MODE="0666"\n'
        )

        tmp_path = None
        try:
            # In Flatpak, /tmp is sandbox-private and invisible to host
            # commands run via flatpak-spawn. Use the cache dir which is
            # shared at the same absolute path.
            tmp_dir = GLib.get_user_cache_dir() if is_flatpak() else None
            with tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".rules",
                delete=False,
                dir=tmp_dir,
            ) as tmp:
                tmp.write(rule_content)
                tmp_path = tmp.name

            dest = "/etc/udev/rules.d/60-meshy-serial.rules"
            result = run_host_command(
                [
                    "pkexec",
                    "bash",
                    "-c",
                    f'cp "$1" {dest}' " && udevadm control --reload-rules" " && udevadm trigger",
                    "--",
                    tmp_path,
                ],
                capture_output=True,
                text=True,
                timeout=60,
            )
            if result.returncode == 0:
                # Wait a moment then retry connection
                def _retry():
                    self._window.connect_to_device(device, transport=transport)
                    return False

                GLib.timeout_add(2000, _retry)
            else:
                dialog = Adw.AlertDialog(
                    heading=_("Installation Failed"),
                    body=_("Could not install udev rule:\n{}").format(result.stderr.strip()),
                )
                dialog.add_response("ok", _("OK"))
                dialog.present(self._window)
        except Exception as e:
            dialog = Adw.AlertDialog(
                heading=_("Installation Failed"),
                body=str(e),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
        finally:
            if tmp_path and os.path.exists(tmp_path):
                os.unlink(tmp_path)

    def _clear_list(self, list_widget):
        child = list_widget.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            popover = getattr(child, "_context_popover", None)
            if popover:
                popover.unparent()
            list_widget.remove(child)
            child = next_child

    def _refresh_tcp_companions(self):
        """Refresh the list of saved TCP companions."""
        self._clear_list(self._tcp_list)

        from gi.repository import Gio

        settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")
        companions = settings.get_strv("tcp-companions")

        if not companions:
            placeholder = Adw.ActionRow(
                title=_("No saved companions"),
                subtitle=_("Use the button below to add one"),
                sensitive=False,
            )
            placeholder.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic"))
            self._tcp_list.append(placeholder)
        else:
            for entry in companions:
                if "|" not in entry:
                    continue
                name, address = entry.split("|", 1)
                row = Adw.ActionRow(title=name, subtitle=address)
                row.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic"))
                connect_btn = Gtk.Button(
                    label=_("Connect"),
                    valign=Gtk.Align.CENTER,
                )
                connect_btn.add_css_class("suggested-action")
                connect_btn.connect(
                    "clicked",
                    lambda b, a=address: self._connect_tcp_address(a),
                )
                row.add_suffix(connect_btn)
                menu_model = Gio.Menu()
                menu_model.append(_("Change Address"), "row.change_address")
                menu_model.append(_("Remove"), "row.remove")

                popover = Gtk.PopoverMenu(menu_model=menu_model)
                popover.set_parent(row)
                popover.set_has_arrow(False)
                row._context_popover = popover

                action_group = Gio.SimpleActionGroup()
                change_action = Gio.SimpleAction.new("change_address", None)
                change_action.connect(
                    "activate", lambda *_, a=address: self._change_tcp_companion_address(a)
                )
                action_group.add_action(change_action)
                remove_action = Gio.SimpleAction.new("remove", None)
                remove_action.connect(
                    "activate", lambda *_, a=address: self._remove_tcp_companion(a)
                )
                action_group.add_action(remove_action)
                row.insert_action_group("row", action_group)

                def _show_menu(x, y, p=popover):
                    rect = Gdk.Rectangle()
                    rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
                    p.set_pointing_to(rect)
                    p.popup()

                right_click = Gtk.GestureClick(button=3)
                right_click.connect("pressed", lambda g, n, x, y: _show_menu(x, y))
                row.add_controller(right_click)

                long_press = Gtk.GestureLongPress()
                long_press.connect("pressed", lambda g, x, y: _show_menu(x, y))
                row.add_controller(long_press)

                self._tcp_list.append(row)

    def _connect_tcp_address(self, address: str):
        """Connect to a saved TCP companion by address (host:port)."""
        from meshy.tcp import TcpDevice, TcpTransport

        host, port_str = address.rsplit(":", 1)
        transport = TcpTransport()
        device = TcpDevice(host, int(port_str))
        self._window.connect_to_device(device, transport=transport)

    def _remove_tcp_companion(self, address: str):
        """Remove a saved TCP companion with confirmation."""
        dialog = Adw.AlertDialog(
            heading=_("Remove Companion"),
            body=_("Remove TCP companion {}?").format(address),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("remove", _("Remove"))
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)

        delete_check = Gtk.CheckButton(label=_("Delete stored data"))
        dialog.set_extra_child(delete_check)

        def _on_response(d, response):
            if response == "remove":
                if delete_check.get_active():
                    Storage.delete_device_db(address)
                self._window.remove_tcp_companion(address)
                self._refresh_tcp_companions()

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _change_tcp_companion_address(self, address: str):
        """Show dialog to change the address of a saved TCP companion."""
        host, port_str = address.rsplit(":", 1)

        dialog = Adw.AlertDialog(
            heading=_("Change Address"),
            body=_("Enter the new address for this companion."),
        )

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/tcp-change-address-content.ui"
        )
        host_entry = builder.get_object("host_entry")
        host_entry.set_text(host)
        port_entry = builder.get_object("port_entry")
        port_entry.set_text(port_str)

        dialog.set_extra_child(builder.get_object("content"))
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("save", _("Save"))
        dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response != "save":
                return
            new_host = host_entry.get_text().strip()
            new_port_str = port_entry.get_text().strip() or port_str
            if not new_host:
                return
            try:
                port = int(new_port_str)
                if not 1 <= port <= 65535:
                    raise ValueError
            except ValueError:
                return
            new_address = f"{new_host}:{port}"
            self._window.update_tcp_companion_address(address, new_address)
            self._refresh_tcp_companions()

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _on_tcp_add_new(self, *args):
        """Show dialog to add a new TCP companion."""
        dialog = Adw.AlertDialog(
            heading=_("Add TCP Companion"),
            body=_("Enter the IP address and port of your MeshCore device."),
        )

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/tcp-add-content.ui"
        )
        host_entry = builder.get_object("host_entry")
        port_entry = builder.get_object("port_entry")

        dialog.set_extra_child(builder.get_object("content"))
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("connect", _("Connect"))
        dialog.set_response_appearance("connect", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response != "connect":
                return
            host = host_entry.get_text().strip()
            port_str = port_entry.get_text().strip() or "5000"
            if not host:
                return
            try:
                port = int(port_str)
                if not 1 <= port <= 65535:
                    raise ValueError
            except ValueError:
                return
            from meshy.tcp import TcpDevice, TcpTransport

            transport = TcpTransport()
            device = TcpDevice(host, port)
            self._window.connect_to_device(device, transport=transport)

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _start_bt_polling(self):
        """Poll for Bluetooth state changes every 2 seconds."""
        if self._bt_poll_id is not None:
            return

        def _check_bt():
            bt_enabled = self._window.ble_transport.is_bluetooth_enabled()
            if bt_enabled != self._bt_was_enabled:
                self.refresh_paired_devices()
            return True

        self._bt_poll_id = GLib.timeout_add(2000, _check_bt)

    def stop_polling(self):
        """Stop polling for Bluetooth state."""
        if self._bt_poll_id is not None:
            GLib.source_remove(self._bt_poll_id)
            self._bt_poll_id = None

    def _on_pair_new(self, *args):
        """Scan for new MeshCore devices and pair."""
        # Check if BLE transport is available
        if not self._window.ble_transport:
            return

        builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/pair-dialog.ui")
        dialog = builder.get_object("dialog")
        list_box = builder.get_object("list_box")
        placeholder = builder.get_object("placeholder")
        list_box.set_placeholder(placeholder)

        seen_addresses = set()
        paired_addresses = {
            d.address.upper() for d in self._window.ble_transport.get_paired_devices()
        }

        def on_device_found(device: BleDevice):
            if device.address in seen_addresses:
                return
            if device.address.upper() in paired_addresses:
                return
            seen_addresses.add(device.address)

            row = Adw.ActionRow(
                title=device.name,
                subtitle=device.address,
            )
            row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-active-symbolic"))
            pair_btn = Gtk.Button(label=_("Pair"), valign=Gtk.Align.CENTER)
            pair_btn.add_css_class("suggested-action")
            pair_btn.connect("clicked", lambda b, d=device: self._initiate_pairing(d, dialog))
            row.add_suffix(pair_btn)
            list_box.append(row)

        # Save original callback and set ours
        orig_callback = self._window.ble_transport.on_device_discovered
        self._window.ble_transport.on_device_discovered = on_device_found

        def on_dialog_closed(d):
            self._window.ble_transport.on_device_discovered = orig_callback
            if self._window.ble_transport.state.name == "SCANNING":
                self._window.ble_transport.stop_scan()
            # Refresh paired list in case a new device was just paired
            self.refresh_paired_devices()

        dialog.connect("closed", on_dialog_closed)

        # Start scanning
        self._window.ble_transport.start_scan()
        dialog.present(self._window)

    def _initiate_pairing(self, device: BleDevice, scan_dialog: Adw.Dialog):
        """Start pairing with a device, showing passkey dialog if needed."""
        # Stop scanning and close scan dialog
        if self._window.ble_transport.state.name == "SCANNING":
            self._window.ble_transport.stop_scan()
        scan_dialog.close()

        # Set up passkey handler
        def on_passkey_requested(device_path, reply_callback):
            GLib.idle_add(lambda: self._show_passkey_dialog(device_path, reply_callback) or False)

        self._window.ble_transport.on_passkey_requested = on_passkey_requested
        self._window.ble_transport.pair_device(device.path)

    def _show_passkey_dialog(self, device_path, reply_callback):
        """Show dialog asking user to enter the passkey displayed on the device."""
        dialog = Adw.AlertDialog(
            heading=_("Enter Pairing PIN"),
            body=_("Enter the PIN shown on your MeshCore device."),
        )

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/passkey-dialog-content.ui"
        )
        dialog.set_extra_child(builder.get_object("content"))
        pin_entry = builder.get_object("pin_entry")

        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("pair", _("Pair"))
        dialog.set_response_appearance("pair", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "pair":
                try:
                    passkey = int(pin_entry.get_text().strip())
                    reply_callback(passkey)
                except ValueError:
                    reply_callback(0)
            else:
                reply_callback(0)

        dialog.connect("response", _on_response)
        dialog.present(self._window)
