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

"""CoreBluetooth BLE transport for MeshCore companion devices (macOS).

Uses the macOS CoreBluetooth framework to discover, connect, and communicate with
MeshCore devices via the Nordic UART Service (NUS).

This implementation uses PyObjC to interface with macOS CoreBluetooth APIs.
"""

import logging

from meshy.platforms.ble_base import (
    BleDevice,
    NUS_RX_CHAR_UUID,
    NUS_SERVICE_UUID,
    NUS_TX_CHAR_UUID,
)
from meshy.transport_base import ConnectionState

log = logging.getLogger(__name__)

# BLE connection timeouts and retry settings
_BLE_CONNECTION_TIMEOUT_S = 30

# Import CoreBluetooth framework via PyObjC
try:
    from Foundation import NSObject
    from CoreBluetooth import (
        CBUUID,
        CBCentralManager,
        CBCharacteristicWriteWithResponse,
        CBManagerStatePoweredOff,
        CBManagerStatePoweredOn,
        CBManagerStateUnauthorized,
        CBManagerStateUnknown,
        CBManagerStateUnsupported,
        CBPeripheral,
    )

    COREBLUETOOTH_AVAILABLE = True

except ImportError:
    log.warning("PyObjC CoreBluetooth not available - BLE will not work on macOS")
    COREBLUETOOTH_AVAILABLE = False
    NSObject = object  # Fallback for class definition


class BleTransportCoreBluetooth(NSObject):
    """BLE transport using CoreBluetooth framework (macOS).

    This class duck-types the BleTransportBase interface and extends NSObject
    to serve as a CoreBluetooth delegate. It does not inherit from BleTransportBase
    to avoid metaclass conflicts with NSObject.
    """

    def init(self):
        """Initialize the CoreBluetooth transport (Objective-C style required for PyObjC)."""
        if not COREBLUETOOTH_AVAILABLE:
            return None

        # Initialize parent NSObject using objc.super
        from objc import super as objc_super
        self = objc_super(BleTransportCoreBluetooth, self).init()
        if self is None:
            return None

        # Initialize instance variables
        self._state = ConnectionState.DISCONNECTED
        self._central_manager = None
        self._connected_peripheral = None
        self._discovered_devices = {}
        self._device_address = None
        self._tx_characteristic = None
        self._rx_characteristic = None
        self._connection_timeout_id = None  # GLib timeout source ID

        # Callbacks (from BleTransportBase interface)
        self.on_state_changed = None
        self.on_data_received = None
        self.on_device_discovered = None
        self.on_passkey_requested = None

        # Initialize CoreBluetooth central manager on main thread
        from gi.repository import GLib
        GLib.idle_add(self._init_central_manager)

        log.info("CoreBluetooth transport initialized")
        return self

    def _init_central_manager(self):
        """Initialize the CBCentralManager (must be called on main thread).

        Note: This will crash if called from a command-line Python process
        without a proper Info.plist containing NSBluetoothAlwaysUsageDescription.
        macOS's TCC (privacy system) requires this before any Bluetooth access.
        """
        if not COREBLUETOOTH_AVAILABLE:
            return False

        # Check if we're running in a proper app bundle with Info.plist
        # If not, skip BLE initialization to avoid TCC crash
        try:
            from Foundation import NSBundle, NSURL
            import sys
            import os

            # Try to find the .app bundle by walking up from the executable
            bundle = None
            executable_path = os.path.realpath(sys.argv[0])
            log.debug(f"Executable path: {executable_path}")

            # Check if we're inside a .app bundle
            # Look for any .app/Contents pattern (could be MacOS, Resources, etc.)
            if '.app/Contents/' in executable_path:
                # Extract the .app bundle path - find first .app and include it
                parts = executable_path.split('.app/Contents/')
                if len(parts) >= 2:
                    app_path = parts[0] + '.app'
                    log.debug(f"Detected .app bundle path: {app_path}")
                    if os.path.exists(app_path):
                        bundle_url = NSURL.fileURLWithPath_(app_path)
                        bundle = NSBundle.bundleWithURL_(bundle_url)
                        log.debug(f"Loaded bundle from: {app_path}")
                    else:
                        log.warning(f"App path does not exist: {app_path}")

            # Fallback to main bundle
            if not bundle:
                bundle = NSBundle.mainBundle()
                log.debug(f"Using main bundle: {bundle.bundlePath()}")

            info = bundle.infoDictionary()
            if info:
                has_bluetooth_key = "NSBluetoothAlwaysUsageDescription" in info
                log.debug(f"Bundle has Info.plist: YES, has Bluetooth key: {has_bluetooth_key}")
                if has_bluetooth_key:
                    log.debug(f"Bluetooth description: {info['NSBluetoothAlwaysUsageDescription']}")
            else:
                log.warning("Bundle has no Info.plist")

            # Check if NSBluetoothAlwaysUsageDescription exists in Info.plist
            if info and "NSBluetoothAlwaysUsageDescription" in info:
                log.info("Info.plist contains Bluetooth permissions, initializing CoreBluetooth")
                self._central_manager = CBCentralManager.alloc().initWithDelegate_queue_(self, None)
                log.info("CoreBluetooth initialized successfully")
            else:
                log.warning("No Info.plist with NSBluetoothAlwaysUsageDescription found")
                log.warning("BLE disabled - run from .app bundle for Bluetooth support")
                log.info("USB and TCP connectivity still available")
                # Don't initialize - would cause TCC crash
        except Exception as e:
            log.error(f"Failed to check for Info.plist: {e}", exc_info=True)
            log.warning("BLE disabled to prevent TCC crash")

        return False  # Don't repeat

    # ─── Properties (BleTransportBase interface) ───────────────────

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

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

    @property
    def connected_address(self) -> str | None:
        return self._device_address

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

    # ─── BLE Operations (BleTransportBase interface) ───────────────

    def start_scan(self):
        """Start scanning for BLE devices."""
        if not COREBLUETOOTH_AVAILABLE:
            log.error("CoreBluetooth not available")
            return

        if not self.is_bluetooth_enabled():
            log.error("Bluetooth is not enabled")
            return

        log.info("Starting BLE scan...")
        self._set_state(ConnectionState.SCANNING)
        self._discovered_devices.clear()

        if self._central_manager:
            # Scan for devices advertising the NUS service
            service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)]
            self._central_manager.scanForPeripheralsWithServices_options_(service_uuids, None)

    def stop_scan(self):
        """Stop scanning for BLE devices."""
        if self._central_manager:
            self._central_manager.stopScan()
        self._set_state(ConnectionState.DISCONNECTED)

    def connect_device(self, device_path: str):
        """Connect to a device by its path/identifier.

        device_path is the peripheral UUID string.
        """
        if not COREBLUETOOTH_AVAILABLE:
            log.error("CoreBluetooth not available")
            return

        # Find the peripheral in discovered devices
        peripheral = None
        for device in self._discovered_devices.values():
            if device.path == device_path:
                peripheral = device._peripheral  # Internal reference
                break

        # If not found in discovered devices, try to retrieve by UUID
        # (this handles paired devices that haven't been scanned this session)
        if not peripheral:
            log.info(f"Device not in discovered list, retrieving by UUID: {device_path}")
            try:
                from Foundation import NSUUID
                uuid = NSUUID.alloc().initWithUUIDString_(device_path)
                if uuid and self._central_manager:
                    peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_([uuid])
                    if peripherals and len(peripherals) > 0:
                        peripheral = peripherals[0]
                        log.info(f"Retrieved peripheral: {peripheral.name()}")
                    else:
                        log.error(f"Could not retrieve peripheral with UUID: {device_path}")
                        return
            except Exception as e:
                log.error(f"Failed to retrieve peripheral: {e}")
                return

        if not peripheral:
            log.error(f"Device {device_path} not found")
            return

        log.info(f"Connecting to device {device_path}...")
        self._set_state(ConnectionState.CONNECTING)
        self._device_address = device_path

        if self._central_manager:
            self._central_manager.connectPeripheral_options_(peripheral, None)

            # Start connection timeout
            from gi.repository import GLib
            self._cancel_connection_timeout()  # Cancel any existing timeout
            self._connection_timeout_id = GLib.timeout_add_seconds(
                _BLE_CONNECTION_TIMEOUT_S,
                self._on_connection_timeout
            )
            log.info(f"Connection timeout scheduled ({_BLE_CONNECTION_TIMEOUT_S} seconds)")

    def connect_by_address(self, address: str):
        """Connect to a device by its MAC address.

        On macOS, we use UUID instead of MAC address.
        This attempts to retrieve a known peripheral by UUID.
        """
        if not COREBLUETOOTH_AVAILABLE:
            log.error("CoreBluetooth not available")
            return

        if not self._central_manager:
            log.warning("CoreBluetooth manager not initialized yet - cannot connect by address")
            return

        try:
            from Foundation import NSUUID
            uuid = NSUUID.alloc().initWithUUIDString_(address)
            if not uuid:
                log.error(f"Invalid UUID: {address}")
                return

            # Retrieve peripheral by identifier
            peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_([uuid])
            if not peripherals or len(peripherals) == 0:
                log.error(f"No peripheral found with UUID {address}")
                return

            peripheral = peripherals[0]
            log.info(f"Connecting to peripheral {peripheral.name()}...")
            self._set_state(ConnectionState.CONNECTING)
            self._device_address = address
            self._central_manager.connectPeripheral_options_(peripheral, None)

            # Start connection timeout
            from gi.repository import GLib
            self._cancel_connection_timeout()  # Cancel any existing timeout
            self._connection_timeout_id = GLib.timeout_add_seconds(
                _BLE_CONNECTION_TIMEOUT_S,
                self._on_connection_timeout
            )
            log.info(f"Connection timeout scheduled ({_BLE_CONNECTION_TIMEOUT_S} seconds)")

        except Exception as e:
            log.error(f"Failed to connect by address: {e}")

    def pair_device(self, device_path: str):
        """Pair with a device (handled automatically by macOS)."""
        # On macOS, pairing is handled automatically by the system
        # Just connect to the device
        self.connect_device(device_path)

    def disconnect(self):
        """Disconnect from the current device."""
        self._cancel_connection_timeout()  # Cancel any pending timeout
        if self._connected_peripheral and self._central_manager:
            self._central_manager.cancelPeripheralConnection_(self._connected_peripheral)
        self._set_state(ConnectionState.DISCONNECTED)

    def _cancel_connection_timeout(self):
        """Cancel any pending connection timeout."""
        if self._connection_timeout_id is not None:
            from gi.repository import GLib
            GLib.source_remove(self._connection_timeout_id)
            self._connection_timeout_id = None
            log.debug("Connection timeout cancelled")

    def _on_connection_timeout(self):
        """Handle connection timeout."""
        if self._state == ConnectionState.CONNECTING:
            log.error(f"BLE connection timeout after {_BLE_CONNECTION_TIMEOUT_S} seconds")
            # Emit error to UI
            if self.on_state_changed:
                from gi.repository import GLib
                GLib.idle_add(self._emit_error, "Connection timeout - device not responding")
            # Force disconnect
            self.disconnect()
        self._connection_timeout_id = None
        return False  # Don't repeat timeout

    def _emit_error(self, message: str):
        """Emit error message via state changed callback."""
        # This is a workaround since BleTransportBase has on_error callback
        # but we're duck-typing the interface without inheriting
        log.error(f"Emitting error: {message}")
        if self.on_state_changed:
            self.on_state_changed(ConnectionState.DISCONNECTED)
        return False

    def send_data(self, data: bytes):
        """Send data to the connected device."""
        if not self.is_connected or not self._rx_characteristic:
            log.warning("Cannot send data: not connected")
            return

        if not self._connected_peripheral:
            log.warning("No connected peripheral")
            return

        # Convert bytes to NSData
        from Foundation import NSData
        ns_data = NSData.dataWithBytes_length_(data, len(data))

        # Write to RX characteristic (app -> device)
        self._connected_peripheral.writeValue_forCharacteristic_type_(
            ns_data,
            self._rx_characteristic,
            CBCharacteristicWriteWithResponse
        )
        log.debug(f"Sent {len(data)} bytes to device")

    def is_bluetooth_enabled(self) -> bool:
        """Check if Bluetooth is enabled."""
        if not self._central_manager:
            return False
        state = self._central_manager.state()
        return state == CBManagerStatePoweredOn

    def has_bluetooth_adapter(self) -> bool:
        """Check if a Bluetooth adapter is available."""
        if not self._central_manager:
            return False
        state = self._central_manager.state()
        return state != CBManagerStateUnsupported and state != CBManagerStateUnknown

    def get_paired_devices(self) -> list[BleDevice]:
        """Get list of paired devices that support NUS.

        Retrieves devices that have been successfully paired by checking:
        1. Saved device UUIDs from GSettings
        2. Currently connected peripherals with NUS service
        """
        if not self._central_manager:
            return []

        devices = []

        # Get saved paired device UUIDs from GSettings
        try:
            from gi.repository import Gio
            settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")
            saved_uuids = settings.get_value("ble-paired-devices")

            if saved_uuids and len(saved_uuids) > 0:
                from Foundation import NSUUID

                # Convert string UUIDs to NSUUID objects
                uuid_objects = []
                for uuid_str in saved_uuids:
                    uuid_obj = NSUUID.alloc().initWithUUIDString_(uuid_str)
                    if uuid_obj:
                        uuid_objects.append(uuid_obj)

                # Retrieve peripherals by their identifiers
                if uuid_objects:
                    peripherals = self._central_manager.retrievePeripheralsWithIdentifiers_(uuid_objects)

                    for peripheral in peripherals:
                        uuid = peripheral.identifier().UUIDString()
                        name = peripheral.name() or "Unknown Device"

                        device = BleDevice(
                            path=uuid,
                            address=uuid,
                            name=name
                        )
                        device._peripheral = peripheral
                        devices.append(device)

        except Exception as e:
            log.warning(f"Failed to retrieve saved paired devices: {e}")

        # Also check for currently connected peripherals with NUS service
        try:
            service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)]
            connected = self._central_manager.retrieveConnectedPeripheralsWithServices_(service_uuids)

            # Add connected devices if not already in list
            existing_uuids = {d.path for d in devices}
            for peripheral in connected:
                uuid = peripheral.identifier().UUIDString()
                if uuid not in existing_uuids:
                    name = peripheral.name() or "Unknown Device"
                    device = BleDevice(
                        path=uuid,
                        address=uuid,
                        name=name
                    )
                    device._peripheral = peripheral
                    devices.append(device)

        except Exception as e:
            log.warning(f"Failed to retrieve connected peripherals: {e}")

        return devices

    def remove_device(self, device_path: str) -> bool:
        """Remove/unpair a device (managed by system on macOS)."""
        # macOS manages pairing at the system level
        # We can only disconnect, not unpair
        log.info("Device pairing is managed by macOS System Settings")
        return False

    # ─── CoreBluetooth Delegate Methods ────────────────────────────

    def centralManagerDidUpdateState_(self, central):
        """Called when the central manager state changes."""
        state = central.state()
        log.info(f"CoreBluetooth state changed: {state}")

        if state == CBManagerStatePoweredOn:
            log.info("Bluetooth is powered on and ready")
            if self._state == ConnectionState.SCANNING:
                # Resume scanning if we were scanning before
                pass
        elif state == CBManagerStatePoweredOff:
            log.warning("Bluetooth is powered off")
        elif state == CBManagerStateUnauthorized:
            log.error("Bluetooth access not authorized")

    def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
        self, central, peripheral, advertisement_data, rssi
    ):
        """Called when a peripheral is discovered during scanning."""
        # Get peripheral UUID as identifier
        uuid = peripheral.identifier().UUIDString()
        name = peripheral.name() or "Unknown"

        log.info(f"Discovered peripheral: {name} ({uuid}), RSSI: {rssi}")

        # Create BleDevice
        device = BleDevice(
            path=uuid,
            address=uuid,  # macOS uses UUID, not MAC address
            name=name
        )
        # Store reference to peripheral for connection
        device._peripheral = peripheral

        self._discovered_devices[uuid] = device

        # Notify callback
        if self.on_device_discovered:
            from gi.repository import GLib
            GLib.idle_add(lambda: self.on_device_discovered(device) if self.on_device_discovered else None)

    def centralManager_didConnectPeripheral_(self, central, peripheral):
        """Called when a peripheral is successfully connected."""
        self._cancel_connection_timeout()  # Cancel timeout on successful connection
        log.info(f"Connected to peripheral: {peripheral.name()}")
        self._connected_peripheral = peripheral
        peripheral.setDelegate_(self)

        # Discover NUS service
        service_uuids = [CBUUID.UUIDWithString_(NUS_SERVICE_UUID)]
        peripheral.discoverServices_(service_uuids)

    def centralManager_didFailToConnectPeripheral_error_(self, central, peripheral, error):
        """Called when connection to a peripheral fails."""
        self._cancel_connection_timeout()  # Cancel timeout on connection failure
        log.error(f"Failed to connect to peripheral: {error}")
        self._set_state(ConnectionState.DISCONNECTED)

    def centralManager_didDisconnectPeripheral_error_(self, central, peripheral, error):
        """Called when a peripheral disconnects."""
        log.info(f"Peripheral disconnected: {error if error else 'user initiated'}")
        self._connected_peripheral = None
        self._tx_characteristic = None
        self._rx_characteristic = None
        self._set_state(ConnectionState.DISCONNECTED)

    # ─── CBPeripheralDelegate Methods ──────────────────────────────

    def peripheral_didDiscoverServices_(self, peripheral, error):
        """Called when services are discovered."""
        if error:
            log.error(f"Service discovery error: {error}")
            self._set_state(ConnectionState.DISCONNECTED)
            return

        # Find the NUS service
        nus_uuid = CBUUID.UUIDWithString_(NUS_SERVICE_UUID)
        nus_service = None

        for service in peripheral.services():
            if service.UUID().isEqual_(nus_uuid):
                nus_service = service
                break

        if not nus_service:
            log.error("NUS service not found on peripheral")
            self._set_state(ConnectionState.DISCONNECTED)
            self._central_manager.cancelPeripheralConnection_(peripheral)
            return

        log.info("NUS service found, discovering characteristics...")
        # Discover TX and RX characteristics
        char_uuids = [
            CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID),
            CBUUID.UUIDWithString_(NUS_RX_CHAR_UUID)
        ]
        peripheral.discoverCharacteristics_forService_(char_uuids, nus_service)

    def peripheral_didDiscoverCharacteristicsForService_error_(self, peripheral, service, error):
        """Called when characteristics are discovered."""
        if error:
            log.error(f"Characteristic discovery error: {error}")
            self._set_state(ConnectionState.DISCONNECTED)
            return

        tx_uuid = CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID)
        rx_uuid = CBUUID.UUIDWithString_(NUS_RX_CHAR_UUID)

        for characteristic in service.characteristics():
            if characteristic.UUID().isEqual_(tx_uuid):
                self._tx_characteristic = characteristic
                # Enable notifications for TX (device -> app)
                peripheral.setNotifyValue_forCharacteristic_(True, characteristic)
                log.info("Enabled notifications for TX characteristic")

            elif characteristic.UUID().isEqual_(rx_uuid):
                self._rx_characteristic = characteristic
                log.info("Found RX characteristic")

        # Check if we have both characteristics
        if self._tx_characteristic and self._rx_characteristic:
            log.info("NUS service ready, connection established")

            # Save this device as paired
            self._save_paired_device(peripheral)

            self._set_state(ConnectionState.CONNECTED)
        else:
            log.error("Failed to find required NUS characteristics")
            self._set_state(ConnectionState.DISCONNECTED)
            self._central_manager.cancelPeripheralConnection_(peripheral)

    def peripheral_didUpdateValueForCharacteristic_error_(self, peripheral, characteristic, error):
        """Called when a characteristic value is updated (notifications)."""
        if error:
            log.error(f"Characteristic update error: {error}")
            return

        # Check if this is the TX characteristic
        tx_uuid = CBUUID.UUIDWithString_(NUS_TX_CHAR_UUID)
        if not characteristic.UUID().isEqual_(tx_uuid):
            return

        # Read data from characteristic
        ns_data = characteristic.value()
        if not ns_data:
            return

        # Convert NSData to bytes
        data = bytes(ns_data.bytes()[:ns_data.length()])
        log.debug(f"Received {len(data)} bytes from device")

        # Notify callback on main thread
        if self.on_data_received:
            from gi.repository import GLib
            GLib.idle_add(lambda: self.on_data_received(data) if self.on_data_received else None)

    # ─── Helper Methods ─────────────────────────────────────────────

    def _set_state(self, state: ConnectionState):
        """Update connection state and notify callback."""
        if self._state != state:
            old = self._state
            self._state = state
            log.info(f"BLE state: {old.name} -> {state.name}")
            if self.on_state_changed:
                # Call on main thread via GLib
                from gi.repository import GLib

                GLib.idle_add(lambda: self.on_state_changed(state) if self.on_state_changed else None)

    def _save_paired_device(self, peripheral):
        """Save a successfully paired device UUID to persistent storage."""
        try:
            from gi.repository import Gio, GLib

            uuid = peripheral.identifier().UUIDString()
            settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")

            # Get existing paired devices
            saved_uuids = list(settings.get_value("ble-paired-devices"))

            # Add this device if not already saved
            if uuid not in saved_uuids:
                saved_uuids.append(uuid)
                settings.set_value("ble-paired-devices", GLib.Variant('as', saved_uuids))
                log.info(f"Saved paired device: {uuid}")
            else:
                log.debug(f"Device already in paired list: {uuid}")

        except Exception as e:
            log.error(f"Failed to save paired device: {e}")
