# 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, Gtk, GLib

from meshy.ble import BleDevice
from meshy.host_integration import run_host_command
from meshy.storage import Storage


@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)

        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
        child = self._usb_list.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self._usb_list.remove(child)
            child = next_child

        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."""
        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 subprocess
        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:
            with tempfile.NamedTemporaryFile(
                mode='w', suffix='.rules', delete=False,
            ) as tmp:
                tmp.write(rule_content)
                tmp_path = tmp.name

            result = run_host_command(
                [
                    'pkexec',
                    'cp', tmp_path,
                    '/etc/udev/rules.d/60-meshy-serial.rules',
                ],
                capture_output=True, text=True, timeout=60,
            )
            if result.returncode == 0:
                # Reload udev rules
                run_host_command(
                    ['pkexec', 'udevadm', 'control', '--reload-rules'],
                    capture_output=True, timeout=10,
                )
                run_host_command(
                    ['pkexec', 'udevadm', 'trigger'],
                    capture_output=True, timeout=10,
                )

                # 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)
                self._add_remove_context_menu(
                    row,
                    lambda a=address: self._remove_tcp_companion(a),
                )
                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 TcpTransport, TcpDevice
        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 _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.'),
        )

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        host_entry = Adw.EntryRow(title=_('Hostname / IP Address'))
        host_entry.set_input_purpose(Gtk.InputPurpose.URL)
        port_entry = Adw.EntryRow(title=_('Port'))
        port_entry.set_input_purpose(Gtk.InputPurpose.DIGITS)
        port_entry.set_text('5000')
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(host_entry)
        fields_list.append(port_entry)
        content.append(fields_list)

        dialog.set_extra_child(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 TcpTransport, TcpDevice
            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."""
        dialog = Adw.Dialog()
        dialog.set_title(_('Pair New Companion'))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=8,
            margin_start=8, margin_end=8, margin_bottom=8,
        )

        # Status with spinner
        status_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
        spinner = Gtk.Spinner(spinning=True)
        status_box.append(spinner)
        status_label = Gtk.Label(label=_('Scanning for MeshCore devices...'))
        status_label.add_css_class('dim-label')
        status_box.append(status_label)
        content.append(status_box)

        # Device list
        scroll = Gtk.ScrolledWindow(vexpand=True)
        list_wrapper = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            margin_start=8, margin_end=8, margin_top=8, margin_bottom=8,
        )
        list_box = Gtk.ListBox()
        list_box.set_selection_mode(Gtk.SelectionMode.NONE)
        list_box.add_css_class('boxed-list')

        placeholder = Adw.StatusPage(
            icon_name='bluetooth-active-symbolic',
            title=_('Scanning...'),
            description=_('Looking for nearby MeshCore devices'),
        )
        list_box.set_placeholder(placeholder)

        list_wrapper.append(list_box)
        scroll.set_child(list_wrapper)
        content.append(scroll)

        toolbar_view.set_content(content)
        dialog.set_child(toolbar_view)

        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.'),
        )

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        pin_entry = Adw.EntryRow(title=_('PIN'))
        pin_entry.set_input_purpose(Gtk.InputPurpose.DIGITS)
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(pin_entry)
        content.append(fields_list)
        dialog.set_extra_child(content)

        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)
