# connection_page.py
#
# Copyright 2024 Christopher Talbot
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

from gi.repository import Adw
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Gio
import json
import gettext
import pyqrcode
import meshtastic.serial_interface
import meshtastic.tcp_interface
import meshtastic.mesh_interface
import meshtastic.ble_interface
import meshtastic
from meshtastic.util import fromPSK
from pubsub import pub
import os

import gtk_meshtastic_client.bluetooth_device_row as bluetooth_device_row
import gtk_meshtastic_client.message_parser as message_parser
import gtk_meshtastic_client.message_storage as message_storage
import gtk_meshtastic_client.ble_storage as ble_storage
import gtk_meshtastic_client.nearby_nodes_page as nearby_nodes_page
import gtk_meshtastic_client.device_configuration_page as device_configuration_page
import gtk_meshtastic_client.channel_configuration_page as channel_configuration_page
import gtk_meshtastic_client.utils as utils
import gtk_meshtastic_client.notification as notification

bluetooth_scanned_device_path = utils.meshtastic_config_dir + '/bluetooth_scanned.json'

@Gtk.Template(resource_path='/org/kop316/meshtastic/ui/connection_page.ui')
class ConnectionPageBin(Adw.Bin):
    __gtype_name__ = 'ConnectionPageBin'

    bluetooth_devices_group = Gtk.Template.Child()
    bluetooth_devices_list_box = Gtk.Template.Child()
    bluetooth_scan_button = Gtk.Template.Child()
    channel_configuration_page = Gtk.Template.Child()
    connect_serial_button = Gtk.Template.Child()
    connect_tcp_entry = Gtk.Template.Child()
    connect_tcp_entry_button = Gtk.Template.Child()
    connection_page_nav_view = Gtk.Template.Child()
    device_configuration_page = Gtk.Template.Child()
    device_preferences_button = Gtk.Template.Child()
    disconnect_button = Gtk.Template.Child()
    refresh_serial_ports_button = Gtk.Template.Child()
    region_selection = Gtk.Template.Child()
    serial_port_selection = Gtk.Template.Child()
    serial_port_options = Gtk.Template.Child()

    disconnection_event = False
    bluetooth_devices_list_box_children = 0
    num_serial_ports = 0

    reconnection_wait_time = 15000 #milliseconds

    def set_connect_button_sensitivity(self, sensitivity):
        #Set sensitive when connected
        self.device_preferences_button.set_sensitive(sensitivity)
        self.disconnect_button.set_sensitive(sensitivity)

    def set_disconnect_button_sensitivity(self, not_sensitivity):
        #Set sensitive when disconnected
        self.connect_serial_button.set_sensitive(not_sensitivity)
        self.refresh_serial_ports_button.set_sensitive(not_sensitivity)
        self.connect_tcp_entry_button.set_sensitive(not_sensitivity)
        self.bluetooth_scan_button.set_sensitive(not_sensitivity)
        self.connect_tcp_entry.set_sensitive(not_sensitivity)

        for x in range(self.bluetooth_devices_list_box_children):
            row = self.bluetooth_devices_list_box.get_row_at_index(x)
            row.connect_button.set_sensitive(not_sensitivity)

    def set_connected_button_sensitivity(self, sensitivity):
        not_sensitivity = not sensitivity
        self.set_connect_button_sensitivity(sensitivity)
        self.set_disconnect_button_sensitivity(not_sensitivity)

    def disconnected_cb(self) -> bool:
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)
        self.set_connected_button_sensitivity(False)
        #There's no need to close a connection once disconnected

        win.window_toast_overlay.add_toast(Adw.Toast(title="Disconnected"))

        win.nearby_nodes_page_bin.reset_all_nodes()
        win.channels_page_bin.reset_all_channels_and_dms()

        win.maps_page_bin.reset_map()
        self.connection_page_nav_view.pop_to_tag("connection-page")
        self.region_selection.set_selected(0)

        self.message_parser.reset_connection_timeout()

        return GLib.SOURCE_REMOVE

    def find_ble_row_by_address(self, address):
        if self.bluetooth_devices_list_box_children == 0:
            return None

        for x in range(self.bluetooth_devices_list_box_children):
            node_to_test = self.bluetooth_devices_list_box.get_row_at_index(x)
            if address == node_to_test.device_address:
                node_to_connect = node_to_test
                return node_to_connect

        return None

    def lost_interface_serial_recover_cb(self) -> bool:
        self.logger.debug("Attempting to recover serial Interface")

        if not self.connect_serial_button.get_sensitive():
            self.logger.debug("There is already a connection event occuring")
            return GLib.SOURCE_REMOVE

        ports = meshtastic.util.findPorts(True)
        if len(ports) == 0:
            self.logger.debug("No Serial Device to connect to")
            return GLib.SOURCE_REMOVE

        self._connect_serial_button_clicked_cb(self.connect_serial_button)
        return GLib.SOURCE_REMOVE

    def lost_interface_ble_recover_cb(self) -> bool:
        self.logger.debug("Attempting to recover BLE Interface")
        if hasattr(self, 'ble_device_address'):
            node_to_connect = self.find_ble_row_by_address(self.ble_device_address)
        else:
            self.logger.warning("Don't know what BLE Address to reconnect to")
            return GLib.SOURCE_REMOVE

        if node_to_connect is None:
            self.logger.warning("Cannot find BLE Row to reconnect to")
            return GLib.SOURCE_REMOVE

        if not node_to_connect.connect_button.get_sensitive():
            self.logger.debug("There is already a connection event occuring")
            return GLib.SOURCE_REMOVE

        node_to_connect._bluetooth_connect_button_clicked_cb(node_to_connect.connect_button)
        return GLib.SOURCE_REMOVE

    def lost_interface_tcp_recover_cb(self) -> bool:
        self.logger.debug("Attempting to recover TCP Interface")

        if not self.connect_tcp_entry_button.get_sensitive():
            self.logger.debug("There is already a connection event occuring")
            return GLib.SOURCE_REMOVE

        self._connect_tcp_button_clicked_cb(self.connect_tcp_entry_button)
        return GLib.SOURCE_REMOVE

    """
    The Python Library doesn't know the  difference between just disconnecting
    and if the connection was lost, so we must do that here.
    """
    def onDisconnection(self, interface):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if self.disconnection_event:
            self.logger.debug("Disconnection event, Not allowing reconnect")
            return

        if isinstance(interface, meshtastic.serial_interface.SerialInterface):
            self.logger.debug("Lost connection Serial Interface")
            notification._lost_connection_notification(True)
            GLib.timeout_add(self.reconnection_wait_time, self.lost_interface_serial_recover_cb)
        elif isinstance(interface, meshtastic.ble_interface.BLEInterface):
            self.logger.debug("Lost connection BLE Interface")
            notification._lost_connection_notification(True)
            GLib.timeout_add(self.reconnection_wait_time, self.lost_interface_ble_recover_cb)
        elif isinstance(interface, meshtastic.tcp_interface.TCPInterface):
            self.logger.debug("Lost connection TCP Interface")
            notification._lost_connection_notification(True)
            GLib.timeout_add(self.reconnection_wait_time, self.lost_interface_tcp_recover_cb)

        self._disconnect_button_clicked_cb(self.disconnect_button)

    def _disconnect_interface_finish(self, self_object, result: Gio.AsyncResult):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        try:
            del self.database
            self.logger.debug("Deleting Database")
        except:
            self.logger.debug("database gone")

        try:
            del self.interface
            self.logger.debug("Deleting Interface")
        except:
            self.logger.debug("Interface already gone")

        """
        For some reason if this is all synchronous, GTK does a lot of weird segfaults
        """
        GLib.timeout_add(10, self.disconnected_cb)

        if win.quit_request:
            app.logger.debug("Disconnection with quit request, quitting")
            app.quit()
            return

        if not Gio.Task.is_valid(result, self_object):
            self_object.set_connected_button_sensitivity(True)
            self.logger.warning("Not valid GTask")
            app.logger.debug("Disconnection Failed")
            return

        success = result.propagate_boolean()
        if not success:
            self.logger.warning("Disconnection failed!")
            self_object.set_connected_button_sensitivity(True)
            app.logger.debug("Disconnection Failed")
            return
        else:
            self.logger.debug("Disconnection succeeded!")

    def _disconnect_interface_async(self, callback) -> None:
        def _disconnect_interface(task, _source_object, _task_data, _cancellable):
            app = Gtk.Application.get_default()
            win = Gtk.Application.get_active_window(app)

            if not hasattr(win.connection_page_bin, 'interface'):
                self.logger.debug("Don't have interface anymore, did you already disconnect?")
                task.return_boolean(False)
                return

            interface = win.connection_page_bin.interface
            try:
                interface.close()
            except:
                task.return_boolean(False)
                return

            task.return_boolean(True)

        task = Gio.Task.new(self, None, callback)
        task.run_in_thread(_disconnect_interface)

    def disconnection_event_cb(self) -> bool:
        self.logger.debug("Disconnection event no longer happening")
        self.disconnection_event = False

    """
    If you disconnect normally, the python library sends out a lost signal
    and we try to reconnect. Disallow this for some time to prevent that.
    """
    @Gtk.Template.Callback()
    def _disconnect_button_clicked_cb(self, button):
        self.logger.debug("Disconnecting interface")
        if not self.disconnect_button.get_sensitive():
            self.logger.debug("Disconnect event already happening or nothing to disconnect")
            return

        self.set_connect_button_sensitivity(False)
        self._disconnect_interface_async(callback=self._disconnect_interface_finish)
        self.logger.debug("Disconnection event happening")
        self.disconnection_event = True
        GLib.timeout_add(10000, self.disconnection_event_cb)

    def connect_cb(self) -> bool:
        self.logger.debug("Awknowledged Connection")
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if not hasattr(self, 'interface'):
            self.logger.warning("no interface")
            return GLib.SOURCE_REMOVE

        self.message_parser.start_connection_timeout()

        self.set_connected_button_sensitivity(True)
        notification._withdraw_lost_connection_notification()

        self.database = message_storage.MessageStorage(self.interface)
        pub.subscribe(self.onDisconnection, 'meshtastic.connection.lost')
        win.window_toast_overlay.add_toast(Adw.Toast(title="Connected"))
        win.nearby_nodes_page_bin.populate_all_nodes(self.interface)
        win.channels_page_bin.populate_all_channels_and_dms(self.interface)
        node = self.interface.getNode('^local')
        # check if node is UNSET, which is 0

        self.region_selection.set_selected(node.localConfig.lora.region)

        if node.localConfig.lora.region == 0:
            self.logger.warning("Region is UNSET!")
            dialog = Adw.AlertDialog.new(_("Alert"),
                                         _("Region is UNSET! Please set the region "
                                           "before doing anything else."))
            dialog.add_response ("close",  _("_Close"))

            dialog.present (win)

        win.maps_page_bin.goto_map_last_seen_first_load()

        return GLib.SOURCE_REMOVE

    def onConnection(self, interface):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if hasattr(win.connection_page_bin, 'interface'):
            self.logger.warning("Trying to connect with already a valid connection!")
            return

        self.interface = interface

        """
        For some reason if this is all synchronous, GTK does a lot of weird segfaults
        """
        GLib.timeout_add(10, self.connect_cb)

    def _tcp_connect_finish(self, self_object, result: Gio.AsyncResult):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if not Gio.Task.is_valid(result, self_object):
            self_object.set_connected_button_sensitivity(False)
            self.logger.warning("Not valid GTask")
            win.window_toast_overlay.add_toast(Adw.Toast(title="TCP Connection Failed"))
            return

        success = result.propagate_boolean()
        if not success:
            self.logger.warning("Connection failed!")
            self_object.set_connected_button_sensitivity(False)
            win.window_toast_overlay.add_toast(Adw.Toast(title="TCP Connection Failed"))
            return

    def _tcp_connect_async(self, callback) -> None:
        def _tcp_connect(task, _source_object, _task_data, _cancellable):
            self.logger.debug("Start TCP Connect")
            url_to_connect = self.connect_tcp_entry.get_text()
            self.logger.debug("Connecting to tcp interface: " + url_to_connect)
            try:
                meshtastic.tcp_interface.TCPInterface(url_to_connect)
            except:
                task.return_boolean(False)
                return

            task.return_boolean(True)

        task = Gio.Task.new(self, None, callback)
        task.run_in_thread(_tcp_connect)

    @Gtk.Template.Callback()
    def _connect_tcp_button_clicked_cb(self, button):
        if not self.connect_tcp_entry_button.get_sensitive():
            self.logger.debug("Application not ready to connect")
            return

        pub.subscribe(self.onConnection, 'meshtastic.connection.established')
        self._tcp_connect_async(callback=self._tcp_connect_finish)
        self.set_disconnect_button_sensitivity(False)

    @Gtk.Template.Callback()
    def _refresh_serial_ports_clicked_cb(self, button):
        ports = meshtastic.util.findPorts(True)
        """
        GtkStringList doesn't look to keep track of how many items it
        has, so I have to keep track of the number myself.
        """
        while self.num_serial_ports >= 1:
             self.serial_port_options.remove(0)
             self.num_serial_ports -= 1

        self.num_serial_ports = len(ports)
        if len(ports) >= 1:
            self.serial_port_selection.set_visible(True)
            for port in ports:
                self.serial_port_options.append(port)
        else:
            self.serial_port_selection.set_visible(False)

    def _serial_connect_finish(self, self_object, result: Gio.AsyncResult):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if not Gio.Task.is_valid(result, self_object):
            self.logger.warning("Not valid GTask")
            self_object.set_connected_button_sensitivity(False)
            win.window_toast_overlay.add_toast(Adw.Toast(title="Serial Connection Failed"))
            return

        success = result.propagate_boolean()
        if not success:
            self.logger.warning("Connection failed!")
            self_object.set_connected_button_sensitivity(False)
            win.window_toast_overlay.add_toast(Adw.Toast(title="Serial Connection Failed"))
            return

    def _serial_connect_async(self, callback) -> None:
        def _serial_connect(task, _source_object, _task_data, _cancellable):
            ports = meshtastic.util.findPorts(True)
            if len(ports) == 0:
                self.logger.warning("No serial ports to connect to")
                task.return_boolean(False)
                return
            elif len(ports) == 1:
                self.logger.debug("Connecting to Serial interface")

                try:
                    meshtastic.serial_interface.SerialInterface()
                except:
                    task.return_boolean(False)
                    return

            else:
                position = self.serial_port_selection.get_selected()
                serial_port = self.serial_port_options.get_string(position)

                self.logger.debug("Connecting to Serial interface port: " + str(serial_port))

                try:
                    meshtastic.serial_interface.SerialInterface(serial_port)
                except:
                    task.return_boolean(False)
                    return

            task.return_boolean(True)

        task = Gio.Task.new(self, None, callback)
        task.run_in_thread(_serial_connect)
        self.set_disconnect_button_sensitivity(False)

    @Gtk.Template.Callback()
    def _connect_serial_button_clicked_cb(self, button):
        ports = meshtastic.util.findPorts(True)
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if not self.connect_serial_button.get_sensitive():
            self.logger.debug("Application not ready to connect")
            return

        if len(ports) == 0 or len(ports) == 1:
            self._refresh_serial_ports_clicked_cb(self.refresh_serial_ports_button)

        if len(ports) == 0:
            dialog = Adw.AlertDialog.new(_("Alert"),
                                         _("No serial Meshtastic devices detected"))
            dialog.add_response ("close",  _("_Close"))
            dialog.present (win)
            return

        pub.subscribe(self.onConnection, 'meshtastic.connection.established')
        self._serial_connect_async(callback=self._serial_connect_finish)

    def _ble_sort_func(self, node1, node2, data=None):
        if node1.favorited and not node2.favorited:
            return -1

        if not node1.favorited and node2.favorited:
            return 1

        if node2.device_address_int > node1.device_address_int:
            return -1
        elif node2.device_address_int < node1.device_address_int:
            return 1
        else:
            return 0

    def populate_bluetooth_devices_list_box(self):
        self.bluetooth_devices_list_box_children = 0
        self.bluetooth_devices_list_box.remove_all()
        self.ble_database.retrieve_ble_devices(self)

        if not os.path.exists(bluetooth_scanned_device_path):
            self.logger.debug("No Scanned Bluetooth file")
            return

        with open(bluetooth_scanned_device_path, 'r+') as json_file:
            content = json_file.read().strip()
            json_file.close()

        if not content:
            self.logger.warning("Scanned Bluetooth file Empty")
            self.bluetooth_devices_group.set_visible(False)
            return

        if content:
            try:
                data = json.loads(content)
            except:
                os.remove(bluetooth_scanned_device_path)
                self.logger.warning("malformed Bluetooth file")
                self.bluetooth_devices_group.set_visible(False)
                return
            else:
                self.logger.debug("Saved Bluetooth Devices:")
                for i in data:
                    self.logger.debug("    Name: " + str(i["name"]) + " Address: " + str(i["address"]))
                    row_to_find = self.find_ble_row_by_address(i["address"])
                    if not row_to_find is None:
                        continue

                    new_device = bluetooth_device_row.BluetoothDeviceRow()

                    new_device.set_device_name(i["name"])
                    new_device.set_device_address(i["address"])
                    new_device.set_connection_page(self)
                    new_device.set_ble_database(self.ble_database)

                    self.bluetooth_devices_list_box.append(new_device)
                    self.bluetooth_devices_list_box_children += 1

                if self.bluetooth_devices_list_box_children == 0:
                    self.bluetooth_devices_group.set_visible(False)
                else:
                    self.bluetooth_devices_group.set_visible(True)

    def _ble_scan_finish(self, self_object, result: Gio.AsyncResult):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        self.populate_bluetooth_devices_list_box()
        self.set_connected_button_sensitivity(False)

        if not Gio.Task.is_valid(result, self_object):
            self.logger.warning("Not valid GTask")
            win.window_toast_overlay.add_toast(Adw.Toast(title="Bluetooth Scan Failed"))
            return

        success = result.propagate_boolean()
        if not success:
            self.logger.warning("Scanning failed!")
            win.window_toast_overlay.add_toast(Adw.Toast(title="Bluetooth Scan Failed"))
            return

        win.window_toast_overlay.add_toast(Adw.Toast(title="Bluetooth Scan Finished"))

    def _ble_scan_async(self, callback) -> None:
        def scan_ble(task, _source_object, _task_data, _cancellable):
            self.logger.debug("Start scanning (takes 10 seconds)")
            try:
                device_list =  meshtastic.ble_interface.BLEInterface.scan()
            except:
                task.return_boolean(False)
                return

            self.logger.debug("Finished Scanning")
            device_json_string = '[\n'
            for x in device_list:
                self.logger.debug(f"    Found: name='{x.name}' address='{x.address}'")
                device_json_string = (device_json_string + " {\"name\": \""+ str(x.name) + "\", \"address\": \"" + str(x.address) + "\"},")

            device_json_string = device_json_string[:-1]
            device_json_string = (device_json_string + "\n]")

            with open(bluetooth_scanned_device_path, 'w') as json_file:
                json_file.write(device_json_string)

            json_file.close()

            task.return_boolean(True)

        task = Gio.Task.new(self, None, callback)
        task.run_in_thread(scan_ble)

    def _start_scan_callback(self, dialog, response, user):
        if "continue" != response:
            return

        self._ble_scan_async(callback=self._ble_scan_finish)
        self.bluetooth_scan_button.set_sensitive(False)

        self.set_disconnect_button_sensitivity(False)
        return

    @Gtk.Template.Callback()
    def _device_preferences_button_clicked_cb(self, button):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)

        if not hasattr(self, 'interface'):
            self.logger.warning("No Interface!")
            return

        self.device_configuration_page.populate_entries(self.interface, True)
        self.connection_page_nav_view.push_by_tag("device-preferences")

    @Gtk.Template.Callback()
    def _bluetooth_scan_clicked_cb(self, button):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)
        dialog = Adw.AlertDialog.new(_("Alert"),
                                     _("Bluetooth scanning will take 10 seconds.\n\n"
                                       "You must also connect the device in the "
                                       "OS before it will connect in this application"))
        dialog.add_response ("close",  _("_Close"))
        dialog.add_response ("continue",  _("_Continue"))

        dialog.set_response_appearance ("continue",
                                        Adw.ResponseAppearance.DESTRUCTIVE)

        dialog.connect("response", self._start_scan_callback, self)

        dialog.present (win)

    @Gtk.Template.Callback()
    def _set_device_region_button_clicked_cb(self, button):
        app = Gtk.Application.get_default()
        win = Gtk.Application.get_active_window(app)
        if not hasattr(self, 'interface'):
            self.logger.warning("No Connections to set")
            return

        node = self.interface.getNode('^local')
        node.localConfig.lora.region = self.region_selection.get_selected()
        node.writeConfig("lora")
        win.window_toast_overlay.add_toast(Adw.Toast(title="Region Successfully Set"))

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        app = Gtk.Application.get_default()

        self.logger = app.logger
        self.ble_database = ble_storage.BLEStorage()
        self.bluetooth_devices_list_box.set_sort_func(sort_func=self._ble_sort_func)
        self.populate_bluetooth_devices_list_box()
        #Do this first
        self.message_parser = message_parser.MessageParser()
        self._refresh_serial_ports_clicked_cb(self.refresh_serial_ports_button)
