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

"""Connection, sync, and reconnect logic extracted from MeshyWindow."""

import logging
import time
from typing import Optional

from gi.repository import Adw, Gtk, GLib, Gio

from meshy.ble import BleTransport, BleDevice
from meshy.transport_base import ConnectionState
from meshy.models import DeviceInfo
from meshy.storage import Storage
from meshy.protocol import (
    build_app_start, build_device_query, build_get_contacts,
    build_get_batt_and_storage, build_sync_next_message,
    build_get_device_time, build_get_channel,
    build_get_auto_add_config, build_get_custom_vars,
    build_set_flood_scope,
)

log = logging.getLogger(__name__)

_HANDSHAKE_TIMEOUT_MS = 5000
_SYNC_TIMEOUT_MS = 90000
_MAX_RECONNECT_ATTEMPTS = 3
_RECONNECT_DELAYS = [2000, 4000, 8000]
_CONTACTS_SYNC_TIMEOUT_MS = 10000
_CONTACTS_SYNC_MAX_RETRIES = 3
_CHANNEL_SYNC_TIMEOUT_MS = 5000
_CHANNEL_SYNC_MAX_RETRIES = 3
_MSG_SYNC_TIMEOUT_MS = 5000
_MSG_SYNC_MAX_RETRIES = 3


class ConnectionController:
    """Owns connection lifecycle, sync state, and reconnect logic."""

    def __init__(self, win):
        self._win = win

        self.syncing: bool = False
        self.msg_syncing: bool = False
        self.sync_msg_count: int = 0
        self.sync_contact_total: int = 0
        self.sync_contact_current: int = 0
        self.synced_contact_keys: Optional[set] = None
        self.contacts_iter_busy: bool = False
        self.contacts_refetch_pending: bool = False
        self.incremental_sync: bool = False
        self.gps_poll_pending: bool = False
        self.contacts_sync_timeout_id: Optional[int] = None
        self.contacts_sync_retries: int = 0

        self.channel_sync_index: int = -1
        self.channel_sync_retries: int = 0
        self.channel_sync_timeout_id: Optional[int] = None

        self.msg_sync_timeout_id: Optional[int] = None
        self.msg_sync_retries: int = 0

        self.handshake_timeout_id: Optional[int] = None
        self.handshake_received: bool = False

        self.connect_timeout_id: Optional[int] = None
        self.sync_timeout_id: Optional[int] = None

        self.reconnect_attempts: int = 0
        self.reconnect_timer_id: Optional[int] = None
        self.last_connected_address: Optional[str] = None
        self.manual_disconnect: bool = False

        self.batt_timer_id: Optional[int] = None
        self.watchdog_timer_id: Optional[int] = None
        self.last_device_rx: float = 0.0

        self.pending_device_address: Optional[str] = None
        self.pending_transport_type: Optional[str] = None

    def setup_transport_callbacks(self, transport):
        transport.on_state_changed = self.on_ble_state_changed
        transport.on_data_received = self._win._on_ble_data_received
        transport.on_device_discovered = self.on_ble_device_discovered

    def auto_connect(self):
        self._win._debug_log(f'auto_connect: state={self._win._transport.state}')
        if self._win._transport.state != ConnectionState.DISCONNECTED:
            return False
        settings = Gio.Settings.new('page.codeberg.sesivany.Meshy')
        last_addr = settings.get_string('last-device-address')
        last_transport = settings.get_string('last-transport')
        self._win._debug_log(f'auto_connect: last_addr={last_addr}, transport={last_transport}')
        if last_addr:
            if last_transport == 'tcp':
                from meshy.tcp import TcpTransport
                self._win._tcp = TcpTransport()
                self._win._transport = self._win._tcp
                self.setup_transport_callbacks(self._win._tcp)
            elif last_transport == 'usb':
                from meshy.usb_serial import UsbSerialTransport
                self._win._usb = UsbSerialTransport()
                self._win._transport = self._win._usb
                self.setup_transport_callbacks(self._win._usb)
            else:
                self._win._transport = self._win._ble
            self._win._show_connecting_screen(last_addr)
            self._win._transport.connect_by_address(last_addr)
        else:
            self._win._show_connection_screen()
        return False

    def connect_to_device(self, device, transport=None):
        if transport:
            self._win._transport = transport
            self.setup_transport_callbacks(transport)
        else:
            self._win._transport = self._win._ble
            self.setup_transport_callbacks(self._win._ble)
        from meshy.usb_serial import UsbDevice
        from meshy.tcp import TcpDevice
        self.pending_device_address = device.address
        if isinstance(device, TcpDevice):
            self.pending_transport_type = 'tcp'
        elif isinstance(device, UsbDevice):
            self.pending_transport_type = 'usb'
        else:
            self.pending_transport_type = 'ble'
        self._win._connection_banner.set_title(_('Connecting...'))
        self._win._connection_banner.set_revealed(True)
        self._win._transport.connect_device(device.path)

    def show_scan_dialog(self):
        dialog = Adw.AlertDialog(
            heading=_('Scan for Devices'),
            body=_('Scanning for MeshCore devices...'),
        )
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('scan', _('Scan'))
        dialog.set_response_appearance('scan', Adw.ResponseAppearance.SUGGESTED)

        self._scan_listbox = Gtk.ListBox()
        self._scan_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
        self._scan_listbox.add_css_class('boxed-list')

        scroll = Gtk.ScrolledWindow(min_content_height=200)
        list_wrapper = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            margin_start=8, margin_end=8, margin_top=8, margin_bottom=8,
        )
        list_wrapper.append(self._scan_listbox)
        scroll.set_child(list_wrapper)

        clamp = Adw.Clamp(maximum_size=400, child=scroll)
        dialog.set_extra_child(clamp)

        self._scan_dialog = dialog
        self._win._ble.start_scan()

        def _on_response(d, response):
            if response == 'cancel':
                self._win._ble.stop_scan()
            self._scan_dialog = None

        dialog.connect('response', _on_response)
        self._scan_listbox.connect('row-activated', self._on_scan_device_selected)
        dialog.present(self._win)

    def _on_scan_device_selected(self, listbox, row):
        device = row._ble_device
        if self._scan_dialog:
            self._scan_dialog.close()
            self._scan_dialog = None
        self._win._ble.stop_scan()
        self.pending_device_address = device.address
        self.pending_transport_type = 'ble'
        self._win._ble.connect_device(device.path)

    def on_ble_device_discovered(self, device: BleDevice):
        def _add_to_list():
            if not hasattr(self, '_scan_dialog') or not self._scan_dialog:
                return False
            row = Adw.ActionRow(
                title=device.name,
                subtitle=device.address,
            )
            row.add_prefix(
                Gtk.Image.new_from_icon_name('bluetooth-symbolic')
            )
            row.set_activatable(True)
            row._ble_device = device
            self._scan_listbox.append(row)
            return False
        GLib.idle_add(_add_to_list)

    def on_ble_state_changed(self, state: ConnectionState):
        def _update_ui():
            if state == ConnectionState.DISCONNECTED:
                self._cancel_connect_timeout()
                self._cancel_handshake_timeout()
                self._cancel_sync_timeout()
                self._cancel_contacts_sync_timeout()
                self._cancel_channel_sync_timeout()
                self._cancel_msg_sync_timeout()
                self._win.stop_login()
                self.channel_sync_index = -1
                if self._win._device_view_inst is not None:
                    self._win._device_view_inst.update_connection_status(False)
                if self.batt_timer_id:
                    GLib.source_remove(self.batt_timer_id)
                    self.batt_timer_id = None
                if self.watchdog_timer_id:
                    GLib.source_remove(self.watchdog_timer_id)
                    self.watchdog_timer_id = None
                if self._win._storage:
                    self._win._storage.fail_orphaned_messages()
                    self._win._storage.close()
                    self._win._storage = None
                self._win._contacts.clear()
                self._win._contacts_by_key.clear()
                self._win._discovered.clear()
                self._win._channels.clear()
                self._win._device_info = DeviceInfo()
                self._win._direct_repeaters.clear()
                if self._win._signal_icon_timer_id:
                    GLib.source_remove(self._win._signal_icon_timer_id)
                    self._win._signal_icon_timer_id = None
                self._win._self_info.clear()
                self._win._msg_ctrl.cleanup_on_disconnect()
                self._win._logged_in_rooms.clear()
                if self._win._frame_handler._contacts_refresh_id:
                    GLib.source_remove(self._win._frame_handler._contacts_refresh_id)
                    self._win._frame_handler._contacts_refresh_id = None
                if self._win._frame_handler._contacts_refetch_id:
                    GLib.source_remove(self._win._frame_handler._contacts_refetch_id)
                    self._win._frame_handler._contacts_refetch_id = None
                self.contacts_iter_busy = False
                self.contacts_refetch_pending = False
                if self._win._contacts_view_inst is not None:
                    self._win._contacts_view_inst.update_contacts([])
                if self._win._channels_view_inst is not None:
                    self._win._channels_view_inst.update_channels([])
                self.syncing = False
                self.msg_syncing = False
                self.sync_msg_count = 0
                self._win._companion_box.set_visible(False)
                if self._should_auto_reconnect():
                    self._schedule_reconnect()
                else:
                    self.reconnect_attempts = 0
                    settings = Gio.Settings.new('page.codeberg.sesivany.Meshy')
                    settings.set_string('last-device-address', '')
                    settings.set_string('last-device-name', '')
                    self._win._connection_banner.set_title(_('Not connected'))
                    self._win._connection_banner.set_button_label(_('Connect'))
                    self._win._connection_banner.set_revealed(True)
                    self._win._show_connection_screen()
                    if not self._win.get_visible():
                        self._win.present()
            elif state == ConnectionState.SCANNING:
                self._win._connection_banner.set_title(_('Scanning...'))
                self._win._connection_banner.set_button_label(_('Stop'))
            elif state == ConnectionState.CONNECTING:
                self._win._connection_banner.set_title(_('Connecting...'))
                self._win._connection_banner.set_button_label('')
                self._start_connect_timeout()
            elif state == ConnectionState.CONNECTED:
                self._cancel_connect_timeout()
                addr = self.pending_device_address
                if addr:
                    settings = Gio.Settings.new('page.codeberg.sesivany.Meshy')
                    settings.set_string('last-device-address', addr)
                    settings.set_string('last-transport',
                                        self.pending_transport_type or 'ble')
                    self.pending_device_address = None
                self._win._connection_banner.set_title(_('Syncing...'))
                self._win._connection_banner.set_button_label('')
                self._win._connection_banner.set_revealed(True)
                transport_type = self.pending_transport_type or \
                    Gio.Settings.new('page.codeberg.sesivany.Meshy').get_string('last-transport')
                self._win._device_view.update_connection_status(True, transport_type)
                self._win._settings_view.update_transport_type(transport_type)
                self._win._hide_connection_screen()
                self.on_connected()
            return False
        GLib.idle_add(_update_ui)

    def _start_connect_timeout(self):
        self._cancel_connect_timeout()

        def _on_timeout():
            self.connect_timeout_id = None
            if self._win._transport and self._win._transport.state == ConnectionState.CONNECTING:
                log.info('Connection timed out after 15 seconds')
                self._win._transport.disconnect()
            return False

        self.connect_timeout_id = GLib.timeout_add(15000, _on_timeout)

    def _cancel_connect_timeout(self):
        if self.connect_timeout_id is not None:
            GLib.source_remove(self.connect_timeout_id)
            self.connect_timeout_id = None

    def _start_handshake_timeout(self):
        self._cancel_handshake_timeout()
        self.handshake_timeout_id = GLib.timeout_add(
            _HANDSHAKE_TIMEOUT_MS, self._on_handshake_timeout
        )

    def _cancel_handshake_timeout(self):
        if self.handshake_timeout_id is not None:
            GLib.source_remove(self.handshake_timeout_id)
            self.handshake_timeout_id = None

    def _on_handshake_timeout(self):
        self.handshake_timeout_id = None
        if self.handshake_received:
            return False
        log.warning('Device did not respond to handshake, disconnecting')
        self.manual_disconnect = True
        self._win.show_toast(
            _('Device did not respond. It may not support this connection type.'))
        self._win._transport.disconnect()
        return False

    def on_handshake_received(self):
        if self.handshake_received:
            return
        self.handshake_received = True
        self._cancel_handshake_timeout()
        self.reconnect_attempts = 0

    def on_connected(self):
        self.syncing = True
        self.handshake_received = False
        self.last_connected_address = self._win._transport.connected_address
        self._start_sync_timeout()
        self._start_handshake_timeout()

        device_addr = self._win._transport.connected_address
        if self._win._storage:
            self._win._storage.close()
        self._win._storage = Storage(device_address=device_addr)
        self._win._contacts = self._win._storage.get_contacts()
        self._win._contacts_by_key = {c.public_key_hex: c for c in self._win._contacts}
        self._win._discovered = self._win._storage.get_discovered_contacts()
        self._win._channels = self._win._storage.get_channels()
        self._win._contacts_view.update_contacts(self._win._contacts)
        self._win._channels_view.update_channels(self._win._channels)
        self._win._frame_handler.rebuild_key_store()
        self._win._frame_handler.restore_unread_counts()
        self._win._settings_view.load_regions()
        self._win._settings_view.restore_default_scope()

        self._win.send_frame(build_device_query())
        GLib.timeout_add(50, lambda: self._win.send_frame(build_app_start()) or False)
        GLib.timeout_add(100, lambda: self._win.send_frame(build_get_device_time()) or False)
        GLib.timeout_add(150, lambda: self._win.send_frame(build_get_batt_and_storage()) or False)
        self.contacts_sync_retries = 0
        GLib.timeout_add(200, self._send_contacts_with_timeout)
        GLib.timeout_add(250, self._win._apply_default_scope)
        self.batt_timer_id = GLib.timeout_add(120000, self.poll_battery)
        self.last_device_rx = time.monotonic()
        self.watchdog_timer_id = GLib.timeout_add(15000, self.connection_watchdog)

    def _start_sync_timeout(self):
        self._cancel_sync_timeout()
        self.sync_timeout_id = GLib.timeout_add(
            _SYNC_TIMEOUT_MS, self._on_sync_timeout
        )

    def _cancel_sync_timeout(self):
        if self.sync_timeout_id is not None:
            GLib.source_remove(self.sync_timeout_id)
            self.sync_timeout_id = None

    def _on_sync_timeout(self):
        self.sync_timeout_id = None
        if not self.syncing:
            return False
        log.warning('Sync timed out, disconnecting')
        if not self.handshake_received:
            self.manual_disconnect = True
        self._cancel_contacts_sync_timeout()
        self._cancel_channel_sync_timeout()
        self._win._transport.disconnect()
        return False

    def _send_contacts_with_timeout(self):
        self._win._frame_handler.send_get_contacts()
        self._start_contacts_sync_timeout()
        return False

    def _start_contacts_sync_timeout(self):
        self._cancel_contacts_sync_timeout()
        self.contacts_sync_timeout_id = GLib.timeout_add(
            _CONTACTS_SYNC_TIMEOUT_MS, self._on_contacts_sync_timeout
        )

    def _cancel_contacts_sync_timeout(self):
        if self.contacts_sync_timeout_id is not None:
            GLib.source_remove(self.contacts_sync_timeout_id)
            self.contacts_sync_timeout_id = None

    def _on_contacts_sync_timeout(self):
        self.contacts_sync_timeout_id = None
        if not self.syncing:
            return False
        self.contacts_sync_retries += 1
        if self.contacts_sync_retries < _CONTACTS_SYNC_MAX_RETRIES:
            log.warning(f'Contacts sync timed out, retry '
                        f'{self.contacts_sync_retries}/{_CONTACTS_SYNC_MAX_RETRIES}')
            self.contacts_iter_busy = False
            self._send_contacts_with_timeout()
        else:
            log.warning('Contacts sync failed after retries, skipping to channels')
            self.contacts_iter_busy = False
            self.synced_contact_keys = None
            self.fetch_channels_sequential(0)
        return False

    def _should_auto_reconnect(self) -> bool:
        if self.manual_disconnect:
            self.manual_disconnect = False
            return False
        if self.reconnect_attempts >= _MAX_RECONNECT_ATTEMPTS:
            return False
        if not self.last_connected_address:
            return False
        return True

    def _schedule_reconnect(self):
        delay = _RECONNECT_DELAYS[min(self.reconnect_attempts,
                                       len(_RECONNECT_DELAYS) - 1)]
        attempt = self.reconnect_attempts + 1
        log.info(f'Auto-reconnect attempt {attempt}/{_MAX_RECONNECT_ATTEMPTS} '
                 f'in {delay}ms')
        self._win._connection_banner.set_title(
            _('Reconnecting ({current}/{total})...').format(current=attempt, total=_MAX_RECONNECT_ATTEMPTS))
        self._win._connection_banner.set_button_label(_('Cancel'))
        self._win._connection_banner.set_revealed(True)
        self.reconnect_timer_id = GLib.timeout_add(delay, self._do_reconnect)

    def _do_reconnect(self):
        self.reconnect_timer_id = None
        self.reconnect_attempts += 1
        addr = self.last_connected_address
        if addr and self._win._transport.state == ConnectionState.DISCONNECTED:
            log.info(f'Reconnecting to {addr}')
            self._win._transport.connect_by_address(addr)
        return False

    def cancel_reconnect(self):
        if self.reconnect_timer_id is not None:
            GLib.source_remove(self.reconnect_timer_id)
            self.reconnect_timer_id = None

    def poll_battery(self) -> bool:
        if self._win._transport.is_connected:
            self._win.send_frame(build_get_batt_and_storage())
            if self._win._custom_vars.get('gps') == '1':
                self.gps_poll_pending = True
                GLib.timeout_add(50, lambda: self._win.send_frame(build_device_query()) or False)
        return self._win._transport.is_connected

    def connection_watchdog(self) -> bool:
        if not self._win._transport.is_connected:
            self.watchdog_timer_id = None
            return False
        silence = time.monotonic() - self.last_device_rx
        if silence > 180:
            log.warning(f'Connection watchdog: no data for {silence:.0f}s, disconnecting')
            self._win._transport.disconnect()
            return False
        return True

    def fetch_channels_sequential(self, index):
        self._cancel_channel_sync_timeout()
        max_ch = self._win._device_info.max_channels or 8
        if self.syncing:
            self._win._connection_banner.set_title(_('Syncing channels: {index}/{total}').format(index=index, total=max_ch))
        if index >= max_ch:
            self.channel_sync_index = -1
            if self.syncing:
                self.syncing = False
                self._cancel_sync_timeout()
                self._win._connection_banner.set_revealed(False)
            self.msg_syncing = True
            self.sync_msg_count = 0
            self._win.send_frame(build_get_auto_add_config())
            self._win.send_frame(build_get_custom_vars())
            self.msg_sync_retries = 0
            self.send_sync_next_message()
            return
        self.channel_sync_index = index
        self.channel_sync_retries = 0
        self._win.send_frame(build_get_channel(index))
        self._start_channel_sync_timeout(index)

    def _start_channel_sync_timeout(self, index):
        self._cancel_channel_sync_timeout()
        self.channel_sync_timeout_id = GLib.timeout_add(
            _CHANNEL_SYNC_TIMEOUT_MS, self._on_channel_sync_timeout, index
        )

    def _cancel_channel_sync_timeout(self):
        if self.channel_sync_timeout_id is not None:
            GLib.source_remove(self.channel_sync_timeout_id)
            self.channel_sync_timeout_id = None

    def _on_channel_sync_timeout(self, index):
        self.channel_sync_timeout_id = None
        if self.channel_sync_index != index:
            return False
        self.channel_sync_retries += 1
        if self.channel_sync_retries < _CHANNEL_SYNC_MAX_RETRIES:
            log.warning(f'Channel {index} fetch timed out, retry '
                        f'{self.channel_sync_retries}/{_CHANNEL_SYNC_MAX_RETRIES}')
            self._win.send_frame(build_get_channel(index))
            self._start_channel_sync_timeout(index)
        else:
            log.warning(f'Channel {index} fetch failed after '
                        f'{_CHANNEL_SYNC_MAX_RETRIES} retries, skipping')
            self.fetch_channels_sequential(index + 1)
        return False

    def send_sync_next_message(self):
        self._cancel_msg_sync_timeout()
        self._win.send_frame(build_sync_next_message())
        self.msg_sync_timeout_id = GLib.timeout_add(
            _MSG_SYNC_TIMEOUT_MS, self._on_msg_sync_timeout)

    def _cancel_msg_sync_timeout(self):
        if self.msg_sync_timeout_id is not None:
            GLib.source_remove(self.msg_sync_timeout_id)
            self.msg_sync_timeout_id = None

    def _on_msg_sync_timeout(self):
        self.msg_sync_timeout_id = None
        if not self.msg_syncing:
            return False
        self.msg_sync_retries += 1
        if self.msg_sync_retries < _MSG_SYNC_MAX_RETRIES:
            log.warning(f'Message sync timed out, retry '
                        f'{self.msg_sync_retries}/{_MSG_SYNC_MAX_RETRIES}')
            self.send_sync_next_message()
        else:
            log.warning('Message sync failed after retries, finishing sync')
            self.finish_msg_sync()
        return False

    def finish_msg_sync(self):
        self._cancel_msg_sync_timeout()
        if not self.msg_syncing:
            return
        self.msg_syncing = False
        if self.sync_msg_count > 0:
            self._win._send_notification(
                'Meshy',
                _('You received {} message(s) while disconnected.').format(self.sync_msg_count),
                'sync-summary',
            )
            self.sync_msg_count = 0

    def send_get_contacts(self, incremental: bool = False):
        self._win._frame_handler.send_get_contacts(incremental=incremental)
