# 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 gettext
import logging
import time

from gi.repository import Adw, Gio, GLib, Gtk
from meshy.ble import BleDevice
from meshy.models import DeviceInfo
from meshy.protocol import (
    build_app_start,
    build_device_query,
    build_get_auto_add_config,
    build_get_batt_and_storage,
    build_get_channel,
    build_get_custom_vars,
    build_get_device_time,
    build_sync_next_message,
)
from meshy.storage import Storage
from meshy.transport_base import ConnectionState
from meshy.utils import TimeoutManager

log = logging.getLogger(__name__)

_HANDSHAKE_TIMEOUT_MS = 5000
_MAX_HANDSHAKE_RETRIES = 2
_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 = 15000
_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: set | None = 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_retries: int = 0

        self.channel_sync_index: int = -1
        self.channel_sync_retries: int = 0

        self.msg_sync_retries: int = 0

        self.handshake_received: bool = False
        self.handshake_retries: int = 0

        self.reconnect_attempts: int = 0
        self._timeouts = TimeoutManager()
        self.last_connected_address: str | None = None
        self.manual_disconnect: bool = False

        self.batt_timer_id: int | None = None
        self.watchdog_timer_id: int | None = None
        self.last_device_rx: float = 0.0

        self.pending_device_address: str | None = None
        self.pending_transport_type: str | None = 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
        transport.on_error = self._on_transport_error

    def _on_transport_error(self, message: str):
        GLib.idle_add(self._win.show_toast, message)

    def auto_connect(self):
        transport = self._win._transport
        if transport is not None:
            self._win._debug_log(f"auto_connect: state={transport.state}")
            if 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

            # Store address for auto-reconnect if this fails
            self.last_connected_address = last_addr
            self._win._show_connecting_screen(last_addr)
            self._win._transport.connect_by_address(last_addr)

            # Add fallback timeout in case connection doesn't start (e.g., BLE not ready)
            def _check_startup_connection():
                if self._win._transport.state == ConnectionState.DISCONNECTED:
                    # Connection didn't start, begin auto-reconnect sequence
                    log.warning("Startup connection failed to start, beginning auto-reconnect")
                    self._schedule_reconnect()
                return False

            GLib.timeout_add(1000, _check_startup_connection)
        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.tcp import TcpDevice
        from meshy.usb_serial import UsbDevice

        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)

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/scan-dialog-content.ui"
        )
        dialog.set_extra_child(builder.get_object("content"))
        self._scan_listbox = builder.get_object("scan_listbox")

        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._timeouts.cancel_all()
                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
                self._win._auto_backup.cancel()
                # Best-effort logout from rooms/repeaters before cleanup
                if self._win._logged_in_rooms and self._win.is_connected:
                    from meshy.protocol import build_logout

                    for pkh in self._win._logged_in_rooms:
                        c = self._win._contacts_by_key.get(pkh)
                        if c:
                            self._win.send_frame(build_logout(c.public_key))
                if self._win._storage:
                    try:
                        self._win._storage.fail_orphaned_messages()
                    except Exception:
                        pass
                    self._win._storage.close()
                    self._win._storage = None
                self._win._contacts.clear()
                self._win._contacts_by_key.clear()
                self._win._discovered.clear()
                self._win._discovered_by_key.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()
                self._win._frame_handler._timeouts.cancel_all()
                self._win._frame_handler.cleanup_on_disconnect()
                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:
                # If we're in auto-reconnect mode, show cancel button and shorter timeout
                if self._timeouts.is_active("reconnect") or self.reconnect_attempts > 0:
                    attempt = self.reconnect_attempts
                    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._start_connect_timeout(10000)
                else:
                    self._win._connection_banner.set_title(_("Connecting..."))
                    self._win._connection_banner.set_button_label("")
                    self._start_connect_timeout()
            elif state == ConnectionState.CONNECTED:
                self._timeouts.cancel("connect")
                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, timeout_ms=25000):
        self._timeouts.start("connect", timeout_ms, self._on_connect_timeout, timeout_ms)

    def _on_connect_timeout(self, timeout_ms):
        if self._win._transport and self._win._transport.state == ConnectionState.CONNECTING:
            log.info(f"Connection timed out after {timeout_ms/1000:.0f} seconds")
            self._win._transport.disconnect()

    def _start_handshake_timeout(self):
        self._timeouts.start("handshake", _HANDSHAKE_TIMEOUT_MS, self._on_handshake_timeout)

    def _on_handshake_timeout(self):
        if self.handshake_received:
            return False
        if self.handshake_retries < _MAX_HANDSHAKE_RETRIES:
            self.handshake_retries += 1
            log.info(f"Handshake retry {self.handshake_retries}/{_MAX_HANDSHAKE_RETRIES}")
            self._win.send_frame(build_device_query())
            self._start_handshake_timeout()
            return False
        log.warning("Device did not respond to handshake after retries, disconnecting")
        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._timeouts.cancel("handshake")
        self.handshake_retries = 0
        self.reconnect_attempts = 0

    def on_connected(self):
        self.syncing = True
        self.handshake_received = False
        self.handshake_retries = 0
        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._discovered_by_key = {c.public_key_hex: c for c in self._win._discovered}
        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._frame_handler.load_blocked_senders()
        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._timeouts.start("sync", _SYNC_TIMEOUT_MS, self._on_sync_timeout)

    def _on_sync_timeout(self):
        if not self.syncing:
            return False
        log.warning("Sync timed out, disconnecting")
        if not self.handshake_received:
            self.manual_disconnect = True
        self._timeouts.cancel("contacts_sync")
        self._timeouts.cancel("channel_sync")
        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._timeouts.start(
            "contacts_sync", _CONTACTS_SYNC_TIMEOUT_MS, self._on_contacts_sync_timeout
        )

    def _cancel_contacts_sync_timeout(self):
        self._timeouts.cancel("contacts_sync")

    def _on_contacts_sync_timeout(self):
        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
        return self.last_connected_address

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

        # Hide connecting screen if it's showing (e.g., from startup auto-connect)
        if self._win._main_stack.get_visible_child_name() == "connecting":
            self._win._show_connection_screen()

        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._timeouts.start("reconnect", delay, self._do_reconnect)

    def _do_reconnect(self):
        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)

            # If connection didn't start (e.g., BLE not ready), schedule timeout to retry or give up
            def _check_connection_started():
                if self._win._transport.state == ConnectionState.DISCONNECTED:
                    # Connection didn't start, treat as timeout
                    log.warning(
                        f"Reconnect attempt {self.reconnect_attempts}/{_MAX_RECONNECT_ATTEMPTS} failed to start connection (state still DISCONNECTED)"
                    )
                    if self.reconnect_attempts >= _MAX_RECONNECT_ATTEMPTS:
                        log.info(
                            f"Max reconnect attempts ({_MAX_RECONNECT_ATTEMPTS}) reached, giving up"
                        )
                        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()
                    else:
                        # Try again
                        log.info(
                            f"Scheduling reconnect attempt {self.reconnect_attempts + 1}/{_MAX_RECONNECT_ATTEMPTS}"
                        )
                        self._schedule_reconnect()
                else:
                    log.info(
                        f"Reconnect attempt {self.reconnect_attempts} started successfully (state: {self._win._transport.state})"
                    )
                return False

            GLib.timeout_add(1000, _check_connection_started)
        return False

    def cancel_reconnect(self):
        self._timeouts.cancel("reconnect")

    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._timeouts.cancel("channel_sync")
        max_ch = self._win._device_info.max_channels or 8
        if self.syncing:
            self._win._connection_banner.set_title(_("Syncing channels") + f"\n{index}/{max_ch}")
        if index >= max_ch:
            self.channel_sync_index = -1
            if self.syncing:
                self.syncing = False
                self._timeouts.cancel("sync")
                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._timeouts.start(
            "channel_sync", _CHANNEL_SYNC_TIMEOUT_MS, self._on_channel_sync_timeout, index
        )

    def _on_channel_sync_timeout(self, index):
        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._timeouts.cancel("msg_sync")
        self._win.send_frame(build_sync_next_message())
        self._timeouts.start("msg_sync", _MSG_SYNC_TIMEOUT_MS, self._on_msg_sync_timeout)

    def _on_msg_sync_timeout(self):
        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._timeouts.cancel("msg_sync")
        if not self.msg_syncing:
            return
        self.msg_syncing = False
        if self.sync_msg_count > 0:
            self._win._send_notification(
                "Meshy",
                gettext.dngettext(
                    "meshy",
                    "You received {} message while disconnected.",
                    "You received {} messages while disconnected.",
                    self.sync_msg_count,
                ).format(self.sync_msg_count),
                "sync-summary",
            )
            self.sync_msg_count = 0

        settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")
        if settings.get_boolean("auto-backup"):
            self._win._auto_backup.schedule(self._win)

        self._win.refresh_stats()

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