# 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 typing import Optional

from gi.repository import Gio, GLib

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

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._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: {current}/{total}').format(current=0, total=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: {current}/{total}').format(current=self._win._conn_ctrl.sync_contact_current, total=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()
            # 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):
                stale = [c for c in self._win._contacts
                         if c.public_key_hex not in self._win._conn_ctrl.synced_contact_keys]
                for c in stale:
                    self._win._contacts.remove(c)
                    self._win._contacts_by_key.pop(c.public_key_hex, None)
                    self._win._storage.deactivate_contact(c.public_key_hex)
                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))
                    ctx = 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) -> Optional[Contact]:
        """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 (decode_log_packet, decrypt_group_text,
                                       parse_advert_payload, PAYLOAD_ADVERT, PAYLOAD_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 len(self._win._rx_log) > 500:
                self._win._rx_log.pop(0)
            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,
            )
