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

"""Protocol frame handling extracted from MeshyWindow."""

import logging
import struct
import time
from datetime import datetime

from gi.repository import Gio, GLib
from meshy.models import (
    Channel,
    ChannelMessage,
    Contact,
    ContactType,
    Message,
    MessageStatus,
    NotificationLevel,
)
from meshy.protocol import (
    PUSH_CODE_ADVERT,
    PUSH_CODE_LOG_DATA,
    PUSH_CODE_LOGIN_FAILED,
    PUSH_CODE_LOGIN_SUCCESS,
    PUSH_CODE_MSG_WAITING,
    PUSH_CODE_NEW_ADVERT,
    PUSH_CODE_PATH_DISCOVERY_RESPONSE,
    PUSH_CODE_PATH_UPDATED,
    PUSH_CODE_SEND_CONFIRMED,
    PUSH_CODE_STATUS_RESPONSE,
    PUSH_CODE_TELEMETRY_RESPONSE,
    PUSH_CODE_TRACE_DATA,
    RESP_CODE_ADVERT_PATH,
    RESP_CODE_AUTO_ADD_CONFIG,
    RESP_CODE_BATT_AND_STORAGE,
    RESP_CODE_BINARY_RESPONSE,
    RESP_CODE_CHANNEL_INFO,
    RESP_CODE_CHANNEL_MSG_RECV,
    RESP_CODE_CHANNEL_MSG_RECV_V3,
    RESP_CODE_CONTACT,
    RESP_CODE_CONTACT_MSG_RECV,
    RESP_CODE_CONTACT_MSG_RECV_V3,
    RESP_CODE_CONTACTS_START,
    RESP_CODE_CONTROL_DATA,
    RESP_CODE_CURR_TIME,
    RESP_CODE_CUSTOM_VARS,
    RESP_CODE_DEVICE_INFO,
    RESP_CODE_END_OF_CONTACTS,
    RESP_CODE_ERR,
    RESP_CODE_EXPORT_CONTACT,
    RESP_CODE_NO_MORE_MESSAGES,
    RESP_CODE_OK,
    RESP_CODE_SELF_INFO,
    RESP_CODE_SENT,
    RESP_CODE_STATS,
    STATS_TYPE_RADIO,
    build_get_contacts,
    build_set_device_time,
    identify_response,
    parse_advert_path_response,
    parse_auto_add_config,
    parse_batt_and_storage,
    parse_channel_info,
    parse_channel_message,
    parse_contact_frame,
    parse_contact_message,
    parse_curr_time,
    parse_custom_vars,
    parse_device_info,
    parse_discover_response,
    parse_error_response,
    parse_neighbors_response,
    parse_push_send_confirmed,
    parse_self_info,
    parse_sent_response,
    parse_stats_response,
    parse_status_response,
    parse_trace_response,
)

log = logging.getLogger(__name__)


class FrameHandler:
    """Handles incoming protocol frames from the companion device."""

    def __init__(self, win):
        self._win = win
        self._contacts_refresh_id = None
        self._contacts_refetch_id = None
        self._pending_channel_snr = {}
        self._ok_callbacks = []

    def handle_frame(self, data: bytes):
        """Process a received protocol frame on the main thread."""
        code = identify_response(data)
        if code is None:
            return False

        # Log all responses when login callback is active
        if self._win._login_callback:
            log.info(
                f"RX during login wait: code=0x{code:02x} ({code}) len={len(data)} hex={data.hex()[:60]}..."
            )

        log.debug(f"RX code={code} len={len(data)} hex={data.hex()}")

        if code == RESP_CODE_OK:
            if self._ok_callbacks:
                on_ok, _on_error, timeout_id = self._ok_callbacks.pop(0)
                GLib.source_remove(timeout_id)
                on_ok()
                return False
            self._win._ignore_next_err = False
            return False

        if code == RESP_CODE_ERR:
            err = parse_error_response(data)
            if self._ok_callbacks:
                _on_ok, on_error, timeout_id = self._ok_callbacks.pop(0)
                GLib.source_remove(timeout_id)
                on_error(err)
                return False
            if self._win._ignore_next_err:
                self._win._ignore_next_err = False
                if err:
                    log.debug(
                        f'Suppressed device error: {err["message"]} (code=0x{err["code"]:02x})'
                    )
                return False
            if err:
                log.warning(f'Device error: {err["message"]} (code=0x{err["code"]:02x})')
                if not self._win._region_callbacks:
                    self._win.show_toast(_("Device error: {}").format(err["message"]))
            if self._win._msg_ctrl.sent_msg_queue:
                front = self._win._msg_ctrl.sent_msg_queue[0]
                if front is None or (isinstance(front, dict) and "type" in front):
                    self._win._msg_ctrl.sent_msg_queue.pop(0)
            return False

        if code == RESP_CODE_SELF_INFO:
            info = parse_self_info(data)
            if self._win._conn_ctrl.gps_poll_pending:
                self._win._conn_ctrl.gps_poll_pending = False
                if info:
                    lat = info.get("adv_lat", 0.0)
                    lon = info.get("adv_lon", 0.0)
                    if abs(lat) > 1e-6 or abs(lon) > 1e-6:
                        self._win._device_info.latitude = lat
                        self._win._device_info.longitude = lon
                    self._win._device_view.update_location(lat, lon)
                    if self._win._map_view_inst is not None:
                        self._win._map_view.update_contacts(self._win._contacts)
                return False
            self._win._conn_ctrl.on_handshake_received()
            if info:
                self._win._self_info = info
                self._win._device_info.public_key = info.get("public_key", "")
                self._win._device_info.name = info.get("name", "")
                self._win._device_info.tx_power = info.get("tx_power", 0)
                self._win._device_info.max_tx_power = info.get("max_tx_power", 0)
                self._win._device_info.radio_freq = info.get("radio_freq", 0)
                self._win._device_info.radio_bw = info.get("radio_bw", 0)
                self._win._device_info.radio_sf = info.get("radio_sf", 0)
                raw_cr = info.get("radio_cr", 0)
                if 1 <= raw_cr <= 4:
                    self._win._device_info.cr_old_encoding = True
                    self._win._device_info.radio_cr = raw_cr + 4
                else:
                    self._win._device_info.cr_old_encoding = False
                    self._win._device_info.radio_cr = raw_cr
                lat = info.get("adv_lat", 0.0)
                lon = info.get("adv_lon", 0.0)
                if abs(lat) > 1e-6 or abs(lon) > 1e-6:
                    self._win._device_info.latitude = lat
                    self._win._device_info.longitude = lon
                self._win._device_view.update_device_info(self._win._device_info)
                self._win._device_view.update_location(
                    info.get("adv_lat", 0.0), info.get("adv_lon", 0.0)
                )
                if self._win._map_view_inst is not None:
                    self._win._map_view.update_contacts(self._win._contacts)
                self._win._settings_view.update_device_info(self._win._device_info)
                self._win._settings_view.update_advert_location(
                    info.get("adv_lat", 0.0),
                    info.get("adv_lon", 0.0),
                    info.get("adv_loc_policy", 0),
                )
                self._win._settings_view.update_telemetry_modes(
                    info.get("telemetry_mode_base", 0),
                    info.get("telemetry_mode_loc", 0),
                    info.get("telemetry_mode_env", 0),
                )
                self._win._device_info.multi_acks = info.get("multi_acks", 0)
                self._win._settings_view.update_multi_acks(info.get("multi_acks", 0))
                self._win._update_companion_label()
                self._win._save_tcp_companion_if_needed()

        elif code == RESP_CODE_CUSTOM_VARS:
            self._win._custom_vars = parse_custom_vars(data)
            self._win._settings_view.update_gps_settings(self._win._custom_vars)
            self._win._device_view.update_gps_enabled(self._win._custom_vars.get("gps") == "1")

        elif code == RESP_CODE_DEVICE_INFO:
            info = parse_device_info(data)
            if info:
                for key in (
                    "model",
                    "ver",
                    "fw_build",
                    "fw_ver",
                    "max_contacts",
                    "max_channels",
                    "path_hash_mode",
                    "repeat",
                    "ble_pin",
                ):
                    if key in info:
                        setattr(self._win._device_info, key, info[key])
                self._win._device_view.update_device_info(self._win._device_info)
                self._win._settings_view.update_device_info(self._win._device_info)

        elif code == RESP_CODE_BATT_AND_STORAGE:
            info = parse_batt_and_storage(data)
            if info:
                self._win._device_info.battery_mv = info.get("level", 0)
                self._win._device_info.storage_used_kb = info.get("used_kb", 0)
                self._win._device_info.storage_total_kb = info.get("total_kb", 0)
                self._win._device_view.update_device_info(self._win._device_info)
                self._win._update_companion_label()

        elif code == RESP_CODE_STATS:
            stats = parse_stats_response(data)
            if stats:
                sub = stats["sub_type"]
                self._win._device_stats[sub] = stats
                self._win._device_view.update_stats(self._win._device_stats)
                if sub == STATS_TYPE_RADIO:
                    total = stats.get("tx_air_secs", 0) + stats.get("rx_air_secs", 0)
                    if total > self._win._msg_ctrl.prev_total_air_secs:
                        self._win._msg_ctrl.last_airtime_bump = time.monotonic()
                    self._win._msg_ctrl.prev_total_air_secs = total

        elif code == RESP_CODE_CONTACTS_START:
            self._win._conn_ctrl.contacts_iter_busy = True
            self._win._storage._batch_depth += 1
            self._win._conn_ctrl._start_contacts_sync_timeout()
            self._win._conn_ctrl.sync_contact_total = (
                int.from_bytes(data[1:3], "little") if len(data) >= 3 else 0
            )
            self._win._conn_ctrl.sync_contact_current = 0
            self._win._conn_ctrl.synced_contact_keys = set()
            if self._win._conn_ctrl.syncing:
                self._win._connection_banner.set_title(
                    _("Syncing contacts") + f"\n{0}/{self._win._conn_ctrl.sync_contact_total}"
                )

        elif code == RESP_CODE_CONTACT:
            self._win._conn_ctrl._start_contacts_sync_timeout()
            contact = parse_contact_frame(data)
            if contact:
                self.add_or_update_contact(contact)
                c = self.dict_to_contact(contact)
                if c:
                    self._win._conn_ctrl.synced_contact_keys.add(c.public_key_hex)
            else:
                log.warning(f"Failed to parse contact frame (raw {len(data)} bytes), skipping")
            self._win._conn_ctrl.sync_contact_current += 1
            if self._win._conn_ctrl.syncing:
                self._win._connection_banner.set_title(
                    _("Syncing contacts")
                    + f"\n{self._win._conn_ctrl.sync_contact_current}/{self._win._conn_ctrl.sync_contact_total}"
                )

        elif code == RESP_CODE_END_OF_CONTACTS:
            self._win._conn_ctrl.contacts_iter_busy = False
            self._win._conn_ctrl._cancel_contacts_sync_timeout()
            if self._win._storage._batch_depth > 0:
                self._win._storage._batch_depth -= 1
                if self._win._storage._batch_depth == 0:
                    self._win._storage._db.commit()
            # Remove contacts no longer on the device (full sync only)
            if (
                self._win._conn_ctrl.synced_contact_keys
                and not self._win._conn_ctrl.incremental_sync
            ):
                synced = self._win._conn_ctrl.synced_contact_keys
                stale_keys = set()
                for c in self._win._contacts:
                    if c.public_key_hex not in synced:
                        stale_keys.add(c.public_key_hex)
                        self._win._contacts_by_key.pop(c.public_key_hex, None)
                        self._win._storage.deactivate_contact(c.public_key_hex)
                self._win._contacts = [
                    c for c in self._win._contacts if c.public_key_hex not in stale_keys
                ]
                self._win._conn_ctrl.synced_contact_keys = None
            self._win._conn_ctrl.incremental_sync = False
            self._win._contacts_view.update_contacts(self._win._contacts)
            if self._win._map_view_inst is not None:
                self._win._map_view.update_contacts(self._win._contacts)
            self._win._refresh_chat_header()
            # Trigger deferred refetch if one was requested during iteration
            if self._win._conn_ctrl.contacts_refetch_pending:
                self._win._conn_ctrl.contacts_refetch_pending = False
                self.schedule_contacts_refetch()
            # Now safe to fetch channels sequentially
            self._win._fetch_channels_sequential(0)

        elif code in (RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CONTACT_MSG_RECV_V3):
            self._win._msg_ctrl.last_rx_time = time.monotonic()
            msg_data = parse_contact_message(data)
            if msg_data:
                self.handle_incoming_message(msg_data)
                self._win._conn_ctrl.msg_sync_retries = 0
                self._win._send_sync_next_message()

        elif code in (RESP_CODE_CHANNEL_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV_V3):
            self._win._msg_ctrl.last_rx_time = time.monotonic()
            msg_data = parse_channel_message(data)
            if msg_data:
                self.handle_incoming_channel_message(msg_data)
                self._win._conn_ctrl.msg_sync_retries = 0
                self._win._send_sync_next_message()

        elif code == RESP_CODE_NO_MORE_MESSAGES:
            self._win._finish_msg_sync()

        elif code == RESP_CODE_SENT:
            result = parse_sent_response(data)
            if result and self._win._msg_ctrl.sent_msg_queue:
                entry = self._win._msg_ctrl.sent_msg_queue.pop(0)
                if isinstance(entry, dict) and "type" in entry:
                    if "expected_ack" in result:
                        tag_hex = result["expected_ack"].hex()
                        self._win._pending_binary_requests[tag_hex] = entry
                        req_type = entry["type"]
                        if req_type == "neighbors":
                            self._win._neighbors_callback = None
                        elif req_type == "acl":
                            self._win._acl_callback = None
                        elif req_type == "telemetry":
                            self._win._telemetry_callback = None
                elif entry is None:
                    # Non-message send (channel, CLI, advert, trace, etc.)
                    if self._win._trace_callback:
                        timeout_ms = result.get("suggested_timeout", 15000)
                        if self._win._trace_timeout_callback:
                            self._win._trace_timeout_callback(timeout_ms)
                elif isinstance(entry, str) and entry.startswith("region:"):
                    # Region discovery request — map ack tag to callback
                    callback_key = entry[7:]  # strip 'region:' prefix
                    if "expected_ack" in result and callback_key in self._win._region_callbacks:
                        ack_hex = result["expected_ack"].hex()
                        self._win._region_callbacks[ack_hex] = self._win._region_callbacks.pop(
                            callback_key
                        )
                elif "expected_ack" in result:
                    msg_id = entry
                    ack_key = result["expected_ack"].hex()
                    if msg_id not in self._win._msg_ctrl.retry_context:
                        # Already delivered by an earlier attempt's ACK — don't
                        # overwrite DELIVERED status with SENT from this late
                        # RESP_CODE_SENT response.
                        pass
                    else:
                        if ack_key != "00000000":
                            self._win._msg_ctrl.pending_acks[ack_key] = msg_id
                        is_flood = result.get("type", 0) == 1
                        detail = self._win._msg_ctrl.send_detail(msg_id, is_flood)
                        self._win._storage.update_message_status(msg_id, MessageStatus.SENT)
                        self._win._chat_view.update_message_status(
                            msg_id, MessageStatus.SENT, detail
                        )
                        est_timeout = result.get("suggested_timeout", 30000)
                        timeout_id = GLib.timeout_add(
                            int(est_timeout * 1.5),
                            self._win._msg_ctrl._on_message_timeout,
                            msg_id,
                            ack_key,
                        )
                        self._win._msg_ctrl.pending_timeouts[msg_id] = timeout_id
            elif result and self._win._trace_callback:
                # Trace sent confirmation with empty queue (fallback)
                timeout_ms = result.get("suggested_timeout", 15000)
                if self._win._trace_timeout_callback:
                    self._win._trace_timeout_callback(timeout_ms)

        elif code == PUSH_CODE_SEND_CONFIRMED:
            result = parse_push_send_confirmed(data)
            if result and "code" in result:
                ack_key = result["code"]
                if ack_key in self._win._msg_ctrl.pending_acks:
                    msg_id = self._win._msg_ctrl.pending_acks.pop(ack_key)
                    # Remove remaining ack_keys for this message
                    for k in [
                        k for k, v in self._win._msg_ctrl.pending_acks.items() if v == msg_id
                    ]:
                        self._win._msg_ctrl.pending_acks.pop(k, None)
                    if msg_id in self._win._msg_ctrl.pending_timeouts:
                        GLib.source_remove(self._win._msg_ctrl.pending_timeouts.pop(msg_id))
                    self._win._msg_ctrl.retry_context.pop(msg_id, None)
                    trip_ms = result.get("trip_time_ms", 0)
                    detail = (
                        f"{trip_ms / 1000:.1f}s"
                        if trip_ms and trip_ms >= 1000
                        else f"{trip_ms}ms"
                        if trip_ms
                        else ""
                    )
                    self._win._storage.update_message_status(msg_id, MessageStatus.DELIVERED)
                    self._win._chat_view.update_message_status(
                        msg_id, MessageStatus.DELIVERED, detail
                    )
                    # Re-fetch contacts soon to pick up the newly discovered path
                    self.schedule_contacts_refetch()
                elif ack_key in self._win._msg_ctrl.grace_acks:
                    # Late ACK arrived during grace period — upgrade from failed to delivered
                    msg_id = self._win._msg_ctrl.grace_acks.pop(ack_key)
                    # Remove remaining ack_keys for this message
                    for k in [k for k, v in self._win._msg_ctrl.grace_acks.items() if v == msg_id]:
                        self._win._msg_ctrl.grace_acks.pop(k, None)
                    if msg_id in self._win._msg_ctrl.grace_timeouts:
                        GLib.source_remove(self._win._msg_ctrl.grace_timeouts.pop(msg_id))
                    trip_ms = result.get("trip_time_ms", 0)
                    detail = (
                        _("{}s (late)").format(f"{trip_ms / 1000:.1f}")
                        if trip_ms and trip_ms >= 1000
                        else _("{}ms (late)").format(trip_ms)
                        if trip_ms
                        else _("late")
                    )
                    log.info(f"Late ACK received for message {msg_id[:8]}")
                    self._win._storage.update_message_status(msg_id, MessageStatus.DELIVERED)
                    self._win._chat_view.update_message_status(
                        msg_id, MessageStatus.DELIVERED, detail
                    )
                    self.schedule_contacts_refetch()

        elif code == PUSH_CODE_MSG_WAITING:
            self._win._conn_ctrl.msg_sync_retries = 0
            self._win._send_sync_next_message()

        elif code in (PUSH_CODE_ADVERT, PUSH_CODE_NEW_ADVERT):
            self._win._msg_ctrl.last_rx_time = time.monotonic()
            contact_dict = parse_contact_frame(data)
            if contact_dict:
                contact = self.dict_to_contact(contact_dict)
                # Notify live advert listener if active
                if self._win._advert_listener:
                    self._win._advert_listener(contact)
                # If already a known contact, update it
                if self._win.is_contact_known(contact.public_key_hex):
                    self.add_or_update_contact(contact_dict)
                    self._win._contacts_view.update_contacts(self._win._contacts)
                # Always add/update in discovered list (dual-write)
                self.add_or_update_discovered(contact)

        elif code == PUSH_CODE_PATH_UPDATED:
            # Re-fetch contacts to get updated paths (debounced)
            self.schedule_contacts_refetch()

        elif code == RESP_CODE_CHANNEL_INFO:
            ch_info = parse_channel_info(data)
            if ch_info:
                channel = Channel(
                    index=ch_info["channel_idx"],
                    name=ch_info.get("channel_name", ""),
                    psk=ch_info.get("channel_secret", bytes(16)),
                )
                self.add_or_update_channel(channel)
                next_idx = ch_info["channel_idx"] + 1
            else:
                log.warning(f"Failed to parse channel info (raw {len(data)} bytes), skipping")
                next_idx = (
                    self._win._conn_ctrl.channel_sync_index + 1
                    if self._win._conn_ctrl.channel_sync_index >= 0
                    else 0
                )
            self._win._fetch_channels_sequential(next_idx)

        elif code == RESP_CODE_CURR_TIME:
            device_time = parse_curr_time(data)
            if device_time is not None:
                now = int(datetime.now().timestamp())
                drift = now - device_time
                # Auto-sync time on every connection — the official app does this too.
                # A correct RTC is critical for repeater login (anti-replay protection).
                # Firmware rejects setting time backwards, so ERR on device-ahead is
                # expected and harmless — suppress it via _ignore_next_err flag.
                log.info(
                    f"Device clock: {datetime.fromtimestamp(device_time)} "
                    f"(drift {drift}s), sending time sync"
                )
                self._win._ignore_next_err = True
                self._win.send_frame(build_set_device_time())
                if abs(drift) > 300:
                    mins = abs(drift) // 60
                    if drift > 0:
                        self._win.show_toast(
                            _("Device clock was {}min behind — synced").format(mins)
                        )
                    else:
                        self._win.show_toast(
                            _("Device clock is {}min ahead of system").format(mins)
                        )

        elif code == RESP_CODE_AUTO_ADD_CONFIG:
            config = parse_auto_add_config(data)
            if config:
                self._win._settings_view.update_auto_add_config(config)

        elif code == RESP_CODE_EXPORT_CONTACT:
            if getattr(self._win, "_pending_qr_callback", None):
                callback = self._win._pending_qr_callback
                self._win._pending_qr_callback = None
                callback(data)
            elif self._win._pending_clipboard_export:
                self._win._pending_clipboard_export = False
                clipboard = self._win.get_clipboard()
                clipboard.set("meshcore://" + data[1:].hex())
                # Persist clipboard content so it survives focus loss (Wayland)
                clipboard.store_async(None, None)

        elif code == RESP_CODE_BINARY_RESPONSE:
            # 0x8C format: [code(1)][reserved(1)][tag(4)][payload...]
            handled = False
            if len(data) >= 6:
                tag_hex = data[2:6].hex()
                region_cb = self._win._region_callbacks.pop(tag_hex, None)
                if region_cb:
                    region_text = data[6:].decode("utf-8", "ignore").strip("\x00")
                    region_cb(region_text)
                    handled = True
                elif tag_hex in self._win._pending_binary_requests:
                    req = self._win._pending_binary_requests.pop(tag_hex)
                    payload = data[6:] if len(data) > 6 else b""
                    if req["type"] == "neighbors":
                        result = parse_neighbors_response(data)
                        req["callback"](result)
                    elif req["type"] == "acl":
                        req["callback"](data)
                    elif req["type"] == "telemetry":
                        req["callback"](payload)
                    handled = True
            if not handled:
                if self._win._neighbors_callback:
                    result = parse_neighbors_response(data)
                    callback = self._win._neighbors_callback
                    self._win._neighbors_callback = None
                    callback(result)
                elif self._win._acl_callback:
                    callback = self._win._acl_callback
                    self._win._acl_callback = None
                    callback(data)
                elif self._win._telemetry_callback:
                    callback = self._win._telemetry_callback
                    self._win._telemetry_callback = None
                    lpp_data = data[6:] if len(data) > 6 else b""
                    callback(lpp_data)

        elif code == PUSH_CODE_TELEMETRY_RESPONSE:
            # 0x8B format: [code(1)][reserved(1)][pubkey_prefix(6)][lpp_data...]
            if self._win._telemetry_callback:
                callback = self._win._telemetry_callback
                self._win._telemetry_callback = None
                lpp_data = data[8:] if len(data) > 8 else b""
                callback(lpp_data)

        elif code == RESP_CODE_CONTROL_DATA:
            result = parse_discover_response(data)
            if result and self._win._discover_callback:
                self._win._discover_callback(result)

        elif code == RESP_CODE_ADVERT_PATH:
            result = parse_advert_path_response(data)
            if result and self._win._advert_path_callback:
                callback = self._win._advert_path_callback
                self._win._advert_path_callback = None
                callback(result)

        elif code == PUSH_CODE_PATH_DISCOVERY_RESPONSE:
            if self._win._path_discovery_callback:
                self._win._path_discovery_callback(data)

        elif code == PUSH_CODE_LOG_DATA:
            self.handle_log_data(data)

        elif code == PUSH_CODE_TRACE_DATA:
            result = parse_trace_response(data)
            if result and self._win._trace_callback:
                self._win._trace_callback(result)

        elif code == PUSH_CODE_LOGIN_SUCCESS:
            log.info(f"Received LOGIN_SUCCESS response, len={len(data)}, hex={data.hex()}")
            # Response format: [code][perms][prefix×6][timestamp×4][...] — 14 bytes
            if len(data) < 8:
                log.warning(f"LOGIN_SUCCESS too short: {len(data)} bytes, expected >= 8")
                return False
            perms = data[1]
            is_admin = (perms & 1) == 1
            log.info(f"Login permissions: 0x{perms:02x}, is_admin={is_admin}")
            response_prefix = data[2:8]
            # Extract repeater clock timestamp if present (bytes 8-11, LE uint32)
            login_timestamp = None
            if len(data) >= 12:
                login_timestamp = struct.unpack_from("<I", data, 8)[0]
                log.info(
                    f"Repeater clock at login: {login_timestamp} "
                    f"({datetime.fromtimestamp(login_timestamp)})"
                )
            log.info(
                f'Response prefix: {response_prefix.hex()}, expected: {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}'
            )
            if self._win._login_callback:
                if (
                    self._win._login_target_prefix
                    and response_prefix == self._win._login_target_prefix
                ):
                    log.info("Prefix matches! Login successful.")
                    self._win._login_is_admin = is_admin
                    was_flood = self._win._login_was_flood_retry
                    pub_key = self._win._login_pub_key
                    cb = self._win._login_callback
                    self._win.stop_login()
                    if was_flood and pub_key:
                        log.info("Flood login succeeded, clearing stale path for re-discovery")
                        from meshy.protocol import build_reset_path

                        self._win.send_frame(build_reset_path(pub_key))
                    cb(True, login_timestamp, is_admin)
                else:
                    log.warning(
                        f'Prefix mismatch: got {response_prefix.hex()}, expected {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}'
                    )
            else:
                log.warning("LOGIN_SUCCESS received but no callback registered")

        elif code == PUSH_CODE_STATUS_RESPONSE:
            log.info(f"Status response received: {len(data)} bytes")
            result = parse_status_response(data)
            if result and self._win._status_callback:
                log.info(
                    f'Status parsed: battery={result["battery_mv"]}mV uptime={result["uptime_secs"]}s'
                )
                self._win._status_callback(result)
                self._win._status_callback = None
            elif not result:
                log.warning(f"Status parse failed: data too short ({len(data)} bytes)")
            elif not self._win._status_callback:
                log.warning("Status response received but no callback registered")

        elif code == PUSH_CODE_LOGIN_FAILED:
            log.info(f"Received LOGIN_FAILED response, len={len(data)}, hex={data.hex()}")
            # Response format: [code][unknown][6-byte-prefix]
            if len(data) < 8:
                log.warning(f"LOGIN_FAILED too short: {len(data)} bytes, expected >= 8")
                return False
            response_prefix = data[2:8]
            log.info(
                f'Response prefix: {response_prefix.hex()}, expected: {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}'
            )
            if self._win._login_callback:
                if (
                    self._win._login_target_prefix
                    and response_prefix == self._win._login_target_prefix
                ):
                    log.info("Prefix matches! Login failed (wrong password).")
                    cb = self._win._login_callback
                    self._win.stop_login()
                    cb(False, None, False)
                else:
                    log.warning(
                        f'Prefix mismatch: got {response_prefix.hex()}, expected {self._win._login_target_prefix.hex() if self._win._login_target_prefix else "none"}'
                    )
            else:
                log.warning("LOGIN_FAILED received but no callback registered")

        return False  # Don't repeat idle callback

    def expect_ok(self, on_ok, on_error, timeout_ms=5000):
        """Register a callback for the next OK/ERROR response."""
        timeout_id = GLib.timeout_add(timeout_ms, self._on_expect_timeout)
        self._ok_callbacks.append((on_ok, on_error, timeout_id))

    def _on_expect_timeout(self):
        if self._ok_callbacks:
            _on_ok, on_error, _tid = self._ok_callbacks.pop(0)
            on_error({"message": "timeout", "code": 0})
        return False

    def dict_to_contact(self, c: dict) -> Contact | None:
        """Convert meshcore contact dict to Contact model."""
        try:
            path_length = c.get("out_path_len", -1)
            return Contact(
                public_key=bytes.fromhex(c["public_key"]),
                name=c.get("adv_name", "Unknown"),
                type=c.get("type", 1),
                flags=c.get("flags", 0),
                path_length=path_length,
                path=bytes.fromhex(c.get("out_path", "")),
                path_hash_size=c.get("path_hash_size", 1),
                latitude=c.get("adv_lat"),
                longitude=c.get("adv_lon"),
                last_seen=datetime.fromtimestamp(c["last_advert"])
                if c.get("last_advert")
                else None,
                device_lastmod=datetime.fromtimestamp(c["lastmod"]) if c.get("lastmod") else None,
                is_active=True,
            )
        except (ValueError, KeyError) as e:
            log.warning(f"Invalid contact data: {e}")
            return None

    def add_or_update_contact(self, contact_dict: dict):
        """Add or update a contact from a meshcore contact dict."""
        contact = self.dict_to_contact(contact_dict)
        if contact is None:
            return
        key = contact.public_key_hex
        if key in self._win._contacts_by_key:
            # Update in-place in the list
            for i, c in enumerate(self._win._contacts):
                if c.public_key_hex == key:
                    self._win._contacts[i] = contact
                    break
        else:
            self._win._contacts.append(contact)
        self._win._contacts_by_key[key] = contact
        self._win._storage.save_contact(contact)
        self.schedule_contacts_refresh()

    def schedule_contacts_refresh(self):
        """Debounce contact list UI updates — refresh once after a burst of changes."""
        if self._contacts_refresh_id:
            GLib.source_remove(self._contacts_refresh_id)
        self._contacts_refresh_id = GLib.timeout_add(200, self.do_contacts_refresh)

    def do_contacts_refresh(self):
        self._contacts_refresh_id = None
        self._win._contacts_view.update_contacts(self._win._contacts)
        if self._win._map_view_inst is not None:
            self._win._map_view.update_contacts(self._win._contacts)
        self._win._refresh_chat_header()
        return False

    def schedule_contacts_refetch(self):
        """Debounced re-fetch of contacts from device.

        Coalesces multiple rapid requests (e.g. SEND_CONFIRMED + PATH_UPDATED)
        into a single GET_CONTACTS call after a short delay.
        """
        if self._contacts_refetch_id:
            GLib.source_remove(self._contacts_refetch_id)
        self._contacts_refetch_id = GLib.timeout_add(300, self.do_contacts_refetch)

    def do_contacts_refetch(self):
        self._contacts_refetch_id = None
        self.send_get_contacts(incremental=True)
        return False

    def send_get_contacts(self, incremental: bool = False):
        """Send CMD_GET_CONTACTS, deferring if the firmware iterator is busy."""
        if self._win._conn_ctrl.contacts_iter_busy:
            self._win._conn_ctrl.contacts_refetch_pending = True
            return False
        self._win._conn_ctrl.contacts_iter_busy = True
        since = None
        if incremental and self._win._storage:
            since = self._win._storage.get_max_device_lastmod()
        self._win._conn_ctrl.incremental_sync = incremental and since is not None
        self._win.send_frame(build_get_contacts(since=since))
        return False

    def add_or_update_discovered(self, contact: Contact):
        """Add or update a contact in the discovered list (memory + SQLite)."""
        for i, c in enumerate(self._win._discovered):
            if c.public_key_hex == contact.public_key_hex:
                self._win._discovered[i] = contact
                if self._win._storage:
                    self._win._storage.save_discovered_contact(contact)
                return
        self._win._discovered.append(contact)
        if self._win._storage:
            self._win._storage.save_discovered_contact(contact)

    def add_or_update_channel(self, channel: Channel):
        """Add or update a channel, preserving local settings."""
        for i, c in enumerate(self._win._channels):
            if c.index == channel.index:
                # Preserve local settings not stored on device
                channel.unread_count = c.unread_count
                channel.notification_level = c.notification_level
                channel.region = c.region
                self._win._channels[i] = channel
                self._win._storage.save_channel(channel)
                self._win._channels_view.update_channels(self._win._channels)
                self.rebuild_key_store()
                return
        # New channel - check if we have stored settings from a previous session
        stored = self._win._storage.get_channel(channel.index)
        if stored:
            channel.notification_level = stored.notification_level
            channel.region = stored.region
        self._win._channels.append(channel)
        self._win._storage.save_channel(channel)
        self._win._channels_view.update_channels(self._win._channels)
        self.rebuild_key_store()

    def is_contact_chat_visible(self, contact: Contact) -> bool:
        """Check if the chat for this contact is currently visible."""
        if not self._win._chat_view_inst:
            return False
        return (
            self._win._current_view == "contacts"
            and self._win._chat_view_inst._contact is not None
            and self._win._chat_view_inst._contact.public_key_hex == contact.public_key_hex
            and self._win.is_active()
        )

    def is_channel_chat_visible(self, channel_index: int) -> bool:
        """Check if the chat for this channel is currently visible."""
        if not self._win._channels_view_inst:
            return False
        chat = self._win._channels_view_inst.get_chat_widget()
        return (
            self._win._current_view == "channels"
            and chat._current_channel is not None
            and chat._current_channel.index == channel_index
            and self._win.is_active()
        )

    def handle_incoming_message(self, msg_data: dict):
        """Handle an incoming direct message."""
        sender_prefix = msg_data.get("pubkey_prefix", "")
        try:
            contact = self._win._storage.find_contact_by_prefix(bytes.fromhex(sender_prefix))
        except ValueError:
            log.warning(f"Invalid sender prefix: {sender_prefix}")
            return
        if not contact:
            log.warning(f"Message from unknown sender: {sender_prefix}")
            return

        is_cli = msg_data.get("txt_type", 0) == 1
        sender_ts = min(msg_data["sender_timestamp"], time.time())
        text = msg_data["text"]
        room_sender_name = ""

        # Room messages: first 4 bytes of text are the sender's pub key prefix
        if contact.type == ContactType.ROOM and len(text) >= 4 and not is_cli:
            sender_bytes = text[:4].encode("latin-1") if isinstance(text, str) else text[:4]
            text = text[4:]
            # Try to resolve sender name from contacts
            for c in self._win._contacts:
                if c.public_key[:4] == sender_bytes:
                    room_sender_name = c.name
                    break
            if not room_sender_name:
                room_sender_name = sender_bytes.hex()

        # Deduplicate: same sender + text within ±5s of the timestamp
        if self._win._storage.has_duplicate_message(contact.public_key_hex, sender_ts, text):
            log.debug(f"Dropping duplicate message from {contact.name}")
            return

        message = Message(
            sender_key=contact.public_key,
            text=text,
            timestamp=datetime.fromtimestamp(sender_ts),
            is_outgoing=False,
            is_cli=is_cli,
            status=MessageStatus.DELIVERED,
            room_sender_name=room_sender_name,
        )
        # Route prefixed CLI responses to their registered callback
        if (
            is_cli
            and len(text) > 3
            and text[2] == "|"
            and text[:3] in self._win._cli_pending_prefixes
        ):
            prefix = text[:3]
            cb = self._win._cli_pending_prefixes.pop(prefix)
            cb(text[3:])
            return

        # Route CLI responses to management dialog if open
        if is_cli and self._win._cli_response_callback:
            self._win._cli_response_callback(contact, message)
            return

        # Repeaters and sensors don't have a chat view – silently drop
        # any non-CLI messages so no phantom unread badge can appear.
        if contact.type in (ContactType.REPEATER, ContactType.SENSOR):
            log.debug(f"Dropping non-CLI message from {contact.type.name} {contact.name}")
            return

        self._win._storage.save_message(contact.public_key_hex, message)
        self._win._chat_view.on_message_received(contact, message)

        if not self.is_contact_chat_visible(contact):
            # Initialize last_read_at if not yet set so the divider has a reference
            if self._win._storage.get_contact_last_read_at(contact.public_key_hex) is None:
                self._win._storage.set_contact_last_read_at(
                    contact.public_key_hex,
                    message.timestamp.timestamp() - 1,
                )
            self._win._contacts_view.mark_unread(contact.public_key_hex, True)
            if self._win._conn_ctrl.msg_syncing:
                self._win._conn_ctrl.sync_msg_count += 1
            else:
                self._win._send_notification(
                    contact.name,
                    message.text,
                    f"contact-{contact.public_key_hex}",
                )

    def handle_incoming_channel_message(self, msg_data: dict):
        """Handle an incoming channel message."""
        my_name = self._win._self_info.get("name", "")
        sender_name = msg_data.get("sender_name", "")
        msg_text = msg_data.get("msg_text", msg_data.get("text", ""))
        raw_sender_ts = msg_data["sender_timestamp"]
        sender_ts = min(raw_sender_ts, time.time())
        channel_idx = msg_data["channel_idx"]

        # Detect echo/repeat of our own outgoing message
        if my_name and sender_name == my_name:
            snr = msg_data.get("SNR")
            path_len = msg_data.get("path_len", -1)
            log.info(
                f"Echo detected: ch={channel_idx} ts={sender_ts} "
                f'text="{msg_text[:30]}" snr={snr} path_len={path_len}'
            )
            path_hops = []
            path_bytes_hex = msg_data.get("path_bytes", "")
            path_len_val = msg_data.get("path_len", 0)
            if path_bytes_hex and path_len_val > 0:
                hash_size = len(path_bytes_hex) // (2 * path_len_val) if path_len_val else 1
                chars = hash_size * 2
                path_hops = [
                    path_bytes_hex[i : i + chars].upper()
                    for i in range(0, len(path_bytes_hex), chars)
                ]
            self._win._channels_view.on_repeat_received(
                channel_idx,
                sender_ts,
                msg_text,
                path_hops,
            )
            return

        channel_msg = ChannelMessage(
            channel_index=channel_idx,
            sender_name=sender_name,
            text=msg_text,
            timestamp=datetime.fromtimestamp(sender_ts),
        )

        # Cache SNR for LOG_DATA to pick up (use raw timestamp to match
        # the key used by LOG_DATA before clamping)
        snr = msg_data.get("SNR")
        if snr is not None:
            cache_key = (channel_idx, raw_sender_ts, msg_text)
            self._pending_channel_snr[cache_key] = snr

        # Check if this is a duplicate (same message via different repeater)
        is_dup = self._win._channels_view.on_path_variant_received(
            channel_msg,
        )
        if is_dup:
            return  # Don't save or notify for duplicate copies

        self._win._storage.save_channel_message(channel_msg)
        self._win._channels_view.on_channel_message_received(channel_msg)

        # Find the channel for notification settings
        channel = None
        for ch in self._win._channels:
            if ch.index == channel_msg.channel_index:
                channel = ch
                break

        if not self.is_channel_chat_visible(channel_msg.channel_index):
            # Initialize last_read_at if not yet set so the divider has a reference
            if self._win._storage.get_channel_last_read_at(channel_msg.channel_index) is None:
                self._win._storage.set_channel_last_read_at(
                    channel_msg.channel_index,
                    channel_msg.timestamp.timestamp() - 1,
                )
            self._win._channels_view.mark_unread(channel_msg.channel_index, True)

            # Send notification based on channel settings
            if channel:
                level = channel.notification_level
                if level == NotificationLevel.DEFAULT:
                    settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")
                    level = NotificationLevel(
                        settings.get_int("default-channel-notification-level")
                    )
                should_notify = False
                if level == NotificationLevel.ALL:
                    should_notify = True
                elif level == NotificationLevel.MENTIONS:
                    if my_name and my_name.lower() in channel_msg.text.lower():
                        should_notify = True

                if should_notify:
                    if self._win._conn_ctrl.msg_syncing:
                        self._win._conn_ctrl.sync_msg_count += 1
                    else:
                        ch_name = channel.name or _("Channel {}").format(channel.index)
                        sender = channel_msg.sender_name or _("Someone")
                        self._win._send_notification(
                            ch_name,
                            _("{sender}: {text}").format(sender=sender, text=channel_msg.text),
                            f"channel-{channel_msg.channel_index}",
                        )

    def restore_unread_counts(self):
        """Restore unread message counts from storage on startup."""
        if not self._win._storage:
            return
        for contact in self._win._contacts:
            # Repeaters and sensors have no chat view – skip them.
            if contact.type in (ContactType.REPEATER, ContactType.SENSOR):
                continue
            count = self._win._storage.count_unread_messages(contact.public_key_hex)
            if count > 0:
                if self._win._storage.get_contact_last_read_at(contact.public_key_hex) is None:
                    oldest = self._win._storage.get_oldest_unread_contact_timestamp(
                        contact.public_key_hex
                    )
                    ts = (oldest - 1) if oldest is not None else (datetime.now().timestamp() - 1)
                    self._win._storage.set_contact_last_read_at(contact.public_key_hex, ts)
                self._win._contacts_view._unread_counts[contact.public_key_hex] = count
                self._win._contacts_view._unread_keys.add(contact.public_key_hex)
        # Refresh to show badges
        self._win._contacts_view.update_contacts(self._win._contacts)

        for channel in self._win._channels:
            if channel.is_empty:
                continue
            count = self._win._storage.count_unread_channel_messages(channel.index)
            if count > 0:
                # Initialize last_read_at if not set, so the unread divider
                # is placed correctly when the user opens the channel.
                if self._win._storage.get_channel_last_read_at(channel.index) is None:
                    oldest = self._win._storage.get_oldest_unread_channel_timestamp(channel.index)
                    ts = (oldest - 1) if oldest is not None else (datetime.now().timestamp() - 1)
                    self._win._storage.set_channel_last_read_at(channel.index, ts)
                self._win._channels_view._unread_counts[channel.index] = count
                self._win._channels_view._unread_indices.add(channel.index)
        # Refresh to show badges
        self._win._channels_view.update_channels(self._win._channels)

    def rebuild_key_store(self):
        """Rebuild the channel hash->PSK map from current channels."""
        from meshy.mesh_crypto import channel_hash

        self._win._channel_hash_map = {}
        self._win._channel_psk_map = {}  # hash -> psk bytes
        for ch in self._win._channels:
            if ch.is_empty:
                continue
            h = channel_hash(ch.psk_hex)
            self._win._channel_hash_map[h] = ch.index
            self._win._channel_psk_map[h] = ch.psk

    def handle_log_data(self, data: bytes):
        """Handle LOG_DATA (0x88) push - raw radio packet with path bytes."""
        from meshy.mesh_crypto import (
            PAYLOAD_ADVERT,
            PAYLOAD_GROUP_TEXT,
            decode_log_packet,
            decrypt_group_text,
        )
        from meshy.models import RxLogEntry

        if len(data) < 4:
            return

        snr = struct.unpack("b", data[1:2])[0] / 4.0
        rssi = struct.unpack("b", data[2:3])[0]
        mesh_data = data[3:]

        try:
            result = decode_log_packet(mesh_data)
        except Exception as e:
            log.warning(f"LOG_DATA decode failed: {e}")
            return

        if result:
            entry = RxLogEntry(
                timestamp=datetime.now(),
                snr=snr,
                rssi=rssi,
                payload_type=result["payload_type"],
                route_type=result.get("route_type", 0),
                path=result.get("path", []),
                path_hash_size=result.get("path_hash_size", 1),
                pkt_hash=result.get("pkt_hash", ""),
                size=len(mesh_data),
                transport_from=result.get("transport_from", ""),
                transport_to=result.get("transport_to", ""),
                raw_hex=mesh_data.hex(),
            )
            self._win._rx_log.append(entry)
            if self._win._rx_log_callback:
                self._win._rx_log_callback(entry)

            if entry.path:
                key = entry.path[-1]
                now = time.monotonic()
                existing = self._win._direct_repeaters.get(key)
                best = max(snr, existing[0]) if existing else snr
                self._win._direct_repeaters[key] = (best, now, 0)
                self._win._repeaters_refreshed.add(key)
                stale = [
                    k for k, (_, ts, _mc) in self._win._direct_repeaters.items() if now - ts > 600
                ]
                for k in stale:
                    del self._win._direct_repeaters[k]
                if len(self._win._direct_repeaters) > 5:
                    weakest = min(
                        self._win._direct_repeaters, key=lambda k: self._win._direct_repeaters[k][0]
                    )
                    del self._win._direct_repeaters[weakest]
                if not self._win._signal_icon_timer_id:
                    self._win._signal_icon_timer_id = GLib.timeout_add_seconds(
                        1, self._win._update_signal_icon_tick
                    )

        if not result:
            return

        if result["payload_type"] == PAYLOAD_ADVERT and "advert" in result:
            advert = result["advert"]
            self_key = self._win._self_info.get("public_key", "")
            if advert["public_key"] != self_key:
                contact = self.dict_to_contact(advert)
                if contact:
                    self.add_or_update_discovered(contact)

        if result["payload_type"] != PAYLOAD_GROUP_TEXT:
            return

        if not self._win._channel_hash_map:
            return

        ch_hash = result.get("channel_hash")
        ciphertext = result.get("ciphertext")
        if not ch_hash or not ciphertext:
            return

        psk = self._win._channel_psk_map.get(ch_hash)
        if not psk:
            return

        decrypted = decrypt_group_text(ciphertext, psk)
        if not decrypted:
            log.warning(
                f"LOG_DATA: decryption failed, "
                f"channel_hash={ch_hash}, "
                f"available_channels={[ch.index for ch in self._win._channels if not ch.is_empty]}"
            )
            return

        sender = decrypted.get("sender", "")
        text = decrypted.get("message", "")
        raw_timestamp = decrypted.get("timestamp", 0)
        timestamp = min(raw_timestamp, time.time())
        path_hops = result.get("path", [])

        if not sender or not text:
            return

        path_bytes_hex = "".join(h.lower() for h in path_hops)
        my_name = self._win._self_info.get("name", "")

        log.info(
            f'LOG_DATA: sender="{sender}" path={path_hops} ' f'text="{text[:30]}..." ts={timestamp}'
        )

        # Match channel using pre-built hash map
        channel_idx = self._win._channel_hash_map.get(ch_hash.lower())
        if channel_idx is None:
            log.debug(f"LOG_DATA: no channel match for hash={ch_hash}")
            return

        # Pull SNR cached by CHANNEL_MSG_RECV handler (use raw timestamp
        # to match the key stored by CHANNEL_MSG_RECV before clamping)
        cache_key = (channel_idx, raw_timestamp, text)
        cached_snr = self._pending_channel_snr.pop(cache_key, None)

        path_info = {
            "snr": cached_snr,
            "path_len": len(path_hops),
            "path_bytes": path_bytes_hex,
        }

        if my_name and sender == my_name:
            # Our own message echoed back through repeaters
            original_key = self._win._channels_view.on_repeat_received(
                channel_idx,
                timestamp,
                text,
                path_hops,
            )
            store_ts = original_key[1] if original_key else timestamp
            if self._win._storage:
                self._win._storage.save_channel_message_path(
                    channel_idx,
                    store_ts,
                    text,
                    path_info,
                )
        else:
            # Someone else's message - record path variant
            channel_msg = ChannelMessage(
                channel_index=channel_idx,
                sender_name=sender,
                text=text,
                timestamp=datetime.fromtimestamp(timestamp),
            )
            if self._win._storage:
                self._win._storage.save_channel_message_path(
                    channel_idx,
                    timestamp,
                    text,
                    path_info,
                )
            self._win._channels_view.add_path_info(
                channel_msg,
                path_info,
            )
