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

"""BlueZ D-Bus BLE transport for MeshCore companion devices.

Uses the BlueZ D-Bus API to discover, connect, and communicate with
MeshCore devices via the Nordic UART Service (NUS).
"""

import logging
import time
from collections import deque
from typing import Optional, Callable

import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gio, GLib

from meshy.transport_base import ConnectionState

log = logging.getLogger(__name__)

# Nordic UART Service UUIDs
NUS_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
NUS_TX_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'  # Notify (device -> app)
NUS_RX_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'  # Write (app -> device)

BLUEZ_BUS_NAME = 'org.bluez'
BLUEZ_ADAPTER_IFACE = 'org.bluez.Adapter1'
BLUEZ_DEVICE_IFACE = 'org.bluez.Device1'
BLUEZ_AGENT_IFACE = 'org.bluez.Agent1'
BLUEZ_AGENT_MANAGER_IFACE = 'org.bluez.AgentManager1'
BLUEZ_GATT_SERVICE_IFACE = 'org.bluez.GattService1'
BLUEZ_GATT_CHAR_IFACE = 'org.bluez.GattCharacteristic1'
DBUS_OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'
DBUS_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'

AGENT_PATH = '/io/github/meshy/agent'

_MAX_WRITE_RETRIES = 3
_WRITE_RETRY_DELAY_MS = 200

# Agent1 D-Bus interface XML for registration
AGENT_INTROSPECTION_XML = '''
<node>
  <interface name="org.bluez.Agent1">
    <method name="Release"/>
    <method name="RequestPinCode">
      <arg direction="in" type="o" name="device"/>
      <arg direction="out" type="s"/>
    </method>
    <method name="DisplayPinCode">
      <arg direction="in" type="o" name="device"/>
      <arg direction="in" type="s" name="pincode"/>
    </method>
    <method name="RequestPasskey">
      <arg direction="in" type="o" name="device"/>
      <arg direction="out" type="u"/>
    </method>
    <method name="DisplayPasskey">
      <arg direction="in" type="o" name="device"/>
      <arg direction="in" type="u" name="passkey"/>
      <arg direction="in" type="q" name="entered"/>
    </method>
    <method name="RequestConfirmation">
      <arg direction="in" type="o" name="device"/>
      <arg direction="in" type="u" name="passkey"/>
    </method>
    <method name="RequestAuthorization">
      <arg direction="in" type="o" name="device"/>
    </method>
    <method name="AuthorizeService">
      <arg direction="in" type="o" name="device"/>
      <arg direction="in" type="s" name="uuid"/>
    </method>
    <method name="Cancel"/>
  </interface>
</node>
'''


class BleDevice:
    """Represents a discovered BLE device."""
    def __init__(self, path: str, address: str, name: str, rssi: int = -100):
        self.path = path
        self.address = address
        self.name = name
        self.rssi = rssi

    def __repr__(self):
        return f'BleDevice({self.name}, {self.address})'


class BleTransport:
    """BLE transport using BlueZ D-Bus API."""

    def __init__(self):
        self._bus: Optional[Gio.DBusConnection] = None
        self._state = ConnectionState.DISCONNECTED
        self._adapter_path: Optional[str] = None
        self._device_path: Optional[str] = None
        self._tx_char_path: Optional[str] = None
        self._rx_char_path: Optional[str] = None
        self._device_address: Optional[str] = None
        self._notify_signal_id: Optional[int] = None
        self._disconnect_signal_id: Optional[int] = None
        self._iface_added_signal_id: Optional[int] = None
        self._discovered_devices: dict[str, BleDevice] = {}
        self._services_check_count: int = 0
        self._recent_rx: deque = deque(maxlen=32)  # (timestamp, data_hash) for dedup
        self._write_queue: deque = deque()  # pending write data
        self._write_in_flight: bool = False
        self._write_current: Optional[bytes] = None  # data currently being written
        self._write_retries: int = 0

        # Callbacks
        self.on_state_changed: Optional[Callable[[ConnectionState], None]] = None
        self.on_data_received: Optional[Callable[[bytes], None]] = None
        self.on_device_discovered: Optional[Callable[[BleDevice], None]] = None
        self.on_passkey_requested: Optional[Callable[[str, Callable], None]] = None  # (device_path, reply_callback)

        self._agent_registered = False
        self._agent_reg_id: Optional[int] = None
        self._passkey_invocation = None  # pending D-Bus invocation for passkey reply

        self._connect_to_system_bus()

    def _connect_to_system_bus(self):
        try:
            self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
            log.info('Connected to system D-Bus')
        except GLib.Error as e:
            log.error(f'Failed to connect to system bus: {e}')

    def _is_bluez_available(self) -> bool:
        """Quick check whether the org.bluez D-Bus name is owned.

        This queries the D-Bus daemon itself (always fast) rather than
        calling into BlueZ, so it never blocks the main loop even when
        no Bluetooth hardware is present.
        """
        if not self._bus:
            return False
        try:
            result = self._bus.call_sync(
                'org.freedesktop.DBus', '/org/freedesktop/DBus',
                'org.freedesktop.DBus', 'NameHasOwner',
                GLib.Variant('(s)', (BLUEZ_BUS_NAME,)),
                GLib.VariantType.new('(b)'),
                Gio.DBusCallFlags.NONE, 1000, None,
            )
            return result.get_child_value(0).get_boolean()
        except GLib.Error:
            return False

    @property
    def state(self) -> ConnectionState:
        return self._state

    @property
    def is_connected(self) -> bool:
        return self._state == ConnectionState.CONNECTED

    @property
    def connected_address(self) -> Optional[str]:
        return self._device_address

    @property
    def discovered_devices(self) -> list[BleDevice]:
        return list(self._discovered_devices.values())

    def _set_state(self, state: ConnectionState):
        if self._state != state:
            old = self._state
            self._state = state
            log.info(f'BLE state: {old.name} -> {state.name}')
            if self.on_state_changed:
                self.on_state_changed(state)

    def _call_bluez(self, object_path: str, interface: str, method: str,
                    parameters: Optional[GLib.Variant] = None,
                    reply_type: Optional[GLib.VariantType] = None) -> Optional[GLib.Variant]:
        """Synchronous D-Bus method call to BlueZ."""
        try:
            result = self._bus.call_sync(
                BLUEZ_BUS_NAME, object_path, interface, method,
                parameters, reply_type,
                Gio.DBusCallFlags.NONE, 10000, None
            )
            return result
        except GLib.Error as e:
            log.error(f'D-Bus call {interface}.{method} on {object_path} failed: {e}')
            return None

    def _call_bluez_async(self, object_path: str, interface: str, method: str,
                          parameters: Optional[GLib.Variant] = None,
                          callback: Optional[Callable] = None):
        """Async D-Bus method call to BlueZ."""
        def _on_done(bus, result):
            try:
                res = bus.call_finish(result)
                if callback:
                    callback(res, None)
            except GLib.Error as e:
                log.error(f'Async D-Bus call {interface}.{method} on {object_path} failed: {e}')
                if callback:
                    callback(None, e)

        self._bus.call(
            BLUEZ_BUS_NAME, object_path, interface, method,
            parameters, None,
            Gio.DBusCallFlags.NONE, 30000, None, _on_done
        )

    def _get_property(self, object_path: str, interface: str, prop: str) -> Optional[GLib.Variant]:
        result = self._call_bluez(
            object_path, DBUS_PROPERTIES_IFACE, 'Get',
            GLib.Variant('(ss)', (interface, prop)),
            GLib.VariantType.new('(v)')
        )
        if result:
            return result.get_child_value(0).get_variant()
        return None

    def _find_adapter(self) -> Optional[str]:
        """Find the first Bluetooth adapter."""
        if not self._is_bluez_available():
            return None
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if not result:
            return None

        objects = result.get_child_value(0)
        for i in range(objects.n_children()):
            entry = objects.get_child_value(i)
            path = entry.get_child_value(0).get_string()
            interfaces = entry.get_child_value(1)
            for j in range(interfaces.n_children()):
                iface_entry = interfaces.get_child_value(j)
                iface_name = iface_entry.get_child_value(0).get_string()
                if iface_name == BLUEZ_ADAPTER_IFACE:
                    return path
        return None

    def start_scan(self):
        """Start BLE device discovery."""
        if self._state != ConnectionState.DISCONNECTED:
            return

        self._adapter_path = self._find_adapter()
        if not self._adapter_path:
            log.error('No Bluetooth adapter found')
            return

        self._discovered_devices.clear()
        self._set_state(ConnectionState.SCANNING)

        # Subscribe to InterfacesAdded for new devices
        self._iface_added_signal_id = self._bus.signal_subscribe(
            BLUEZ_BUS_NAME, DBUS_OBJECT_MANAGER_IFACE,
            'InterfacesAdded', None, None,
            Gio.DBusSignalFlags.NONE, self._on_interfaces_added
        )

        # Set discovery filter - use NUS service UUID (what MeshCore devices actually advertise)
        self._call_bluez(
            self._adapter_path, BLUEZ_ADAPTER_IFACE, 'SetDiscoveryFilter',
            GLib.Variant('(a{sv})', ({
                'Transport': GLib.Variant('s', 'le'),
            },))
        )

        # Check already known/paired devices first
        self._check_existing_devices()

        # Start discovery
        self._call_bluez(self._adapter_path, BLUEZ_ADAPTER_IFACE, 'StartDiscovery')
        log.info('BLE scan started')

    def _check_existing_devices(self):
        """Check for already-known devices that have the NUS service."""
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if not result:
            return

        objects = result.get_child_value(0)
        for i in range(objects.n_children()):
            entry = objects.get_child_value(i)
            path = entry.get_child_value(0).get_string()
            interfaces = entry.get_child_value(1)
            for j in range(interfaces.n_children()):
                iface_entry = interfaces.get_child_value(j)
                iface_name = iface_entry.get_child_value(0).get_string()
                if iface_name == BLUEZ_DEVICE_IFACE:
                    props = iface_entry.get_child_value(1)
                    self._process_device(path, props)

    def _on_interfaces_added(self, connection, sender, object_path,
                             interface_name, signal_name, parameters):
        """Handle new D-Bus interface (new device discovered)."""
        path = parameters.get_child_value(0).get_string()
        interfaces = parameters.get_child_value(1)

        for i in range(interfaces.n_children()):
            entry = interfaces.get_child_value(i)
            iface_name = entry.get_child_value(0).get_string()
            if iface_name == BLUEZ_DEVICE_IFACE:
                props = entry.get_child_value(1)
                self._process_device(path, props)

    def _process_device(self, path: str, props: GLib.Variant):
        """Process a discovered device's properties.

        Accept devices that have the NUS service UUID in their advertised UUIDs.
        """
        address = name = ''
        rssi = -100
        uuids = []

        for k in range(props.n_children()):
            prop = props.get_child_value(k)
            key = prop.get_child_value(0).get_string()
            val = prop.get_child_value(1).get_variant()
            if key == 'Address':
                address = val.get_string()
            elif key == 'Name':
                name = val.get_string()
            elif key == 'Alias':
                if not name:
                    name = val.get_string()
            elif key == 'RSSI':
                rssi = val.get_int16()
            elif key == 'UUIDs':
                uuids = [val.get_child_value(i).get_string()
                         for i in range(val.n_children())]

        if not address:
            return

        # Accept devices that have NUS service UUID
        has_nus = NUS_SERVICE_UUID.lower() in [u.lower() for u in uuids]
        if not has_nus:
            return

        log.info(f'Found NUS device: {name} ({address}) at {path}')
        device = BleDevice(path=path, address=address,
                          name=name or address, rssi=rssi)
        self._discovered_devices[path] = device
        if self.on_device_discovered:
            self.on_device_discovered(device)

    def stop_scan(self):
        """Stop BLE device discovery."""
        if self._state != ConnectionState.SCANNING:
            return

        if self._adapter_path:
            self._call_bluez(self._adapter_path, BLUEZ_ADAPTER_IFACE, 'StopDiscovery')

        if self._iface_added_signal_id:
            self._bus.signal_unsubscribe(self._iface_added_signal_id)
            self._iface_added_signal_id = None

        self._set_state(ConnectionState.DISCONNECTED)
        log.info('BLE scan stopped')

    def connect_device(self, device_path: str):
        """Connect to a BLE device by its D-Bus object path."""
        if self._state == ConnectionState.SCANNING:
            self.stop_scan()

        self._set_state(ConnectionState.CONNECTING)
        self._device_path = device_path
        self._services_check_count = 0

        self._reconnect_attempted = False

        # Read the device address
        addr_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, 'Address')
        self._device_address = addr_var.get_string() if addr_var else None

        # Register agent - needed for devices requiring auth on every connection
        self._register_agent()

        # Ensure device is trusted (required for GATT service resolution)
        self._call_bluez(
            device_path, DBUS_PROPERTIES_IFACE, 'Set',
            GLib.Variant('(ssv)', (BLUEZ_DEVICE_IFACE, 'Trusted',
                                   GLib.Variant('b', True)))
        )

        # Check if device is paired, if not try pairing first
        paired_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, 'Paired')
        if paired_var and not paired_var.get_boolean():
            log.info(f'Device {device_path} not paired, initiating pairing')
            self.pair_device(device_path)
            return

        # Check if already connected
        connected_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, 'Connected')
        if connected_var and connected_var.get_boolean():
            log.info(f'Device {device_path} is already connected')
            # Check if services are already resolved
            resolved_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, 'ServicesResolved')
            if resolved_var and resolved_var.get_boolean():
                log.info('Services already resolved, finding characteristics')
                self._find_nus_characteristics()
                return
            else:
                log.info('Waiting for services to resolve...')
                GLib.timeout_add(500, self._check_services_resolved)
                return

        # Not connected, initiate connection
        log.info(f'Initiating connection to {device_path}')

        def _on_connected(result, error):
            if error:
                error_msg = str(error)
                # "Already Connected" is not a real error
                if 'Already Connected' in error_msg:
                    log.info('Device reports already connected')
                    GLib.timeout_add(500, self._check_services_resolved)
                else:
                    log.error(f'Failed to connect: {error}')
                    self._set_state(ConnectionState.DISCONNECTED)
                return
            log.info(f'Connect call succeeded for {device_path}')
            GLib.timeout_add(500, self._check_services_resolved)

        self._call_bluez_async(
            device_path, BLUEZ_DEVICE_IFACE, 'Connect',
            callback=_on_connected
        )

    def connect_by_address(self, address: str):
        """Connect to a device by its Bluetooth address."""
        # Find the device path from known devices
        for path, device in self._discovered_devices.items():
            if device.address.upper() == address.upper():
                self.connect_device(path)
                return

        # Try to find it in BlueZ known/paired devices
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if result:
            objects = result.get_child_value(0)
            for i in range(objects.n_children()):
                entry = objects.get_child_value(i)
                path = entry.get_child_value(0).get_string()
                interfaces = entry.get_child_value(1)
                for j in range(interfaces.n_children()):
                    iface_entry = interfaces.get_child_value(j)
                    iface_name = iface_entry.get_child_value(0).get_string()
                    if iface_name == BLUEZ_DEVICE_IFACE:
                        addr_var = self._get_property(path, BLUEZ_DEVICE_IFACE, 'Address')
                        if addr_var and addr_var.get_string().upper() == address.upper():
                            log.info(f'Found device at {path} for address {address}')
                            self.connect_device(path)
                            return

        # Device not in BlueZ (maybe removed) - scan to find it
        log.info(f'Device {address} not in BlueZ, scanning to rediscover...')
        target_address = address.upper()

        orig_callback = self.on_device_discovered

        def _on_found(device):
            if device.address.upper() == target_address:
                log.info(f'Rediscovered {device.name} at {device.path}')
                self.on_device_discovered = orig_callback
                if self.state == ConnectionState.SCANNING:
                    self.stop_scan()
                # Need to pair since bond was removed
                self._set_state(ConnectionState.CONNECTING)
                self.pair_device(device.path)

        self.on_device_discovered = _on_found
        self.start_scan()

        # Give up after 10s
        def _scan_timeout():
            if self.on_device_discovered == _on_found:
                self.on_device_discovered = orig_callback
                if self.state == ConnectionState.SCANNING:
                    self.stop_scan()
                log.error(f'Device {address} not found after scanning')
                self._set_state(ConnectionState.DISCONNECTED)
            return False

        GLib.timeout_add(10000, _scan_timeout)

    def _check_services_resolved(self) -> bool:
        """Check if GATT services are resolved after connection."""
        if not self._device_path or self._state != ConnectionState.CONNECTING:
            return False

        self._services_check_count += 1

        if not self._device_path or self._state != ConnectionState.CONNECTING:
            return False

        # Check if still connected
        connected_var = self._get_property(
            self._device_path, BLUEZ_DEVICE_IFACE, 'Connected'
        )
        is_connected = connected_var and connected_var.get_boolean()
        log.debug(f'Checking ServicesResolved (attempt {self._services_check_count}, '
                  f'connected={is_connected})')

        if not is_connected and not self._reconnect_attempted:
            # Device not connected yet - retry Connect with a longer delay
            self._reconnect_attempted = True
            device_path = self._device_path
            log.info('Device not connected, retrying Connect in 3s...')

            def _do_retry():
                if self._state != ConnectionState.CONNECTING:
                    return False
                self._services_check_count = 0
                log.info('Retrying Connect...')

                def _on_connect(result, error):
                    if error:
                        log.error(f'Retry connect failed: {error}')
                        self._set_state(ConnectionState.DISCONNECTED)
                        return
                    log.info('Retry connect succeeded, waiting for services...')
                    GLib.timeout_add(500, self._check_services_resolved)

                self._call_bluez_async(
                    device_path, BLUEZ_DEVICE_IFACE, 'Connect',
                    callback=_on_connect,
                )
                return False

            GLib.timeout_add(3000, _do_retry)
            return False  # Stop polling, retry starts new one

        if not is_connected and self._reconnect_attempted:
            # Already tried reconnect, still not connected
            log.error('Device not connected after reconnect')
            self._set_state(ConnectionState.DISCONNECTED)
            return False

        # Connected but services not resolved
        resolved_var = self._get_property(
            self._device_path, BLUEZ_DEVICE_IFACE, 'ServicesResolved'
        )
        if resolved_var and resolved_var.get_boolean():
            log.info('Services resolved, finding NUS characteristics')
            self._find_nus_characteristics()
            return False

        # After 20 attempts (10s) with connection but no services, try disconnect+reconnect
        if self._services_check_count == 20 and not self._reconnect_attempted:
            self._reconnect_attempted = True
            device_path = self._device_path
            log.info('Services not resolving despite connection, trying disconnect+reconnect')
            self._call_bluez(device_path, BLUEZ_DEVICE_IFACE, 'Disconnect')

            def _do_reconnect():
                if self._state != ConnectionState.CONNECTING:
                    return False
                log.info('Reconnecting after disconnect...')
                self._services_check_count = 0

                def _on_reconnect(result, error):
                    if error:
                        log.error(f'Reconnect failed: {error}')
                        self._set_state(ConnectionState.DISCONNECTED)
                        return
                    log.info('Reconnect succeeded, waiting for services...')
                    GLib.timeout_add(500, self._check_services_resolved)

                self._call_bluez_async(
                    device_path, BLUEZ_DEVICE_IFACE, 'Connect',
                    callback=_on_reconnect,
                )
                return False

            GLib.timeout_add(3000, _do_reconnect)
            return False  # Stop polling, reconnect starts new one

        # Give up after 40 attempts (20 seconds)
        if self._services_check_count > 40:
            log.error('Timed out waiting for services to resolve')
            self._set_state(ConnectionState.DISCONNECTED)
            return False

        return True

    def _find_nus_characteristics(self):
        """Find NUS TX/RX characteristics after services are resolved."""
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if not result:
            log.error('Failed to enumerate GATT services')
            self._set_state(ConnectionState.DISCONNECTED)
            return

        self._tx_char_path = None
        self._rx_char_path = None

        objects = result.get_child_value(0)
        for i in range(objects.n_children()):
            entry = objects.get_child_value(i)
            path = entry.get_child_value(0).get_string()
            if not path.startswith(self._device_path):
                continue

            interfaces = entry.get_child_value(1)
            for j in range(interfaces.n_children()):
                iface_entry = interfaces.get_child_value(j)
                iface_name = iface_entry.get_child_value(0).get_string()
                if iface_name == BLUEZ_GATT_CHAR_IFACE:
                    # Read UUID from the properties dict in the managed objects
                    char_props = iface_entry.get_child_value(1)
                    uuid_var = char_props.lookup_value('UUID', GLib.VariantType.new('s'))
                    if uuid_var:
                        uuid = uuid_var.get_string().lower()
                        log.debug(f'  Characteristic {path}: UUID={uuid}')
                        if uuid == NUS_TX_CHAR_UUID:
                            self._tx_char_path = path
                            log.info(f'Found NUS TX (notify) at {path}')
                        elif uuid == NUS_RX_CHAR_UUID:
                            self._rx_char_path = path
                            log.info(f'Found NUS RX (write) at {path}')

        if self._tx_char_path and self._rx_char_path:
            log.info('Found both NUS characteristics, starting notifications')
            self._start_notifications()
        else:
            log.error(f'NUS characteristics not found (TX={self._tx_char_path}, RX={self._rx_char_path})')
            # Fallback: try reading UUID via Get property call
            self._find_nus_characteristics_fallback()

    def _find_nus_characteristics_fallback(self):
        """Fallback: enumerate characteristics by calling Get on each one."""
        log.info('Trying fallback characteristic discovery...')
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if not result:
            self._set_state(ConnectionState.DISCONNECTED)
            return

        objects = result.get_child_value(0)
        for i in range(objects.n_children()):
            entry = objects.get_child_value(i)
            path = entry.get_child_value(0).get_string()
            if not path.startswith(self._device_path):
                continue

            interfaces = entry.get_child_value(1)
            for j in range(interfaces.n_children()):
                iface_entry = interfaces.get_child_value(j)
                iface_name = iface_entry.get_child_value(0).get_string()
                if iface_name == BLUEZ_GATT_CHAR_IFACE:
                    uuid_var = self._get_property(path, BLUEZ_GATT_CHAR_IFACE, 'UUID')
                    if uuid_var:
                        uuid = uuid_var.get_string().lower()
                        log.debug(f'  Fallback: Char {path} UUID={uuid}')
                        if uuid == NUS_TX_CHAR_UUID:
                            self._tx_char_path = path
                        elif uuid == NUS_RX_CHAR_UUID:
                            self._rx_char_path = path

        if self._tx_char_path and self._rx_char_path:
            log.info('Fallback found NUS characteristics')
            self._start_notifications()
        else:
            log.error(f'Fallback also failed (TX={self._tx_char_path}, RX={self._rx_char_path})')
            self._set_state(ConnectionState.DISCONNECTED)

    def _start_notifications(self):
        """Start notifications on the TX characteristic to receive data."""
        # Subscribe to PropertiesChanged for the TX characteristic
        self._notify_signal_id = self._bus.signal_subscribe(
            BLUEZ_BUS_NAME, DBUS_PROPERTIES_IFACE,
            'PropertiesChanged', self._tx_char_path, None,
            Gio.DBusSignalFlags.NONE, self._on_tx_notify
        )

        # Listen for disconnect
        self._disconnect_signal_id = self._bus.signal_subscribe(
            BLUEZ_BUS_NAME, DBUS_PROPERTIES_IFACE,
            'PropertiesChanged', self._device_path, None,
            Gio.DBusSignalFlags.NONE, self._on_device_properties_changed
        )

        # Start notifications on the TX characteristic
        def _on_notify_started(result, error):
            if error:
                error_msg = str(error)
                if 'Already notifying' in error_msg or 'In Progress' in error_msg:
                    log.info('Notifications already active')
                    self._set_state(ConnectionState.CONNECTED)
                else:
                    log.error(f'Failed to start notifications: {error}')
                    self._set_state(ConnectionState.DISCONNECTED)
                return
            log.info('BLE notifications started successfully')
            self._set_state(ConnectionState.CONNECTED)

        self._call_bluez_async(
            self._tx_char_path, BLUEZ_GATT_CHAR_IFACE, 'StartNotify',
            callback=_on_notify_started
        )

    def _on_tx_notify(self, connection, sender, object_path,
                      interface_name, signal_name, parameters):
        """Handle data received via TX characteristic notification."""
        iface = parameters.get_child_value(0).get_string()
        if iface != BLUEZ_GATT_CHAR_IFACE:
            return

        changed = parameters.get_child_value(1)
        value_var = changed.lookup_value('Value', GLib.VariantType.new('ay'))
        if value_var:
            data = bytes(value_var.unpack())
            if data:
                # Deduplicate: BlueZ sometimes delivers the same notification twice
                now = time.monotonic()
                data_hash = hash(data)
                for ts, dh in self._recent_rx:
                    if dh == data_hash and now - ts < 0.5:
                        return  # duplicate within 500ms, skip
                self._recent_rx.append((now, data_hash))

                log.debug(f'BLE RX [{len(data)} bytes]: {data[:20].hex()}...')
                if self.on_data_received:
                    self.on_data_received(data)

    def _on_device_properties_changed(self, connection, sender, object_path,
                                      interface_name, signal_name, parameters):
        """Handle device property changes (e.g., disconnect)."""
        # Only handle events for the currently connected device
        if object_path != self._device_path:
            return

        iface = parameters.get_child_value(0).get_string()
        if iface != BLUEZ_DEVICE_IFACE:
            return

        changed = parameters.get_child_value(1)
        connected_var = changed.lookup_value('Connected', GLib.VariantType.new('b'))
        if connected_var and not connected_var.get_boolean():
            log.info(f'Device {object_path} disconnected')
            self._cleanup()

    def send_data(self, data: bytes):
        """Queue data for sending via the RX characteristic (WriteValue).

        Writes are serialized: only one WriteValue is in-flight at a time.
        Subsequent calls are queued and drained as each write completes.
        """
        if not self._rx_char_path or self._state != ConnectionState.CONNECTED:
            log.warning(f'Cannot send: state={self._state.name}, rx_path={self._rx_char_path}')
            return

        if self._write_in_flight:
            self._write_queue.append(data)
            return

        self._do_write(data)

    def _do_write(self, data: bytes):
        """Send data immediately via BlueZ WriteValue."""
        self._write_in_flight = True
        self._write_current = data
        log.debug(f'BLE TX [{len(data)} bytes]: {data[:20].hex()}...')

        value = GLib.Variant('(aya{sv})', (
            list(data),
            {},
        ))

        self._call_bluez_async(
            self._rx_char_path, BLUEZ_GATT_CHAR_IFACE, 'WriteValue',
            parameters=value,
            callback=self._on_write_done
        )

    def _is_device_connected(self) -> bool:
        if not self._device_path:
            return False
        connected_var = self._get_property(
            self._device_path, BLUEZ_DEVICE_IFACE, 'Connected')
        return connected_var.get_boolean() if connected_var else False

    def _on_write_done(self, result, error):
        failed_data = self._write_current
        self._write_current = None
        self._write_in_flight = False
        if error:
            error_msg = str(error)
            log.error(f'Write failed: {error_msg}')
            if 'In Progress' in error_msg and failed_data is not None:
                log.info('Retrying write after In Progress error')
                self._write_queue.appendleft(failed_data)
                GLib.timeout_add(50, self._drain_write_queue)
                return
            if self._is_device_connected():
                if self._write_retries < _MAX_WRITE_RETRIES and failed_data is not None:
                    self._write_retries += 1
                    log.warning(f'Write error but device connected, retry '
                                f'{self._write_retries}/{_MAX_WRITE_RETRIES}')
                    self._write_queue.appendleft(failed_data)
                    GLib.timeout_add(_WRITE_RETRY_DELAY_MS, self._drain_write_queue)
                    return
                log.warning(f'Write failed after {_MAX_WRITE_RETRIES} retries, dropping frame')
                self._write_retries = 0
                GLib.idle_add(self._drain_write_queue)
                return
            log.warning('Write failure and device disconnected, cleaning up')
            self._cleanup()
            return
        self._write_retries = 0
        GLib.idle_add(self._drain_write_queue)

    def _drain_write_queue(self):
        """Send the next queued write if idle."""
        if self._write_in_flight or not self._write_queue:
            return False
        if not self._rx_char_path or self._state != ConnectionState.CONNECTED:
            self._write_queue.clear()
            return False
        self._do_write(self._write_queue.popleft())
        return False

    def _register_agent(self):
        """Register our Agent1 with BlueZ for handling pairing."""
        if self._agent_registered:
            return

        introspection = Gio.DBusNodeInfo.new_for_xml(AGENT_INTROSPECTION_XML)
        self._agent_reg_id = self._bus.register_object(
            AGENT_PATH,
            introspection.interfaces[0],
            self._on_agent_method_call,
            None, None,
        )

        result = self._call_bluez(
            '/org/bluez', BLUEZ_AGENT_MANAGER_IFACE, 'RegisterAgent',
            GLib.Variant('(os)', (AGENT_PATH, 'KeyboardDisplay'))
        )
        if result is None:
            # Might already be registered from a previous session,
            # re-register the D-Bus object but skip the BlueZ call
            log.warning('Agent registration call failed (may already be registered)')

        # Set as default agent so BlueZ always uses ours
        self._call_bluez(
            '/org/bluez', BLUEZ_AGENT_MANAGER_IFACE, 'RequestDefaultAgent',
            GLib.Variant('(o)', (AGENT_PATH,))
        )
        self._agent_registered = True
        log.info('BlueZ agent registered as default')

    def _unregister_agent(self):
        """Unregister our Agent1."""
        if not self._agent_registered:
            return
        self._call_bluez(
            '/org/bluez', BLUEZ_AGENT_MANAGER_IFACE, 'UnregisterAgent',
            GLib.Variant('(o)', (AGENT_PATH,))
        )
        if self._agent_reg_id:
            self._bus.unregister_object(self._agent_reg_id)
            self._agent_reg_id = None
        self._agent_registered = False
        log.info('BlueZ agent unregistered')

    def _set_device_trusted(self, device_path: str):
        """Mark a device as trusted in BlueZ."""
        log.info(f'Setting {device_path} as trusted')
        self._call_bluez(
            device_path, DBUS_PROPERTIES_IFACE, 'Set',
            GLib.Variant('(ssv)', (BLUEZ_DEVICE_IFACE, 'Trusted',
                                   GLib.Variant('b', True)))
        )

    def _on_agent_method_call(self, connection, sender, object_path,
                               interface_name, method_name, parameters,
                               invocation):
        """Handle Agent1 D-Bus method calls from BlueZ."""
        log.info(f'Agent1 call: {method_name} params={parameters}')

        if method_name == 'Release':
            invocation.return_value(None)

        elif method_name == 'RequestPasskey':
            device_path = parameters.get_child_value(0).get_string()
            log.info(f'Passkey requested for {device_path}')
            self._set_device_trusted(device_path)
            self._passkey_invocation = invocation
            if self.on_passkey_requested:
                self.on_passkey_requested(device_path, self._reply_passkey)
            else:
                invocation.return_value(GLib.Variant('(u)', (0,)))

        elif method_name == 'RequestPinCode':
            device_path = parameters.get_child_value(0).get_string()
            log.info(f'PIN code requested for {device_path}')
            self._set_device_trusted(device_path)
            self._passkey_invocation = invocation
            if self.on_passkey_requested:
                self.on_passkey_requested(device_path, self._reply_pincode)
            else:
                invocation.return_value(GLib.Variant('(s)', ('000000',)))

        elif method_name == 'RequestConfirmation':
            device_path = parameters.get_child_value(0).get_string()
            passkey = parameters.get_child_value(1).get_uint32()
            log.info(f'Confirmation requested for {device_path}: {passkey:06d}')
            self._set_device_trusted(device_path)
            invocation.return_value(None)

        elif method_name == 'DisplayPasskey':
            device_path = parameters.get_child_value(0).get_string()
            passkey = parameters.get_child_value(1).get_uint32()
            log.info(f'Display passkey for {device_path}: {passkey:06d}')
            invocation.return_value(None)

        elif method_name in ('RequestAuthorization', 'AuthorizeService'):
            device_path = parameters.get_child_value(0).get_string()
            self._set_device_trusted(device_path)
            invocation.return_value(None)

        elif method_name == 'Cancel':
            log.info('Pairing cancelled by BlueZ')
            self._passkey_invocation = None
            invocation.return_value(None)

        else:
            invocation.return_dbus_error(
                'org.bluez.Error.Rejected', 'Not implemented'
            )

    def _reply_passkey(self, passkey: int):
        """Reply to a pending RequestPasskey with the user-provided passkey."""
        if self._passkey_invocation:
            self._passkey_invocation.return_value(
                GLib.Variant('(u)', (passkey,))
            )
            self._passkey_invocation = None

    def _reply_pincode(self, passkey: int):
        """Reply to a pending RequestPinCode with the user-provided PIN."""
        if self._passkey_invocation:
            self._passkey_invocation.return_value(
                GLib.Variant('(s)', (f'{passkey:06d}',))
            )
            self._passkey_invocation = None

    def pair_device(self, device_path: str):
        """Initiate pairing with a device. Registers agent first,
        then calls Pair() which handles connect+auth+bond."""
        self._register_agent()

        self._set_state(ConnectionState.CONNECTING)
        self._device_path = device_path

        # Read address before pairing
        addr_var = self._get_property(device_path, BLUEZ_DEVICE_IFACE, 'Address')
        self._device_address = addr_var.get_string() if addr_var else None

        def _on_pair_done(result, error):
            if error:
                error_msg = str(error)
                if 'Already Exists' in error_msg:
                    log.info('Device already paired, connecting...')
                    self.connect_device(device_path)
                else:
                    log.error(f'Pairing failed: {error}')
                    self._set_state(ConnectionState.DISCONNECTED)
                return
            log.info(f'Pairing succeeded for {device_path}')
            self.connect_device(device_path)

        log.info(f'Calling Pair() on {device_path}')
        self._call_bluez_async(
            device_path, BLUEZ_DEVICE_IFACE, 'Pair',
            callback=_on_pair_done,
        )

    def is_bluetooth_enabled(self) -> bool:
        """Check if the Bluetooth adapter is powered on."""
        adapter_path = self._adapter_path or self._find_adapter()
        if not adapter_path:
            return False
        powered = self._get_property(adapter_path, BLUEZ_ADAPTER_IFACE, 'Powered')
        return powered.get_boolean() if powered else False

    def has_bluetooth_adapter(self) -> bool:
        """Check if a Bluetooth adapter exists."""
        return self._find_adapter() is not None

    def get_paired_devices(self) -> list[BleDevice]:
        """Get list of already-paired devices that have NUS service."""
        if not self._is_bluez_available():
            return []
        result = self._call_bluez(
            '/', DBUS_OBJECT_MANAGER_IFACE, 'GetManagedObjects',
            None, GLib.VariantType.new('(a{oa{sa{sv}}})')
        )
        if not result:
            return []

        devices = []
        objects = result.get_child_value(0)
        for i in range(objects.n_children()):
            entry = objects.get_child_value(i)
            path = entry.get_child_value(0).get_string()
            interfaces = entry.get_child_value(1)
            for j in range(interfaces.n_children()):
                iface_entry = interfaces.get_child_value(j)
                iface_name = iface_entry.get_child_value(0).get_string()
                if iface_name == BLUEZ_DEVICE_IFACE:
                    props = iface_entry.get_child_value(1)
                    address = name = ''
                    paired = False
                    uuids = []
                    for k in range(props.n_children()):
                        prop = props.get_child_value(k)
                        key = prop.get_child_value(0).get_string()
                        val = prop.get_child_value(1).get_variant()
                        if key == 'Address':
                            address = val.get_string()
                        elif key == 'Name':
                            name = val.get_string()
                        elif key == 'Alias' and not name:
                            name = val.get_string()
                        elif key == 'Paired':
                            paired = val.get_boolean()
                        elif key == 'UUIDs':
                            uuids = [val.get_child_value(m).get_string()
                                     for m in range(val.n_children())]
                    has_nus = NUS_SERVICE_UUID.lower() in [u.lower() for u in uuids]
                    if paired and has_nus and address:
                        devices.append(BleDevice(path=path, address=address,
                                                  name=name or address))
        return devices

    def remove_device(self, device_path: str) -> bool:
        """Remove (unpair) a Bluetooth device via Adapter1.RemoveDevice."""
        adapter = self._adapter_path or self._find_adapter()
        if not adapter:
            log.error('No Bluetooth adapter found for device removal')
            return False
        result = self._call_bluez(
            adapter, BLUEZ_ADAPTER_IFACE, 'RemoveDevice',
            GLib.Variant('(o)', (device_path,))
        )
        return result is not None

    def disconnect(self):
        """Disconnect from the current device."""
        if self._state in (ConnectionState.DISCONNECTED, ConnectionState.DISCONNECTING):
            return

        self._set_state(ConnectionState.DISCONNECTING)

        # Unsubscribe signals FIRST to prevent race conditions
        # when connecting to a different device
        if self._notify_signal_id:
            self._bus.signal_unsubscribe(self._notify_signal_id)
            self._notify_signal_id = None
        if self._disconnect_signal_id:
            self._bus.signal_unsubscribe(self._disconnect_signal_id)
            self._disconnect_signal_id = None

        if self._tx_char_path:
            self._call_bluez(
                self._tx_char_path, BLUEZ_GATT_CHAR_IFACE, 'StopNotify'
            )

        device_path = self._device_path
        self._tx_char_path = None
        self._rx_char_path = None
        self._device_path = None
        self._device_address = None
        self._write_queue.clear()
        self._write_in_flight = False
        self._write_current = None

        if device_path:
            self._call_bluez_async(
                device_path, BLUEZ_DEVICE_IFACE, 'Disconnect',
                callback=lambda r, e: self._set_state(ConnectionState.DISCONNECTED)
            )
        else:
            self._set_state(ConnectionState.DISCONNECTED)

    def _cleanup(self):
        """Clean up connection state."""
        if self._notify_signal_id:
            self._bus.signal_unsubscribe(self._notify_signal_id)
            self._notify_signal_id = None
        if self._disconnect_signal_id:
            self._bus.signal_unsubscribe(self._disconnect_signal_id)
            self._disconnect_signal_id = None
        self._tx_char_path = None
        self._rx_char_path = None
        self._device_path = None
        self._device_address = None
        self._write_queue.clear()
        self._write_in_flight = False
        self._write_current = None
        self._write_retries = 0
        self._set_state(ConnectionState.DISCONNECTED)
