import BitLogger
import BitFoundation
import Foundation
import CoreBluetooth
import Combine
#if os(iOS)
import UIKit
#endif

/// BLEService — Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
final class BLEService: NSObject {
    
    // MARK: - Constants
    
    #if DEBUG
    static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
    #else
    static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
    #endif
    static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
    private static let centralRestorationID = "chat.bitchat.ble.central"
    private static let peripheralRestorationID = "chat.bitchat.ble.peripheral"
    
    // Default per-fragment chunk size when link limits are unknown
    private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
    private let bleMaxMTU = 512
    private let maxMessageLength = InputValidator.Limits.maxMessageLength
    private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
    // Flood/battery controls
    private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
    private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
    
    // MARK: - Core State (5 Essential Collections)

    // 1. Consolidated BLE link tracking for both central and peripheral roles.
    private var linkStateStore = BLELinkStateStore()

    // A peer ID can retain an established Noise session after its physical
    // link disappears. Courier handover therefore needs the stronger fact
    // that the session was established *on this current ingress link*, not
    // merely that some session exists for the claimed ID. bleQueue-owned.
    private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]

    // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
    // store): entries older than the cooldown are pruned on insert.
    private var lastLinkRebindAt: [String: Date] = [:]

    // Redundant-link retirement cooldown per peer (bleQueue-owned): bounds
    // how often a replayed announce could flip which duplicate link survives.
    private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:]

    // BCH-01-004: Rate-limiting for subscription-triggered announces.
    private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
    
    // 3. Peer Information (single source of truth)
    private var peerRegistry = BLEPeerRegistry()
    
    // 4. Efficient Message Deduplication
    private let messageDeduplicator = MessageDeduplicator()

    // Courier store-and-forward: envelopes this device carries for offline
    // third parties, and the trust gate for accepting deposits. The policy
    // maps (depositor key, announce-verified?) to a quota tier, or nil to
    // reject. Injectable for tests; main-actor policy because favorites live
    // on the main actor.
    var courierStore: CourierStore = .shared
    // Bulletin-board posts this device carries; injectable for tests.
    var boardStore: BoardStore = .shared
    var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
        if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
        return isVerifiedPeer ? .verified : nil
    }
    // Local-only store-and-forward counters; nil in unit tests.
    var sfMetrics: StoreAndForwardMetrics?

    // Verified one-time prekey bundles gossiped by other peers, used to seal
    // courier mail forward-secretly. Injectable for tests.
    var prekeyBundleStore: PrekeyBundleStore = .shared
    // Throttle for re-broadcasting our own (unchanged) bundle; guarded by
    // collectionsQueue barriers.
    private var lastPrekeyBundleSentAt: Date?
    // Prekey bundles that arrived before their owner's verified announce bound
    // a signing key. The receive queue is concurrent, so a bundle can race
    // ahead of the announce it depends on; we retain the latest such bundle per
    // owner (bounded) and re-attempt attribution when the announce lands.
    // Guarded by collectionsQueue barriers.
    private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
    private static let pendingPrekeyBundleCap = 64
    // Gateway mode: sink for received nostrCarrier packets (set by app
    // wiring, called on the main actor after transport-level checks) and the
    // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
    // for every announce. `directedToUs` distinguishes an uplink deposit
    // addressed to this device from a downlink broadcast.
    var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
    /// Fired (off-main) when a signature-verified announce is processed —
    /// the bridge courier watch refreshes its tag set on new arrivals.
    var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
    private var runtimeCapabilities: PeerCapabilities = []  // collectionsQueue
    private var localBridgeGeohash: String?  // collectionsQueue

    #if DEBUG
    // Test-only tap on the outbound pipeline so multi-node tests can ferry
    // packets between in-process service instances.
    var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
    #endif
    private var selfBroadcastTracker = BLESelfBroadcastTracker()
    private let meshTopology = MeshTopologyTracker()
    // Route health for originated source routes; guarded by collectionsQueue.
    private var sourceRouteFailures = BLESourceRouteFailureCache()

    // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
    // inbound ping budget — keyed by the ingress link (the directly connected
    // peer that delivered the packet), since the unsigned claimed sender is
    // spoofable — so a directed unencrypted probe cannot be turned into an
    // amplification primitive. Both are owned by collectionsQueue barriers
    // like the other mutable collections.
    private struct PendingMeshPing {
        let peerID: PeerID
        let sentAt: Date
        let completion: @MainActor (MeshPingResult?) -> Void
        let timeout: DispatchWorkItem
    }
    private var pendingMeshPings: [Data: PendingMeshPing] = [:]
    private var meshPingResponseLimiter = SyncResponseRateLimiter(
        maxResponses: TransportConfig.meshPingInboundMaxPerLink,
        window: TransportConfig.meshPingInboundWindowSeconds
    )

    // 5. Fragment Reassembly (necessary for messages > MTU)
    private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
    private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
    private let incomingFileStore = BLEIncomingFileStore()
    
    // Simple announce throttling
    private var announceThrottle = BLEAnnounceThrottle()
    
    // Application state tracking (thread-safe)
    #if os(iOS)
    private var isAppActive: Bool = true  // Assume active initially
    /// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the
    /// main thread, cached so bleQueue status logs can read it without ever
    /// dispatching to main (see `captureBluetoothStatus` for the invariant).
    private let backgroundTimeLock = NSLock()
    private var _cachedBackgroundTimeRemaining: TimeInterval = .greatestFiniteMagnitude
    private var cachedBackgroundTimeRemaining: TimeInterval {
        backgroundTimeLock.lock(); defer { backgroundTimeLock.unlock() }
        return _cachedBackgroundTimeRemaining
    }
    #endif
    
    // MARK: - Core BLE Objects
    
    private var centralManager: CBCentralManager?
    private var peripheralManager: CBPeripheralManager?
    private var characteristic: CBMutableCharacteristic?
    
    // MARK: - Identity
    
    private var noiseService: NoiseEncryptionService
    private let identityManager: SecureIdentityStateManagerProtocol
    private let keychain: KeychainManagerProtocol
    private let idBridge: NostrIdentityBridge
    /// Binary form of `myPeerID`; same contract — mutated only inside a
    /// `messageQueue` barrier via `refreshPeerIdentity()`.
    private var myPeerIDData: Data = Data()

    // MARK: - Advertising Privacy
    // No Local Name by default for maximum privacy. No rotating alias.
    
    // MARK: - Queues
    
    private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent)
    private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
    private let messageQueueKey = DispatchSpecificKey<Void>()
    private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
    private let bleQueueKey = DispatchSpecificKey<Void>()
    
    // Noise messages and typed payloads pending handshake completion.
    private var pendingNoiseSessionQueues = BLENoiseSessionQueues()
    // Queue for notifications that failed due to full queue
    private var pendingNotifications = BLEOutboundNotificationBuffer<CBCentral>()
    // Backpressure logging fires per fragment during media transfers
    // (hundreds of lines per image); sampled via this counter, which is
    // only touched inside collectionsQueue barriers (no sync needed).
    var notificationBackpressureLogCount = 0

    // Accumulate long write chunks per central until a full frame decodes
    private var pendingWriteBuffers = BLEInboundWriteBuffer()
    // Relay jitter scheduling to reduce redundant floods
    private var scheduledRelays = BLEScheduledRelayStore()
    // Track short-lived traffic bursts to adapt announces/scanning under load
    private var recentTrafficTracker = BLERecentTrafficTracker()

    // Ingress link tracking for duplicate and last-hop suppression
    private var ingressLinks = BLEIngressLinkRegistry()
    // Inner message IDs of recently opened courier envelopes. Redundant
    // copies of one message ride different envelopes (each seal uses a fresh
    // ephemeral key, and bridge drops multiply across relays/couriers), so
    // envelope-level dedup can't catch them; dedup on the inner ID before
    // delivery so a duplicate costs one decrypt instead of a delivery + ack
    // + handshake each. Owned by collectionsQueue barriers.
    private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap)
    private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5)

    private var pendingPeripheralWrites = BLEOutboundWriteBuffer()
    // Debounce duplicate disconnect notifies
    private var disconnectNotifyDebouncer = BLEPeerEventDebouncer()
    // Store-and-forward for directed messages when we have no links
    private var pendingDirectedRelays = BLEDirectedRelaySpool()
    // Debounce for 'reconnected' logs
    private var reconnectLogDebouncer = BLEPeerEventDebouncer()
    // Announce-packet orchestration (queue hops stay in the environment closures)
    private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment())
    // Public-message orchestration (queue hops stay in the environment closures)
    private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment())
    // Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures)
    private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment())
    // Fragment-assembly orchestration (queue hops stay in the environment closures)
    private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
    // File-transfer orchestration (queue hops stay in the environment closures)
    private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())

    // MARK: - Gossip Sync
    private var gossipSyncManager: GossipSyncManager?
    private let requestSyncManager = RequestSyncManager()
    
    // MARK: - Maintenance Timer
    
    private var maintenanceTimer: DispatchSourceTimer?  // Single timer for all maintenance tasks
    private var maintenanceCounter = 0  // Track maintenance cycles
    private var lastMaintenanceAt = Date.distantPast  // bleQueue-confined; drives background-wake catch-up passes
    /// Whether real CoreBluetooth managers were initialized. When false (unit
    /// tests), periodic mesh background work is not started — the maintenance
    /// timer and the gossip-sync timers only drain BLE writes/notifications,
    /// re-announce, and sign/broadcast sync packets, all meaningless without
    /// Bluetooth. Leaving them running in the test process is pure background
    /// churn that aggravates flaky exit hangs.
    private var meshBackgroundEnabled = false

    // MARK: - Connection budget & scheduling (central role)
    private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
    // Recently seen peripherals retained for background wake-on-proximity
    // connects (bleQueue-confined, like the link state store)
    private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()

    // MARK: - Adaptive scanning duty-cycle
    private var scanDutyTimer: DispatchSourceTimer?
    private var dutyEnabled: Bool = true
    private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
    private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
    private var dutyActive: Bool = false
    
    // Debounced publish to coalesce rapid changes
    private var peerPublishCoalescer = BLEPeerPublishCoalescer()
    private func requestPeerDataPublish() {
        switch peerPublishCoalescer.requestPublish(now: Date()) {
        case .publishNow:
            publishFullPeerData()
        case .schedule(let delay):
            messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
                guard let self = self else { return }
                self.peerPublishCoalescer.scheduledPublishFired(now: Date())
                self.publishFullPeerData()
            }
        case .skip:
            break
        }
    }
    
    // MARK: - Initialization
    
    init(
        keychain: KeychainManagerProtocol,
        idBridge: NostrIdentityBridge,
        identityManager: SecureIdentityStateManagerProtocol,
        initializeBluetoothManagers: Bool = true
    ) {
        self.keychain = keychain
        self.idBridge = idBridge
        noiseService = NoiseEncryptionService(keychain: keychain)
        self.identityManager = identityManager
        super.init()
        
        configureNoiseServiceCallbacks(for: noiseService)
        refreshPeerIdentity()
        
        // Set queue key for identification
        messageQueue.setSpecific(key: messageQueueKey, value: ())
        
        // Set up application state tracking (iOS only)
        #if os(iOS)
        // Check initial state on main thread. The background-budget cache is
        // seeded here too: a background-restore launch captures Bluetooth
        // status before any lifecycle notification fires, and the init-time
        // sentinel would log a meaningless bgRemaining=∞ for exactly the
        // wake window that matters.
        if Thread.isMainThread {
            isAppActive = UIApplication.shared.applicationState == .active
            refreshCachedBackgroundTimeRemaining()
        } else {
            DispatchQueue.main.sync {
                isAppActive = UIApplication.shared.applicationState == .active
                refreshCachedBackgroundTimeRemaining()
            }
        }
        
        // Observe application state changes
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appDidBecomeActive),
            name: UIApplication.didBecomeActiveNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appDidEnterBackground),
            name: UIApplication.didEnterBackgroundNotification,
            object: nil
        )
        #endif
        
        // Tag BLE queue for re-entrancy detection
        bleQueue.setSpecific(key: bleQueueKey, value: ())
        // Link state is owned exclusively by bleQueue; debug builds trap
        // any access from another queue (cross-queue reads use readLinkState).
        linkStateStore.assumeOwnership(of: bleQueue)

        if initializeBluetoothManagers {
            // Initialize BLE on background queue to prevent main thread blocking.
            #if os(iOS)
            let centralOptions: [String: Any] = [
                CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
            ]
            centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)

            let peripheralOptions: [String: Any] = [
                CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
            ]
            peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
            #else
            centralManager = CBCentralManager(delegate: self, queue: bleQueue)
            peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
            #endif
        }
        
        // Single maintenance timer for all periodic tasks (dispatch-based for
        // determinism). Only run it when real Bluetooth managers exist.
        meshBackgroundEnabled = initializeBluetoothManagers
        startMaintenanceTimer()

        // Publish initial empty state
        requestPeerDataPublish()

        // Initialize gossip sync manager
        restartGossipManager()
    }
    
    private func restartGossipManager() {
        // Stop existing
        gossipSyncManager?.stop()
        
        let config = GossipSyncManager.Config(
            seenCapacity: TransportConfig.syncSeenCapacity,
            gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
            gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
            maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
            publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
            maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
            stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
            stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
            fragmentCapacity: TransportConfig.syncFragmentCapacity,
            fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
            fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
            fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
            messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
            responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
            responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
            prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
            prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
            prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
        )

        // Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
        let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
        let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
        manager.delegate = self
        // Board posts sync from the board store (their retention owner) so
        // deleted/expired posts drop out of rounds immediately. Real sessions
        // only, matching the archive: unit tests stay hermetic.
        if meshBackgroundEnabled {
            manager.boardPacketsProvider = { [weak self] in
                self?.boardStore.syncCandidates() ?? []
            }
        }
        // Only start the periodic sync timers when real Bluetooth exists. In unit
        // tests there is no mesh to sync with, and the periodic sign/broadcast
        // churn just keeps the process busy and aggravates flaky exit hangs.
        if meshBackgroundEnabled {
            manager.start()
        }
        gossipSyncManager = manager
    }

    // No advertising policy to set; we never include Local Name in adverts.
    
    deinit {
        maintenanceTimer?.cancel()
        scanDutyTimer?.cancel()
        scanDutyTimer = nil
        centralManager?.stopScan()
        peripheralManager?.stopAdvertising()
        #if os(iOS)
        NotificationCenter.default.removeObserver(self)
        #endif
    }

    func resetIdentityForPanic(currentNickname: String) {
        messageQueue.sync(flags: .barrier) {
            pendingNoiseSessionQueues.removeAll()
        }

        let cancelledTransfers = collectionsQueue.sync(flags: .barrier) {
            pendingPeripheralWrites.removeAll()
            pendingNotifications.removeAll()
            let transfers = outboundFragmentTransfers.removeAll()
            fragmentAssemblyBuffer.removeAll()
            pendingDirectedRelays.removeAll()
            ingressLinks.removeAll()
            recentTrafficTracker.removeAll()
            scheduledRelays.cancelAll()
            // Let the post-panic identity publish its fresh bundle promptly.
            lastPrekeyBundleSentAt = nil
            return transfers
        }

        for entry in cancelledTransfers {
            entry.workItems.forEach { $0.cancel() }
            TransferProgressManager.shared.cancel(id: entry.id)
        }

        bleQueue.sync {
            pendingWriteBuffers.removeAll()
            connectionScheduler.reset()
        }
        disconnectNotifyDebouncer.removeAll()

        // The crypto-service replacement and the derived identity swap must be
        // one atomic unit with respect to messageQueue senders: a queued send
        // must never observe the new Noise service alongside the old peer ID
        // (it would sign with the new identity while carrying the old sender).
        // refreshPeerIdentity() executes inline here via its re-entrancy check.
        messageQueue.sync(flags: .barrier) {
            noiseService.clearEphemeralStateForPanic()
            noiseService.clearPersistentIdentity()

            let newNoise = NoiseEncryptionService(keychain: keychain)
            noiseService = newNoise
            configureNoiseServiceCallbacks(for: newNoise)
            refreshPeerIdentity()
        }
        restartGossipManager()

        setNickname(currentNickname)

        messageDeduplicator.reset()
        messageQueue.async(flags: .barrier) { [weak self] in
            self?.selfBroadcastTracker.removeAll()
        }
        requestPeerDataPublish()
        startServices()
    }
    
    // Ensure this runs on message queue to avoid main thread blocking
    func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {
        // Call directly if already on messageQueue, otherwise dispatch
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)
            }
            return
        }
        
        guard content.count <= maxMessageLength else {
            SecureLogger.error("Message too long: \(content.count) chars", category: .session)
            return
        }
        
        if let recipientID {
            sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)
            return
        }
        
        // Public broadcast
        // Create packet with explicit fields so we can sign it
        let sendDate = timestamp ?? Date()
        let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000)
        let basePacket = BitchatPacket(
            type: MessageType.message.rawValue,
            senderID: Data(hexString: myPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: sendTimestampMs,
            payload: Data(content.utf8),
            signature: nil,
            ttl: messageTTL
        )
        guard let signedPacket = noiseService.signPacket(basePacket) else {
            SecureLogger.error("❌ Failed to sign public message", category: .security)
            return
        }
        // Pre-mark our own broadcast as processed to avoid handling relayed self copy
        let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
        messageDeduplicator.markProcessed(dedupID)
        if let messageID {
            selfBroadcastTracker.record(messageID: messageID, packet: signedPacket, sentAt: sendDate)
        }
        // Call synchronously since we're already on background queue
        broadcastPacket(signedPacket)
        // Track our own broadcast for sync
        gossipSyncManager?.onPublicPacketSeen(signedPacket)
    }
    
    // MARK: - Transport Protocol Conformance

    // MARK: Delegates

    weak var delegate: BitchatDelegate?
    weak var eventDelegate: TransportEventDelegate?
    weak var peerEventsDelegate: TransportPeerEventsDelegate?

    func currentPeerSnapshots() -> [TransportPeerSnapshot] {
        collectionsQueue.sync {
            peerRegistry.transportSnapshots(selfNickname: myNickname)
        }
    }
    
    // MARK: Identity

    /// Derived from the Noise identity fingerprint; rotated only via
    /// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
    /// inside a `messageQueue` barrier so concurrent queue work never sees a
    /// half-updated identity. Externally read-only — no out-of-band mutation
    /// may bypass that derivation.
    private(set) var myPeerID = PeerID(str: "")
    /// Externally read-only; mutate via `setNickname(_:)`, which also
    /// broadcasts the change to peers.
    private(set) var myNickname: String = "anon"

    /// Sole mutator for `myNickname`: updates the stored value and force-sends
    /// an announce so peers learn the new name.
    func setNickname(_ nickname: String) {
        self.myNickname = nickname
        // Send announce to notify peers of nickname change (force send)
        sendAnnounce(forceSend: true)
    }
    
    // MARK: Lifecycle
    
    /// Creates and starts the periodic maintenance timer if it is not already
    /// running. Idempotent so it can be called from both `init` and
    /// `startServices()` — the latter matters after a panic reset, where
    /// `stopServices()` cancels and nils the timer.
    private func startMaintenanceTimer() {
        guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
        let timer = DispatchSource.makeTimerSource(queue: bleQueue)
        timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
                       repeating: TransportConfig.bleMaintenanceInterval,
                       leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
        timer.setEventHandler { [weak self] in
            self?.performMaintenance()
        }
        timer.resume()
        maintenanceTimer = timer
    }

    func startServices() {
        // Restart the maintenance timer if a prior stopServices() cancelled it
        // (e.g. the panic flow), otherwise periodic announces, peer reconciliation
        // and cache cleanup would never resume until app restart.
        startMaintenanceTimer()

        // Start BLE services if not already running
        if centralManager?.state == .poweredOn {
            centralManager?.scanForPeripherals(
                withServices: [BLEService.serviceUUID],
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
            )
        }
        
        // Send initial announce after services are ready
        // Use longer delay to avoid conflicts with other announces
        messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
            self?.sendAnnounce(forceSend: true)
        }
    }
    
    func stopServices() {
        // Send leave message synchronously to ensure delivery
        var leavePacket = BitchatPacket(
            type: MessageType.leave.rawValue,
            senderID: myPeerIDData,
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: Data(),
            signature: nil,
            ttl: messageTTL
        )

        if let signed = noiseService.signPacket(leavePacket) {
            leavePacket = signed
        }

        // Send immediately to all connected peers (synchronized access to BLE state)
        if let data = leavePacket.toBinaryData(padding: false) {
            let leavePriority = BLEOutboundPacketPolicy.priority(for: leavePacket, data: data)

            // Snapshot BLE state under bleQueue to avoid races with delegate callbacks
            let (peripheralStates, centralsCount, char) = bleQueue.sync {
                (linkStateStore.peripheralStates, linkStateStore.subscribedCentralCount, characteristic)
            }

            // Send to peripherals we're connected to as central
            for state in peripheralStates where state.isConnected {
                if let characteristic = state.characteristic {
                    writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)
                }
            }

            // Send to centrals subscribed to us as peripheral
            if centralsCount > 0, let ch = char {
                peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
            }
        }

        // Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)
        let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)
        while Date() < deadline {
            RunLoop.current.run(until: Date().addingTimeInterval(0.01))
        }

        // Clear pending notifications
        collectionsQueue.sync(flags: .barrier) {
            pendingNotifications.removeAll()
        }

        // Stop timer
        maintenanceTimer?.cancel()
        maintenanceTimer = nil
        scanDutyTimer?.cancel()
        scanDutyTimer = nil

        centralManager?.stopScan()
        peripheralManager?.stopAdvertising()

        // Disconnect all peripherals (synchronized access)
        let peripheralsToDisconnect = bleQueue.sync { linkStateStore.peripheralStates }
        for state in peripheralsToDisconnect {
            centralManager?.cancelPeripheralConnection(state.peripheral)
        }
    }
    
    func emergencyDisconnectAll() {
        stopServices()

        // Clear all sessions and peers
        let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
            let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
            peerRegistry.removeAll()
            fragmentAssemblyBuffer.removeAll()
            sourceRouteFailures = BLESourceRouteFailureCache()
            // Also clear pending message queues to avoid stale state across sessions
            pendingNoiseSessionQueues.removeAll()
            pendingDirectedRelays.removeAll()
            return entries
        }

        for entry in cancelledTransfers {
            entry.items.forEach { $0.cancel() }
            TransferProgressManager.shared.cancel(id: entry.id)
        }

        // Clear processed messages
        messageDeduplicator.reset()

        // Clear peripheral references (synchronized access to avoid races with BLE callbacks)
        bleQueue.sync {
            linkStateStore.clearAll()
            noiseAuthenticatedLinkOwners.removeAll()
            connectionScheduler.reset()
            subscriptionAnnounceLimiter.removeAll()
        }
        meshTopology.reset()
    }
    
    // MARK: Connectivity and peers
    
    func isPeerConnected(_ peerID: PeerID) -> Bool {
        // Accept both 16-hex short IDs and 64-hex Noise keys
        return collectionsQueue.sync { peerRegistry.isConnected(peerID) }
    }

    func isPeerReachable(_ peerID: PeerID) -> Bool {
        // Accept both 16-hex short IDs and 64-hex Noise keys
        return collectionsQueue.sync {
            peerRegistry.isReachable(peerID, now: Date())
        }
    }

    func canDeliverSecurely(to peerID: PeerID) -> Bool {
        // A live link binding alone is forgeable: the rotation heal rebinds a
        // link on a signature-verified "direct" announce, but directness rides
        // on the unsigned TTL, so a replayed announce can bind an absent
        // peer's ID to the replayer's link. An established Noise session
        // proves the other end of the link holds the peer's private key.
        //
        // Sessions are keyed by the short wire ID, so normalize like
        // isPeerConnected does — a send keyed by the full 64-hex Noise key
        // must not misread an established session as insecure.
        noiseService.hasEstablishedSession(with: peerID.toShort())
    }

    func peerNickname(peerID: PeerID) -> String? {
        collectionsQueue.sync {
            peerRegistry.nickname(for: peerID, connectedOnly: true)
        }
    }

    /// Capabilities the peer advertised in its last verified announce.
    /// Empty for peers that predate the capabilities TLV.
    func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities {
        collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
    }

    /// Enables or disables a runtime-advertised capability bit (e.g. the
    /// internet-gateway toggle) and re-announces so peers learn promptly.
    /// Build-time bits stay in `PeerCapabilities.localSupported`.
    func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
        let changed: Bool = collectionsQueue.sync(flags: .barrier) {
            let before = runtimeCapabilities
            if enabled {
                runtimeCapabilities.insert(capability)
            } else {
                runtimeCapabilities.remove(capability)
            }
            return runtimeCapabilities != before
        }
        guard changed else { return }
        sendAnnounce(forceSend: true)
    }

    /// Reachable peers currently advertising the `.gateway` capability.
    func reachableGatewayPeers() -> [PeerID] {
        let now = Date()
        return collectionsQueue.sync {
            peerRegistry.peers(advertising: .gateway)
                .filter { peerRegistry.isReachable($0, now: now) }
        }
    }

    /// Reachable peers currently advertising the `.bridge` capability.
    func reachableBridgePeers() -> [PeerID] {
        let now = Date()
        return collectionsQueue.sync {
            peerRegistry.peers(advertising: .bridge)
                .filter { peerRegistry.isReachable($0, now: now) }
        }
    }

    /// A rendezvous cell advertised by a bridge-capable peer's announce.
    func advertisedBridgeGeohash() -> String? {
        collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
    }

    /// The rendezvous cell this device advertises in its own announces while
    /// bridging with the gateway toggle on. Set from the main actor; the
    /// value rides the next (forced) announce.
    func setLocalBridgeGeohash(_ cell: String?) {
        let changed: Bool = collectionsQueue.sync(flags: .barrier) {
            guard localBridgeGeohash != cell else { return false }
            localBridgeGeohash = cell
            return true
        }
        guard changed else { return }
        sendAnnounce(forceSend: true)
    }

    func getPeerNicknames() -> [PeerID: String] {
        return collectionsQueue.sync {
            peerRegistry.displayNicknames(selfNickname: myNickname)
        }
    }
    
    // MARK: Protocol utilities
    
    func getFingerprint(for peerID: PeerID) -> String? {
        return collectionsQueue.sync {
            peerRegistry.fingerprint(for: peerID)
        }
    }
    
    func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
        if noiseService.hasEstablishedSession(with: peerID) {
            return .established
        } else if noiseService.hasSession(with: peerID) {
            return .handshaking
        } else {
            return .none
        }
    }
    
    func triggerHandshake(with peerID: PeerID) {
        // Callers are on the main actor; the handshake broadcast sync-waits
        // on bleQueue for link state, so hop off main first.
        messageQueue.async { [weak self] in
            self?.initiateNoiseHandshake(with: peerID)
        }
    }
    
    // MARK: Noise identity/session access (narrow Transport wrappers)

    func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
        noiseService.getPeerPublicKeyData(peerID)
    }

    func noiseIdentityFingerprint() -> String {
        noiseService.getIdentityFingerprint()
    }

    func noiseStaticPublicKeyData() -> Data {
        noiseService.getStaticPublicKeyData()
    }

    func noiseSigningPublicKeyData() -> Data {
        noiseService.getSigningPublicKeyData()
    }

    func noiseSignData(_ data: Data) -> Data? {
        noiseService.signData(data)
    }

    func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
        noiseService.verifySignature(signature, for: data, publicKey: publicKey)
    }

    func installNoiseSessionCallbacks(
        onPeerAuthenticated: @escaping (PeerID, String) -> Void,
        onHandshakeRequired: @escaping (PeerID) -> Void
    ) {
        // `onPeerAuthenticated` is additive (the encryption service keeps an
        // array of handlers); `onHandshakeRequired` is a single slot.
        noiseService.onPeerAuthenticated = onPeerAuthenticated
        noiseService.onHandshakeRequired = onHandshakeRequired
    }

    func getCurrentBluetoothState() -> CBManagerState {
        return centralManager?.state ?? .unknown
    }

    // MARK: Messaging

    func cancelTransfer(_ transferId: String) {
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }

            switch self.outboundFragmentTransfers.cancelTransfer(transferId) {
            case let .active(id, workItems):
                workItems.forEach { $0.cancel() }
                TransferProgressManager.shared.cancel(id: transferId)
                SecureLogger.debug("🛑 Cancelled transfer \(id.prefix(8))…", category: .session)
                self.messageQueue.async { [weak self] in
                    self?.startNextPendingTransferIfNeeded()
                }

            case let .pending(id):
                TransferProgressManager.shared.cancel(id: transferId)
                SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)

            case .missing:
                break
            }
        }
    }
    
    // Transport protocol conformance helper: simplified public message send
    func sendMessage(_ content: String, mentions: [String]) {
        // Delegate to the full API with default routing
        sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)
    }

    func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
        sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp)
    }
    
    func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
        sendPrivateMessage(content, to: peerID, messageID: messageID)
    }

    func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
        messageQueue.async { [weak self] in
            guard let self = self else { return }
            guard let payload = filePacket.encode() else {
                SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
                return
            }

            var packet = BitchatPacket(
                type: MessageType.fileTransfer.rawValue,
                senderID: self.myPeerIDData,
                recipientID: nil,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: payload,
                signature: nil,
                ttl: self.messageTTL,
                version: 2
            )

            if let signed = self.noiseService.signPacket(packet) {
                packet = signed
            } else {
                SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
                return
            }

            let senderHex = packet.senderID.hexEncodedString()
            let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
            self.messageDeduplicator.markProcessed(dedupID)

            SecureLogger.debug("📁 Broadcasting file transfer payload bytes=\(payload.count)", category: .session)
            self.broadcastPacket(packet, transferId: transferId)
            self.gossipSyncManager?.onPublicPacketSeen(packet)
        }
    }

    func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
        messageQueue.async { [weak self] in
            guard let self = self else { return }
            guard let payload = filePacket.encode() else {
                SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
                return
            }
            // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
            // This ensures 64-hex Noise keys are converted to the canonical routing format
            let targetID = peerID.toShort()
            guard let recipientData = Data(hexString: targetID.id) else {
                SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session)
                return
            }

            var packet = BitchatPacket(
                type: MessageType.fileTransfer.rawValue,
                senderID: self.myPeerIDData,
                recipientID: recipientData,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: payload,
                signature: nil,
                ttl: self.messageTTL,
                version: 2
            )

            if let signed = self.noiseService.signPacket(packet) {
                packet = signed
            }

            SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session)
            self.broadcastPacket(packet, transferId: transferId)
        }
    }

    
    func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
        // Hop like sendMessage: callers are often on the main actor, and the
        // send path sync-waits on bleQueue for link state — the main thread
        // must never block on bleQueue (see captureBluetoothStatus).
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.sendReadReceipt(receipt, to: peerID)
            }
            return
        }
        let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)

        if noiseService.hasEstablishedSession(with: peerID) {
            SecureLogger.debug("📤 Sending READ receipt id=\(receipt.originalMessageID.prefix(8))… to \(peerID.id.prefix(8))…", category: .session)
            do {
                broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
            } catch {
                SecureLogger.error("Failed to send read receipt: \(error)")
            }
        } else {
            // Queue for after handshake; initiate only while the peer is
            // around to answer (see sendDeliveryAck — absent senders must
            // not turn queued acks into handshake floods).
            collectionsQueue.sync(flags: .barrier) {
                pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
            }
            if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
                initiateNoiseHandshake(with: peerID)
            }
            SecureLogger.debug("🕒 Queued READ receipt for \(peerID.id.prefix(8))… until handshake completes", category: .session)
        }
    }
    
    private func acceptedIngressContext(
        for packet: BitchatPacket,
        claimedSenderID: PeerID,
        boundPeerID: PeerID?,
        linkDescription: String
    ) -> BLEIngressPacketContext? {
        switch BLEIngressPacketGuard.evaluate(
            packet: packet,
            claimedSenderID: claimedSenderID,
            boundPeerID: boundPeerID,
            localPeerID: myPeerID,
            directAnnounceTTL: messageTTL,
            isValidSyncResponse: { [requestSyncManager] peerID in
                requestSyncManager.isValidResponse(from: peerID, isRSR: true)
            }
        ) {
        case .success(let context):
            if packet.isRSR {
                logValidRSR(from: context.validationPeerID)
            }
            return context
        case .failure(.selfLoopback):
            logSelfLoopback(packetType: packet.type, linkDescription: linkDescription)
            return nil
        case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
            SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security)
            return nil
        case .failure(.invalidRSR(let peerID)):
            SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
            return nil
        case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
            SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
            return nil
        }
    }

    private func isAcceptedIngressPayload(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
        switch BLEIngressPacketGuard.validatePayload(
            packet,
            from: peerID,
            isValidSyncResponse: { [requestSyncManager] peerID in
                requestSyncManager.isValidResponse(from: peerID, isRSR: true)
            }
        ) {
        case .success:
            if packet.isRSR {
                logValidRSR(from: peerID)
            }
            return true
        case .failure(.invalidRSR(let peerID)):
            SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
            return false
        case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
            SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
            return false
        case .failure(.selfLoopback), .failure(.directSenderMismatch):
            return false
        }
    }

    private func logValidRSR(from peerID: PeerID) {
        guard logRateLimiter.shouldLog(key: "valid-rsr:\(peerID.id)") else { return }
        SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
    }

    private func logSelfLoopback(packetType: UInt8, linkDescription: String) {
        guard logRateLimiter.shouldLog(
            key: "self-loopback:\(packetType)",
            minimumInterval: 30
        ) else { return }
        SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packetType) from \(linkDescription)", category: .session)
    }

    private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool {
        return collectionsQueue.sync(flags: .barrier) {
            ingressLinks.recordIfNew(
                packet,
                link: link,
                peerID: peerID,
                lifetime: TransportConfig.bleIngressRecordLifetimeSeconds
            )
        }
    }

    // MARK: - Packet Broadcasting
    
    private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
        // Apply route if recipient exists (centralized route application)
        let packetToSend: BitchatPacket
        if let recipientPeerID = PeerID(hexData: packet.recipientID) {
            packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
        } else {
            packetToSend = packet
        }

        #if DEBUG
        _test_onOutboundPacket?(packetToSend)
        #endif
        
        // Encode once using a small per-type padding policy, then delegate by type
        let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
        if packetToSend.type == MessageType.fileTransfer.rawValue {
            sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
            return
        }
        guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
            SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
            return
        }
        if packetToSend.type == MessageType.noiseEncrypted.rawValue {
            sendEncrypted(packetToSend, data: data, pad: padForBLE)
            return
        }
        sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
    }

    private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
        guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }
        var sentEncrypted = false

        let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)

        // Per-link limits for the specific peer
        let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
        let recipientCentral = snapshotSubscribedCentrals().central(for: recipientPeerID)

        if let peripheralMaxLen = directPeripheralState?.peripheral.maximumWriteValueLength(for: .withoutResponse),
           data.count > peripheralMaxLen {
            let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: peripheralMaxLen)
            sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
            return
        }
        if let centralMaxLen = recipientCentral?.maximumUpdateValueLength,
           data.count > centralMaxLen {
            let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: centralMaxLen)
            sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
            return
        }

        // Direct write via peripheral link
        if let state = directPeripheralState,
           state.isConnected,
           let characteristic = state.characteristic {
            writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority)
            sentEncrypted = true
        }

        // Notify via central link (dual-role)
        if let characteristic = characteristic, !sentEncrypted, let recipientCentral {
            let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [recipientCentral]) ?? false
            if success {
                sentEncrypted = true
            } else {
                enqueuePendingNotification(data: data, centrals: [recipientCentral], context: "encrypted")
            }
        }

        if !sentEncrypted {
            // Flood as last resort with recipient set; link aware
            sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
        }
    }

    private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {
        sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)
    }

    private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            let result = self.pendingNotifications.enqueue(
                data: data,
                targets: centrals,
                capCount: TransportConfig.blePendingNotificationsCapCount
            )

            if case let .enqueued(count) = result {
                self.logBackpressureSampled("📋 Queued \(context) packet for retry (pending=\(count))")
                return
            }

            if attempt >= TransportConfig.bleNotificationRetryMaxAttempts {
                SecureLogger.error("❌ Dropping \(context) packet after exhausting retry window (pending=\(self.pendingNotifications.count))", category: .session)
                return
            }

            let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1)
            let deadline = DispatchTime.now() + .milliseconds(backoff)
            self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in
                self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1)
            }
        }
    }

    /// Synchronously admits a notification to the link-specific retry queue.
    /// Destructive courier handoff uses this result as its commit point, so a
    /// full process-local queue must be reported as rejection, not success.
    private func enqueuePendingNotificationIfAccepted(
        data: Data,
        centrals: [CBCentral],
        context: String
    ) -> Bool {
        let result = collectionsQueue.sync(flags: .barrier) {
            pendingNotifications.enqueue(
                data: data,
                targets: centrals,
                capCount: TransportConfig.blePendingNotificationsCapCount
            )
        }
        switch result {
        case let .enqueued(count):
            SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
            return true
        case let .full(count):
            SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
            return false
        }
    }

    /// Serializes the final authenticated-link check with CoreBluetooth's
    /// notification admission on `bleQueue`, closing the rebind/disconnect
    /// race between fanout planning and the actual handoff.
    private func notifyOrEnqueueIfAccepted(
        data: Data,
        centrals: [CBCentral],
        characteristic: CBMutableCharacteristic,
        context: String,
        requiredAuthenticatedPeer: PeerID?
    ) -> Bool {
        let accept = { [self] in
            let eligible: [CBCentral]
            if let peerID = requiredAuthenticatedPeer {
                eligible = centrals.filter { central in
                    let link = BLEIngressLinkID.central(central.identifier.uuidString)
                    return noiseAuthenticatedLinkOwners[link] == peerID
                        && linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
                }
            } else {
                eligible = centrals
            }
            guard !eligible.isEmpty else { return false }
            if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
                return true
            }
            return enqueuePendingNotificationIfAccepted(
                data: data,
                centrals: eligible,
                context: context
            )
        }

        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
            return accept()
        }
        return bleQueue.sync(execute: accept)
    }

    /// Returns true only when the packet was accepted by at least one current
    /// physical link (including its link-specific backpressure queue). A
    /// process-local directed spool is deliberately not success: callers
    /// that own a durable upstream copy must keep it retryable.
    @discardableResult
    private func sendOnAllLinks(
        packet: BitchatPacket,
        data: Data,
        pad: Bool,
        directedOnlyPeer: PeerID?,
        requireDirectPeerLink: Bool = false,
        requireNoiseAuthenticatedPeerLink: Bool = false
    ) -> Bool {
        let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
        var excludedPeerLinks = links(to: ingressRecord?.peerID)
        if requireNoiseAuthenticatedPeerLink {
            guard let directedOnlyPeer else { return false }
            let boundLinks = links(to: directedOnlyPeer)
            let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
            guard !authenticatedLinks.isEmpty else { return false }
            excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
        }
        let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)

        let states = snapshotPeripheralStates()
        // A link without a discovered characteristic cannot be written to
        // (the write loop below skips it); offering it to the planner only
        // wastes fanout slots — and a peer's single collapsed copy would be
        // silently dropped if its bound link is still mid-rediscovery.
        let connectedStates = states.filter { $0.isConnected && $0.characteristic != nil }
        let centralSnapshot = snapshotSubscribedCentrals()
        let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals
        let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString }
        let centralIDs = subscribedCentrals.map { $0.identifier.uuidString }
        let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in
            state.peerID.map { (state.peripheral.identifier.uuidString, $0) }
        })
        let plan = BLEOutboundLinkPlanner.plan(
            packet: packet,
            dataCount: data.count,
            peripheralIDs: connectedPeripheralIDs,
            peripheralWriteLimits: connectedStates.map { $0.peripheral.maximumWriteValueLength(for: .withoutResponse) },
            centralIDs: centralIDs,
            centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength },
            ingressRecord: ingressRecord,
            excludedLinks: excludedPeerLinks,
            peripheralPeerBindings: peripheralPeerBindings,
            centralPeerBindings: centralSnapshot.peerIDsByCentralUUID,
            // Perf note: this is a third bleQueue hop per send; if send-path
            // profiling ever flags it, fold it into snapshotPeripheralStates
            // as a combined snapshot.
            preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
            directAnnounceTTL: messageTTL,
            directedOnlyPeer: directedOnlyPeer,
            requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
        )

        if let chunk = plan.fragmentChunkSize {
            guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
                return false
            }
            return sendFragmentedPacket(
                packet,
                pad: pad,
                maxChunk: chunk,
                directedOnlyPeer: directedOnlyPeer,
                requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
                requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
            )
        }

        // If directed and we currently have no links to forward on, spool for a short window
        if let only = plan.directedPeerHint,
           plan.shouldSpoolDirectedPacket {
            spoolDirectedPacket(packet, recipientPeerID: only)
        }

        var acceptedByPhysicalLink = false

        // Writes to selected connected peripherals
        for s in connectedStates {
            let pid = s.peripheral.identifier.uuidString
            guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
            if let ch = s.characteristic {
                if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
                    acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
                        data,
                        to: s.peripheral,
                        characteristic: ch,
                        priority: outboundPriority,
                        requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
                    ) || acceptedByPhysicalLink
                } else {
                    writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
                }
            }
        }
        // Notify selected subscribed centrals
        if let ch = characteristic {
            let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
            if !targets.isEmpty {
                if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
                    acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
                        data: data,
                        centrals: targets,
                        characteristic: ch,
                        context: "directed",
                        requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
                    ) || acceptedByPhysicalLink
                } else {
                    let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
                    if !success {
                        // Notification queue full - queue for retry to prevent silent packet loss
                        // This is critical for fragment delivery reliability
                        let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
                        enqueuePendingNotification(data: data, centrals: targets, context: context)
                    }
                }
            }
        }
        if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
        return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
    }

    // Directed send helper (unicast to a specific peerID) without altering packet contents
    @discardableResult
    private func sendPacketDirected(
        _ packet: BitchatPacket,
        to peerID: PeerID,
        requireDirectPeerLink: Bool = false,
        requireNoiseAuthenticatedPeerLink: Bool = false
    ) -> Bool {
        #if DEBUG
        _test_onOutboundPacket?(packet)
        #endif
        guard let data = packet.toBinaryData(padding: false) else { return false }
        return sendOnAllLinks(
            packet: packet,
            data: data,
            pad: false,
            directedOnlyPeer: peerID,
            requireDirectPeerLink: requireDirectPeerLink,
            requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
        )
    }

    // MARK: - Directed store-and-forward
    private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {
        let msgID = BLEOutboundPacketPolicy.messageID(for: packet)
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            if self.pendingDirectedRelays.enqueue(
                packet: packet,
                recipient: recipientPeerID,
                messageID: msgID,
                enqueuedAt: Date()
            ) {
                SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
            }
        }
    }

    private func flushDirectedSpool() {
        // Move items out and attempt broadcast; if still no links, they'll be re-spooled
        let toSend = collectionsQueue.sync(flags: .barrier) {
            pendingDirectedRelays.drainUnexpired(
                now: Date(),
                window: TransportConfig.bleDirectedSpoolWindowSeconds
            )
        }
        guard !toSend.isEmpty else { return }
        for entry in toSend {
            messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) }
        }
    }

    private func signedSenderDisplayName(for packet: BitchatPacket, from peerID: PeerID) -> String? {
        guard let signature = packet.signature,
              let packetData = packet.toBinaryDataForSigning() else {
            return nil
        }

        let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
        for candidate in candidates {
            guard let signingKey = candidate.signingPublicKey,
                  noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) else {
                continue
            }

            if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
                return social.localPetname ?? social.claimedNickname
            }

            return BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
        }

        return nil
    }

    // MARK: - Archived public messages ("heard here earlier")

    func purgeArchivedPublicMessages(from peerID: PeerID) {
        gossipSyncManager?.removePublicMessages(from: peerID)
    }

    func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
        guard let sync = gossipSyncManager else {
            Task { @MainActor in completion([]) }
            return
        }
        sync.collectPublicMessagePackets { [weak self] packets in
            guard let self = self else {
                Task { @MainActor in completion([]) }
                return
            }
            // Signature verification and registry lookups run on messageQueue
            // like the live receive path.
            self.messageQueue.async {
                let decoded = packets
                    .compactMap { self.decodeArchivedPublicMessage($0) }
                    .sorted { $0.timestamp < $1.timestamp }
                Task { @MainActor in completion(decoded) }
            }
        }
    }

    private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
        guard packet.type == MessageType.message.rawValue,
              let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
        else { return nil }
        let senderPeerID = PeerID(hexData: packet.senderID)
        let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
        // Archived senders are usually long gone, so the signature-derived
        // identity is the best shot at a name; a live registry entry is
        // next; anonymous fallback matches the live path.
        let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
            ?? BLEPeerSenderDisplayName.resolveKnownPeer(
                peerID: senderPeerID,
                localPeerID: myPeerID,
                localNickname: myNickname,
                peers: peers,
                allowConnectedUnverified: false
            )
            ?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
        return ArchivedPublicMessage(
            packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
            senderPeerID: senderPeerID,
            senderNickname: nickname,
            content: content,
            timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
        )
    }

    private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
        fileTransferHandler.handle(packet, from: peerID)
    }

    /// Builds the file-transfer handler environment. All queue hops stay here
    /// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
    /// testable.
    private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
        BLEFileTransferHandlerEnvironment(
            localPeerID: { [weak self] in
                self?.myPeerID ?? PeerID(str: "")
            },
            localNickname: { [weak self] in
                self?.myNickname ?? ""
            },
            peersSnapshot: { [weak self] in
                guard let self = self else { return [:] }
                return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
            },
            verifyPacketSignature: { [weak self] packet, signingPublicKey in
                self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
            },
            signedSenderDisplayName: { [weak self] packet, peerID in
                self?.signedSenderDisplayName(for: packet, from: peerID)
            },
            trackPacketSeen: { [weak self] packet in
                self?.gossipSyncManager?.onPublicPacketSeen(packet)
            },
            enforceStorageQuota: { [weak self] reservingBytes in
                self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
            },
            saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
                self?.incomingFileStore.save(
                    data: data,
                    preferredName: preferredName,
                    subdirectory: subdirectory,
                    fallbackExtension: fallbackExtension,
                    defaultPrefix: defaultPrefix
                )
            },
            updatePeerLastSeen: { [weak self] peerID in
                self?.updatePeerLastSeen(peerID)
            },
            deliverMessage: { [weak self] message in
                // Single main-actor hop delivering `.messageReceived`.
                self?.emitTransportEvent(.messageReceived(message))
            }
        )
    }
    
    func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
        SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
        
        // Include Nostr public key in the notification
        var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
        var includesNostrIdentity = false
        
        // Add our Nostr public key if available
        if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
            content += ":" + myNostrIdentity.npub
            includesNostrIdentity = true
            SecureLogger.debug("📝 Favorite notification includes Nostr npub=\(myNostrIdentity.npub.prefix(16))…", category: .session)
        }
        
        SecureLogger.debug("📤 Sending favorite notification to \(peerID.id.prefix(8))… isFavorite=\(isFavorite) includesNostrIdentity=\(includesNostrIdentity)", category: .session)
        sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
    }
    
    func sendBroadcastAnnounce() {
        sendAnnounce()
    }
    
    func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
        // Hop like sendMessage: callers are often on the main actor, and the
        // send path sync-waits on bleQueue for link state — the main thread
        // must never block on bleQueue (see captureBluetoothStatus).
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.sendDeliveryAck(for: messageID, to: peerID)
            }
            return
        }
        let payload = BLENoisePayloadFactory.delivered(messageID: messageID)

        if noiseService.hasEstablishedSession(with: peerID) {
            do {
                broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
            } catch {
                SecureLogger.error("Failed to send delivery ACK: \(error)")
            }
        } else {
            // Queue for after handshake; initiate only while the peer is
            // around to answer — couriered/bridged mail routinely arrives
            // from absent (or rotated) identities, and every duplicate copy
            // initiating a handshake broadcast turns one undeliverable ack
            // into a mesh-wide flood. The queued ack flushes whenever a
            // session eventually establishes.
            collectionsQueue.sync(flags: .barrier) {
                pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
            }
            if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
                initiateNoiseHandshake(with: peerID)
            }
            SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID.id.prefix(8))… until handshake completes", category: .session)
        }
    }

    private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
        _ = collectionsQueue.sync(flags: .barrier) {
            // Remove the peer when they leave
            peerRegistry.remove(peerID)
        }
        // Remove any stored announcement for sync purposes
        gossipSyncManager?.removeAnnouncementForPeer(peerID)
        // Send on main thread
        notifyUI { [weak self] in
            guard let self = self else { return }
            
            // Get current peer list (after removal)
            let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
            
            self.deliverTransportEvent(.peerDisconnected(peerID))
            self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
        }
    }
    private func sendAnnounce(forceSend: Bool = false) {
        // Throttle announces to prevent flooding
        if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
            return
        }

        // Reduced logging - only log errors, not every announce
        
        // Create announce payload with both noise and signing public keys
        let noisePub = noiseService.getStaticPublicKeyData()  // For noise handshakes and peer identification
        let signingPub = noiseService.getSigningPublicKeyData()  // For signature verification
        
        let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
            (
                peerRegistry.connectedRoutingData,
                PeerCapabilities.localSupported.union(runtimeCapabilities),
                runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
            )
        }

        let announcement = AnnouncementPacket(
            nickname: myNickname,
            noisePublicKey: noisePub,
            signingPublicKey: signingPub,
            directNeighbors: connectedPeerIDs,
            capabilities: advertisedCapabilities,
            bridgeGeohash: advertisedBridgeCell
        )
        
        guard let payload = announcement.encode() else {
            SecureLogger.error("❌ Failed to encode announce packet", category: .session)
            return
        }
        
        // Create packet with signature using the noise private key
        let packet = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: myPeerIDData,
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil, // Will be set by signPacket below
            ttl: messageTTL
        )
        
        // Sign the packet using the noise private key
        guard let signedPacket = noiseService.signPacket(packet) else {
            SecureLogger.error("❌ Failed to sign announce packet", category: .security)
            return
        }
        
        // Call directly if on messageQueue, otherwise dispatch
        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
            broadcastPacket(signedPacket)
        } else {
            messageQueue.async { [weak self] in
                self?.broadcastPacket(signedPacket)
            }
        }
        // Ensure our own announce is included in sync state
        gossipSyncManager?.onPublicPacketSeen(signedPacket)

        // Keep our prekey bundle riding alongside presence (throttled; the
        // send is a no-op when the bundle was refreshed recently).
        sendPrekeyBundle()
    }

    // MARK: QR Verification over Noise
    
    // MARK: Private Groups

    /// Sends creator-signed group state (invite) 1:1 over the Noise session,
    /// queueing behind a handshake when none is established yet.
    func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
        sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
    }

    /// Sends creator-signed group state (key rotation / roster update) 1:1
    /// over the Noise session.
    func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
        sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
    }

    /// Broadcasts a sealed group message (MessageType 0x25) like a public
    /// message: fire-and-flood with gossip-sync backfill. The outer packet is
    /// intentionally unsigned — receivers authenticate the sender's Ed25519
    /// signature inside the ciphertext, which still verifies for backfilled
    /// copies long after the sender's announce has expired.
    func broadcastGroupMessage(_ envelope: Data) {
        guard !envelope.isEmpty else { return }
        messageQueue.async { [weak self] in
            guard let self else { return }
            let packet = BitchatPacket(
                type: MessageType.groupMessage.rawValue,
                senderID: Data(hexString: self.myPeerID.id) ?? Data(),
                recipientID: nil,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: envelope,
                signature: nil,
                ttl: self.messageTTL
            )
            // Pre-mark our own broadcast as processed to avoid handling a
            // relayed self copy.
            let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
            self.messageDeduplicator.markProcessed(dedupID)
            self.broadcastPacket(packet)
            // Track our own broadcast for gossip sync
            self.gossipSyncManager?.onPublicPacketSeen(packet)
        }
    }

    func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
        let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
        sendNoisePayload(payload, to: peerID)
    }

    func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
        guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
        sendNoisePayload(payload, to: peerID)
    }

    // MARK: Vouching over Noise

    func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
        sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
    }

    // MARK: Live Voice (PTT)

    /// Sends one live voice-burst packet inside the Noise session. Unlike
    /// `sendNoisePayload` this never queues behind a handshake: live audio is
    /// only useful now, so without an established session frames are dropped.
    func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
        messageQueue.async { [weak self] in
            guard let self else { return }
            guard self.noiseService.hasEstablishedSession(with: peerID) else {
                SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session)
                return
            }
            do {
                let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode()
                self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
            } catch {
                SecureLogger.error("Failed to send voice frame: \(error)", category: .session)
            }
        }
    }

    /// Broadcasts one live voice-burst packet to the public mesh, signed like
    /// a public message so receivers can authenticate the talker. Ephemeral:
    /// never tracked for gossip sync (stale audio is worthless to replay).
    func sendVoiceFrameBroadcast(_ burstContent: Data) {
        guard !burstContent.isEmpty else { return }
        messageQueue.async { [weak self] in
            guard let self else { return }
            let packet = BitchatPacket(
                type: MessageType.voiceFrame.rawValue,
                senderID: self.myPeerIDData,
                recipientID: nil,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: burstContent,
                signature: nil,
                ttl: self.messageTTL
            )
            guard let signedPacket = self.noiseService.signPacket(packet) else {
                SecureLogger.error("❌ Failed to sign voice frame", category: .security)
                return
            }
            // Pre-mark our own broadcast as processed to avoid handling a
            // relayed self copy.
            let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
            self.messageDeduplicator.markProcessed(dedupID)
            self.broadcastPacket(signedPacket)
        }
    }

    func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
        // Appends to the encryption service's handler array, so this never
        // displaces the callbacks installed by installNoiseSessionCallbacks.
        noiseService.addOnPeerAuthenticatedHandler(handler)
    }
}

// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
    func sendPacket(_ packet: BitchatPacket) {
        broadcastPacket(packet)
    }

    func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
        sendPacketDirected(packet, to: peerID)
    }

    func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
        return noiseService.signPacket(packet) ?? packet
    }
    
    func getConnectedPeers() -> [PeerID] {
        return collectionsQueue.sync {
            peerRegistry.connectedPeerIDs
        }
    }
}

// MARK: - CBCentralManagerDelegate

extension BLEService: CBCentralManagerDelegate {
    #if os(iOS)
    func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
        let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
        let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
        let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
        let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool

        SecureLogger.info(
            "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
            category: .session
        )

        for peripheral in restoredPeripherals {
            let identifier = peripheral.identifier.uuidString
            peripheral.delegate = self
            let existing = linkStateStore.state(forPeripheralID: identifier)
            let assembler = existing?.assembler ?? NotificationStreamAssembler()
            let characteristic = existing?.characteristic
            let peerID = existing?.peerID
            let wasConnecting = existing?.isConnecting ?? false
            let wasConnected = existing?.isConnected ?? false

            let restoredState = BLEPeripheralLinkState(
                peripheral: peripheral,
                characteristic: characteristic,
                peerID: peerID,
                isConnecting: wasConnecting || peripheral.state == .connecting,
                isConnected: wasConnected || peripheral.state == .connected,
                lastConnectionAttempt: existing?.lastConnectionAttempt,
                assembler: assembler
            )
            linkStateStore.setPeripheralState(restoredState, for: identifier)

            // Restored peripherals are the freshest wake-on-proximity
            // candidates we have after a relaunch — without this the cache
            // starts empty and backgrounding right after a restore arms
            // nothing. Service rediscovery for restored-connected links waits
            // for poweredOn: CoreBluetooth drops commands issued during
            // restoration (API MISUSE warnings).
            recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date())
        }

        // Via the sampler (not a direct capture): it refreshes the cached
        // background budget on main first, so the restore log shows the real
        // wake window instead of the init sentinel.
        logBluetoothStatus("central-restore")

        if central.state == .poweredOn {
            startScanning()
        }
    }
    #endif

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        emitTransportEvent(.bluetoothStateUpdated(central.state))

        switch central.state {
        case .poweredOn:
            // Links restored as connected have no characteristic in the new
            // process; without rediscovery they sit connected-but-unusable
            // until the peer disconnects. Runs here (not willRestoreState)
            // because commands issued before poweredOn are dropped.
            for state in linkStateStore.peripheralStates where state.isConnected
                && state.characteristic == nil
                && state.peripheral.state == .connected {
                SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session)
                state.peripheral.discoverServices([BLEService.serviceUUID])
            }

            // Start scanning - use allow duplicates for faster discovery when active
            startScanning()

        case .poweredOff:
            // Bluetooth was turned off - stop scanning and clean up connection state
            SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
            central.stopScan()
            // Mark all peripheral connections as disconnected (they are now invalid)
            let peripheralStates = linkStateStore.peripheralStates
            let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
            for state in peripheralStates {
                central.cancelPeripheralConnection(state.peripheral)
            }
            _ = linkStateStore.clearPeripherals()
            // Notify UI of disconnections
            for peerID in peerIDs {
                notifyUI { [weak self] in
                    self?.notifyPeerDisconnectedDebounced(peerID)
                }
            }

        case .unauthorized:
            // User denied Bluetooth permission
            SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
            central.stopScan()
            _ = linkStateStore.clearPeripherals()

        case .unsupported:
            // Device doesn't support BLE
            SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)

        case .resetting:
            // Bluetooth stack is resetting - will get another state update when done
            SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)

        case .unknown:
            // Initial state before we know the actual state
            SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)

        @unknown default:
            SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
        }
    }
    
    private func startScanning() {
        guard let central = centralManager,
              central.state == .poweredOn,
              !central.isScanning else { return }
        
        // Use allow duplicates = true for faster discovery in foreground
        // This gives us discovery events immediately instead of coalesced
        #if os(iOS)
        let allowDuplicates = isAppActive  // Use our tracked state (thread-safe)
        #else
        let allowDuplicates = true  // macOS doesn't have background restrictions
        #endif
        
        central.scanForPeripherals(
                withServices: [BLEService.serviceUUID],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
        )
        
        // Started BLE scanning
    }
    
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        let peripheralID = peripheral.identifier.uuidString
        let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
        let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
        let rssiValue = RSSI.intValue

        let candidate = BLEConnectionCandidate(
            peripheral: peripheral,
            peripheralID: peripheralID,
            rssi: rssiValue,
            name: String(advertisedName),
            isConnectable: isConnectable,
            discoveredAt: Date()
        )
        if isConnectable {
            recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
        }
        let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)

        switch connectionScheduler.handleDiscovery(
            candidate,
            connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
            existingState: existingState,
            peripheralState: peripheral.state.connectionSchedulerState,
            now: candidate.discoveredAt
        ) {
        case .ignore, .queued:
            return
        case .scheduleRetry(let delay):
            bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
                self?.tryConnectFromQueue()
            }
            return
        case .cancelStaleConnection:
            central.cancelPeripheralConnection(peripheral)
            return
        case .connectNow:
            beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
        }
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        let peripheralID = peripheral.identifier.uuidString

        #if os(iOS)
        // A connect completing while backgrounded is the wake-on-proximity
        // path doing its job — worth an info line for field verification.
        if !isAppActive {
            SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
        }
        #endif

        // Update state to connected
        linkStateStore.markConnected(peripheral)
        
        // Reset backoff state on success
        connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID)

        SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
        
        // Discover services
        peripheral.discoverServices([BLEService.serviceUUID])
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        let peripheralID = peripheral.identifier.uuidString
        
        // Find the peer ID if we have it
        let peerID = linkStateStore.peerID(forPeripheralID: peripheralID)
        
        SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)

        // If disconnect carried an error (often timeout), apply short backoff to avoid thrash
        if error != nil {
            connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
        }

        // Retain the handle: a dropped link is the best wake-on-proximity
        // candidate if the app backgrounds before the peer returns.
        recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date())

        #if os(iOS)
        // Link lost while backgrounded (peer walked away): re-arm a pending
        // connect during this wake window so the peer's return wakes us again.
        // Delayed past the disconnect-settle window to avoid reconnect thrash
        // at range edge.
        if !isAppActive {
            bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
                guard let self, !self.isAppActive else { return }
                // Reserve 0: use the slot this disconnect freed even in a
                // dense mesh, so the lost peer can wake us when it returns.
                self.armPendingBackgroundConnects(slotReserve: 0)
            }
        }
        #endif

        // Clean up references and peer mappings
        collectionsQueue.sync(flags: .barrier) {
            pendingPeripheralWrites.discardAll(for: peripheralID)
        }
        noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
        _ = linkStateStore.removePeripheral(peripheralID)
        // A duplicate link can drop while the peer stays live on another
        // (the dual-role central link, or a second bound link after a
        // restore): peer-disconnect bookkeeping only runs once the peer's
        // last live link is gone. removePeripheral just repaired the reverse
        // map onto a connected survivor, so directLinkState is accurate
        // here. The scan restart and connect-slot refill below stay
        // unguarded — they respond to the physical drop regardless of
        // remaining logical links.
        let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) }
        let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false)
        if let peerID, !peerStillLinked {
            // Do not remove peer; mark as not connected but retain for reachability
            collectionsQueue.sync(flags: .barrier) {
                peerRegistry.markDisconnected(peerID)
            }
            refreshLocalTopology()
        }


        // Restart scanning with allow duplicates for faster rediscovery
        if centralManager?.state == .poweredOn {
            // Stop and restart scanning to ensure we get fresh discovery events
            centralManager?.stopScan()
            bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
                self?.startScanning()
            }
        }
        // Attempt to fill freed slot from queue
        bleQueue.async { [weak self] in self?.tryConnectFromQueue() }

        // Notify delegate about disconnection on main thread (direct link dropped)
        notifyUI { [weak self] in
            guard let self = self else { return }

            // Get current peer list (after removal)
            let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }

            if let peerID, !peerStillLinked {
                self.notifyPeerDisconnectedDebounced(peerID)
            }
            self.requestPeerDataPublish()
            self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
        }
    }
    
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        let peripheralID = peripheral.identifier.uuidString
        
        // Clean up the references
        collectionsQueue.sync(flags: .barrier) {
            pendingPeripheralWrites.discardAll(for: peripheralID)
        }
        noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
        _ = linkStateStore.removePeripheral(peripheralID)
        
        SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
        connectionScheduler.recordConnectionFailure(peripheralID: peripheralID)
        // Try next candidate
        bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
    }
}

// MARK: - Connection scheduling helpers
private extension BLEExistingConnectionState {
    init(_ state: BLEPeripheralLinkState) {
        self.init(
            isConnecting: state.isConnecting,
            isConnected: state.isConnected,
            lastConnectionAttempt: state.lastConnectionAttempt
        )
    }
}

private extension CBPeripheralState {
    var connectionSchedulerState: BLEPeripheralConnectionState {
        switch self {
        case .connected:
            return .connected
        case .connecting:
            return .connecting
        case .disconnected, .disconnecting:
            return .disconnected
        @unknown default:
            return .disconnected
        }
    }
}

extension BLEService {
    private func tryConnectFromQueue() {
        guard let central = centralManager, central.state == .poweredOn else { return }

        let decision = connectionScheduler.nextCandidate(
            connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
            isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
                let state = linkStateStore.state(forPeripheralID: peripheralID)
                return state?.isConnected == true || state?.isConnecting == true
            },
            now: Date()
        )

        switch decision {
        case .none:
            return
        case .retryAfter(let delay):
            bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
        case .connect(let candidate):
            beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
        }
    }

    private func beginCentralConnection(
        _ candidate: BLEConnectionCandidate<CBPeripheral>,
        using central: CBCentralManager,
        logPrefix: String
    ) {
        let peripheral = candidate.peripheral
        let peripheralID = candidate.peripheralID
        linkStateStore.beginConnecting(to: peripheral, at: Date())
        peripheral.delegate = self
        let options: [String: Any] = [
            CBConnectPeripheralOptionNotifyOnConnectionKey: true,
            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
            CBConnectPeripheralOptionNotifyOnNotificationKey: true
        ]
        central.connect(peripheral, options: options)
        connectionScheduler.recordConnectionAttempt(at: Date())
        SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)

        bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
            guard let self = self,
                  let state = self.linkStateStore.state(forPeripheralID: peripheralID),
                  state.isConnecting && !state.isConnected else { return }

            guard peripheral.state != .connected else {
                SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
                return
            }

            #if os(iOS)
            if !self.isAppActive {
                // Backgrounded: leave the connect pending. iOS never expires
                // it — the controller completes it whenever the peer comes
                // back into range, waking the app (state restoration relaunches
                // us if we were terminated). Foreground return cancels stale
                // pendings via cancelStalePendingConnects().
                SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
                return
            }
            #endif

            SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
            central.cancelPeripheralConnection(peripheral)
            self.collectionsQueue.sync(flags: .barrier) {
                self.pendingPeripheralWrites.discardAll(for: peripheralID)
            }
            self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
            _ = self.linkStateStore.removePeripheral(peripheralID)
            self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
            self.tryConnectFromQueue()
        }
    }
}

private extension BLEService {
    static func shouldRediscoverBitChatService(
        invalidatedServiceUUIDs: [CBUUID],
        cachedServiceUUIDs: [CBUUID]?
    ) -> Bool {
        invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
    }
}

#if DEBUG
// Test-only helper to inject packets into the receive pipeline
extension BLEService {
    func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
        if preseedPeer {
            // Ensure the synthetic peer is known and marked verified for public-message tests
            let normalizedID = PeerID(hexData: packet.senderID)
            collectionsQueue.sync(flags: .barrier) {
                if var existing = peerRegistry.info(for: normalizedID) {
                    existing.isConnected = true
                    existing.isVerifiedNickname = true
                    if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
                    existing.lastSeen = Date()
                    peerRegistry.upsert(existing)
                } else {
                    peerRegistry.upsert(BLEPeerInfo(
                        peerID: normalizedID,
                        nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
                        isConnected: true,
                        noisePublicKey: packet.senderID,
                        signingPublicKey: signingPublicKey,
                        isVerifiedNickname: true,
                        lastSeen: Date()
                    ))
                }
            }
        }
        handleReceivedPacket(packet, from: fromPeerID)
    }

    /// Waits until fragment ingress already submitted by a test has finished
    /// reassembly/reinjection and any resulting transport event has crossed
    /// the MainActor delivery hop. This is a deterministic pipeline fence,
    /// avoiding wall-clock sleeps that become flaky under a parallel suite.
    func _test_drainFragmentPipeline() async {
        await withCheckedContinuation { continuation in
            messageQueue.async(flags: .barrier) {
                // Reassembled packets are reinjected synchronously on
                // `messageQueue`; their UI delivery task is therefore already
                // enqueued before this later MainActor marker.
                Task { @MainActor in
                    continuation.resume()
                }
            }
        }
    }

    func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
        gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
    }

    func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
        let claimedSenderID = PeerID(hexData: packet.senderID)
        guard case .success = BLEIngressLinkRegistry.packetContext(
            for: packet,
            claimedSenderID: claimedSenderID,
            boundPeerID: boundPeerID,
            localPeerID: myPeerID,
            directAnnounceTTL: messageTTL
        ) else {
            return false
        }
        return true
    }

    func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool {
        recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
    }

    func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
        bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
    }

    func _test_centralBinding(_ centralUUID: String) -> PeerID? {
        bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
    }

    func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
        bleQueue.sync {
            guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
            noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
        }
    }

    func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
        collectionsQueue.sync(flags: .barrier) {
            peerRegistry.upsert(BLEPeerInfo(
                peerID: peerID,
                nickname: nickname,
                isConnected: true,
                noisePublicKey: nil,
                signingPublicKey: nil,
                isVerifiedNickname: true,
                lastSeen: Date()
            ))
        }
    }

    /// Handshake plumbing for tests that need a real established Noise
    /// session (e.g. canDeliverSecurely) without Bluetooth in the loop.
    func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data {
        try noiseService.initiateHandshake(with: peerID)
    }

    func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
        try noiseService.processHandshakeMessage(from: peerID, message: message)
    }

    static func _test_shouldRediscoverBitChatService(
        invalidatedServiceUUIDs: [CBUUID],
        cachedServiceUUIDs: [CBUUID]?
    ) -> Bool {
        shouldRediscoverBitChatService(
            invalidatedServiceUUIDs: invalidatedServiceUUIDs,
            cachedServiceUUIDs: cachedServiceUUIDs
        )
    }
}
#endif

// MARK: - CBPeripheralDelegate

extension BLEService: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
            // Retry service discovery after a delay
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                guard peripheral.state == .connected else { return }
                peripheral.discoverServices([BLEService.serviceUUID])
            }
            return
        }
        
        guard let services = peripheral.services else {
            SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
            return
        }
        
        guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
            // Not a BitChat peer - disconnect
            centralManager?.cancelPeripheralConnection(peripheral)
            return
        }
        
        // Discovering BLE characteristics
        peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
            return
        }
        
        guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
            SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
            return
        }
        
        // Found characteristic
        
        // Log characteristic properties for debugging
        var properties: [String] = []
        if characteristic.properties.contains(.read) { properties.append("read") }
        if characteristic.properties.contains(.write) { properties.append("write") }
        if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
        if characteristic.properties.contains(.notify) { properties.append("notify") }
        if characteristic.properties.contains(.indicate) { properties.append("indicate") }
        // Characteristic properties: \(properties.joined(separator: ", "))
        
        // Verify characteristic supports reliable writes
        if !characteristic.properties.contains(.write) {
            SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
        }
        
        // Store characteristic in our consolidated structure
        let peripheralID = peripheral.identifier.uuidString
        linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
        
        // Subscribe for notifications
        if characteristic.properties.contains(.notify) {
            peripheral.setNotifyValue(true, for: characteristic)
            SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
            
            // Send announce after subscription is confirmed (force send for new connection)
            messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
                self?.sendAnnounce(forceSend: true)
                // Try flushing any spooled directed packets now that we have a link
                self?.flushDirectedSpool()
            }
        } else {
            SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
            return
        }
        
        guard let data = characteristic.value, !data.isEmpty else {
            SecureLogger.warning("⚠️ No data in notification", category: .session)
            return
        }

        bufferNotificationChunk(data, from: peripheral)
    }

    private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
        let peripheralUUID = peripheral.identifier.uuidString

        var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
            peripheral: peripheral,
            characteristic: nil,
            peerID: nil,
            isConnecting: false,
            isConnected: peripheral.state == .connected,
            lastConnectionAttempt: nil,
            assembler: NotificationStreamAssembler()
        )

        var assembler = state.assembler
        let result = assembler.append(chunk)
        state.assembler = assembler
        linkStateStore.setPeripheralState(state, for: peripheralUUID)

        for byte in result.droppedPrefixes {
            SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
        }

        if result.reset {
            SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
        }
        
        // Codex review identified TOCTOU in this patch.
        // Enforce per-link sender binding immediately within the same notification batch.
        // NOTE: `processNotificationPacket` may bind the stored peer ID when an announce
        // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
        // we see a binding-eligible announce so subsequent frames can't spoof a different sender.
        var boundPeerID: PeerID? = state.peerID

        for frame in result.frames {
            guard let packet = BinaryProtocol.decode(frame) else {
                let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
                SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
                continue
            }

            let claimedSenderID = PeerID(hexData: packet.senderID)
            let context = acceptedIngressContext(
                for: packet,
                claimedSenderID: claimedSenderID,
                boundPeerID: boundPeerID,
                linkDescription: "Peripheral \(peripheralUUID.prefix(8))…"
            )

            guard let context else { continue }

            // If this is a direct-link announce, bind immediately for the remainder of this batch.
            if boundPeerID == nil,
               packet.type == MessageType.announce.rawValue,
               packet.ttl == messageTTL {
                boundPeerID = claimedSenderID
                state.peerID = claimedSenderID
                linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID)
            }

            if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) {
                continue
            }
            processNotificationPacket(
                packet,
                from: peripheral,
                peripheralUUID: peripheralUUID,
                receivedFrom: context.receivedFromPeerID
            )
        }
    }

    private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
        let senderID = PeerID(hexData: packet.senderID)

        if packet.type != MessageType.announce.rawValue {
            SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
        }

        if packet.type == MessageType.announce.rawValue,
           packet.ttl == messageTTL {
            // Only bind an unbound link here: this runs before signature
            // verification, so a bound link must not be re-bound by a raw
            // announce (spoofable). Rotation rebinds happen after the announce
            // verifies (rebindLinkAfterVerifiedDirectAnnounce).
            let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID)
            if boundPeerID == nil || boundPeerID == senderID {
                linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
                refreshLocalTopology()
            }
        }

        handleReceivedPacket(packet, from: peerID)
    }
    
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
            // Don't retry - just log the error
        } else {
            SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
        }
    }
    
    func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
        // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
        if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
            SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
        }
        drainPendingWrites(for: peripheral)
    }
    
    func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
        SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)

        let shouldRediscover = BLEService.shouldRediscoverBitChatService(
            invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
            cachedServiceUUIDs: peripheral.services?.map(\.uuid)
        )

        guard shouldRediscover else { return }

        let peripheralID = peripheral.identifier.uuidString
        linkStateStore.updatePeripheral(peripheralID) {
            $0.characteristic = nil
            $0.assembler = NotificationStreamAssembler()
        }

        SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
        peripheral.discoverServices([BLEService.serviceUUID])
    }
    
    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
        } else {
            SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
            
            // If notifications are now on, send an announce to ensure this peer knows about us
            if characteristic.isNotifying {
                // Sending announce after subscription
                self.sendAnnounce(forceSend: true)
            }
        }
    }

}

// MARK: - CBPeripheralManagerDelegate

extension BLEService: CBPeripheralManagerDelegate {
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)

        switch peripheral.state {
        case .poweredOn:
            // Remove all services first to ensure clean state
            peripheral.removeAllServices()

            // Create characteristic
            characteristic = CBMutableCharacteristic(
                type: BLEService.characteristicUUID,
                properties: [.notify, .write, .writeWithoutResponse, .read],
                value: nil,
                permissions: [.readable, .writeable]
            )

            // Create service
            let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
            service.characteristics = [characteristic!]

            // Add service (advertising will start in didAdd delegate)
            SecureLogger.debug("🔧 Adding BLE service...", category: .session)
            peripheral.add(service)

        case .poweredOff:
            // Bluetooth was turned off - clean up peripheral state
            SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
            peripheral.stopAdvertising()
            // Clear subscribed centrals (they are now invalid)
            let centralPeerIDs = linkStateStore.clearCentrals()
            subscriptionAnnounceLimiter.removeAll()
            characteristic = nil
            // Notify UI of disconnections
            for peerID in centralPeerIDs {
                notifyUI { [weak self] in
                    self?.notifyPeerDisconnectedDebounced(peerID)
                }
            }

        case .unauthorized:
            // User denied Bluetooth permission
            SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
            peripheral.stopAdvertising()
            _ = linkStateStore.clearCentrals()
            subscriptionAnnounceLimiter.removeAll()
            characteristic = nil

        case .unsupported:
            // Device doesn't support BLE peripheral role
            SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)

        case .resetting:
            // Bluetooth stack is resetting
            SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)

        case .unknown:
            SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)

        @unknown default:
            SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
        }
    }
    
    #if os(iOS)
    func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
        let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
        let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]

        SecureLogger.info(
            "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
            category: .session
        )

        // Attempt to recover characteristic from restored services
        if characteristic == nil {
            if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
               let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
                characteristic = restoredCharacteristic
            }
        }

        // Via the sampler for a fresh background budget (see central-restore).
        logBluetoothStatus("peripheral-restore")

        if peripheral.state == .poweredOn && !peripheral.isAdvertising {
            peripheral.startAdvertising(buildAdvertisementData())
        }
    }
    #endif
    
    func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
        if let error = error {
            SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
            return
        }
        
        SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
        
        // Start advertising after service is confirmed added
        let adData = buildAdvertisementData()
        peripheral.startAdvertising(adData)
        
        SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
    }
    
    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
        let centralUUID = central.identifier.uuidString
        SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
        linkStateStore.addSubscribedCentral(central)

        // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
        let now = Date()
        switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
        case .allowed:
            break
        case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
            SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
            if suppressAnnounce {
                SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
                return
            }

            // Still flush directed packets for legitimate mesh operation
            messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
                self?.flushDirectedSpool()
            }
            return
        }

        // Send announce to the newly subscribed central after a small delay
        messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
            self?.sendAnnounce(forceSend: true)
            // Flush any spooled directed packets now that we have a central subscribed
            self?.flushDirectedSpool()
        }
    }
    
    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
        let centralID = central.identifier.uuidString
        SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session)
        collectionsQueue.sync(flags: .barrier) {
            pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
        }
        noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
        let removedPeerID = linkStateStore.removeSubscribedCentral(central)
        
        // Ensure we're still advertising for other devices to find us
        if peripheral.isAdvertising == false {
            SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
            peripheral.startAdvertising(buildAdvertisementData())
        }
        
        // Find and disconnect the peer associated with this central
        if let peerID = removedPeerID {
            // The remote side retiring a redundant duplicate connection
            // arrives here as an unsubscribe while the peer stays live on
            // its other links; only the peer's last link disconnecting
            // counts. If every link truly dropped, the surviving-link
            // callbacks (didDisconnectPeripheral, or this one again) run
            // the bookkeeping.
            guard linkStateStore.links(to: peerID).isEmpty else { return }
            // Mark peer as not connected; retain for reachability
            collectionsQueue.sync(flags: .barrier) {
                peerRegistry.markDisconnected(peerID)
            }
            
            refreshLocalTopology()
            
            // Update UI immediately
            notifyUI { [weak self] in
                guard let self = self else { return }
                
                // Get current peer list (after removal)
                let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
                
                self.notifyPeerDisconnectedDebounced(peerID)
                // Publish snapshots so UnifiedPeerService can refresh icons promptly
                self.requestPeerDataPublish()
                self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
            }
        }
    }
    
    func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
        drainPendingNotifications(logPrefix: "✅ Sent")
    }

    private func logBackpressureSampled(_ message: @autoclosure () -> String) {
        notificationBackpressureLogCount += 1
        if notificationBackpressureLogCount == 1 ||
            notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
            SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
        }
    }

    private func drainPendingNotifications(logPrefix: String) {
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self,
                  let characteristic = self.characteristic,
                  !self.pendingNotifications.isEmpty else { return }

            let pending = self.pendingNotifications.takeAll()
            let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)

            if sentCount > 0 {
                self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
            }
        }
    }

    private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
        var sentCount = 0

        for (index, notification) in pending.enumerated() {
            let success = peripheralManager?.updateValue(
                notification.data,
                for: characteristic,
                onSubscribedCentrals: notification.targets
            ) ?? false

            guard success else {
                let remaining = Array(pending.dropFirst(index))
                pendingNotifications.prepend(remaining)
                logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
                break
            }

            sentCount += 1
        }

        return sentCount
    }
    
    func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
        // Suppress logs for single write requests to reduce noise
        if requests.count > 1 {
            SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
        }
        
        // IMPORTANT: Respond immediately to prevent timeouts!
        // We must respond within a few milliseconds or the central will timeout
        for request in requests {
            peripheral.respond(to: request, withResult: .success)
        }
        
        // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
        // Combine per-central request values by offset before decoding.
        // Process directly on our message queue to match transport context
        let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
        for (centralUUID, group) in grouped {
            // Sort by offset ascending
            let sorted = group.sorted { $0.offset < $1.offset }
            let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
            let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
                guard let data = request.value, !data.isEmpty else { return nil }
                return BLEInboundWriteChunk(offset: request.offset, data: data)
            }

            let result = pendingWriteBuffers.append(
                chunks: chunks,
                for: centralUUID,
                capBytes: TransportConfig.blePendingWriteBufferCapBytes
            )

            switch result {
            case let .decoded(packet, metadata):
                logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
                processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)

            case let .waiting(metadata):
                logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
                logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)

            case let .oversized(metadata):
                logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
                SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session)
                logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
            }
        }
    }

    private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
        guard let packetType = metadata.packetType,
              packetType != MessageType.announce.rawValue else { return }

        SecureLogger.debug(
            "📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
            category: .session
        )
    }

    private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
        guard !hasMultiple, let raw = sortedRequests.first?.value else { return }

        let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
        SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
    }

    private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
        let claimedSenderID = PeerID(hexData: packet.senderID)
        let context = acceptedIngressContext(
            for: packet,
            claimedSenderID: claimedSenderID,
            boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID),
            linkDescription: "Central \(centralUUID.prefix(8))…"
        )
        guard let context else { return }

        if packet.type != MessageType.announce.rawValue {
            SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))…", category: .session)
        }

        linkStateStore.addSubscribedCentral(central)

        if packet.type == MessageType.announce.rawValue,
           packet.ttl == messageTTL {
            // Same rule as the peripheral path: raw announces only bind
            // unbound links; rotation rebinds require a verified announce.
            let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
            if boundPeerID == nil || boundPeerID == claimedSenderID {
                linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
                refreshLocalTopology()
            }
        }

        guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
            return
        }

        handleReceivedPacket(packet, from: context.receivedFromPeerID)
    }
}

// MARK: - Advertising Builders & Alias Rotation

extension BLEService {
    private func buildAdvertisementData() -> [String: Any] {
        let data: [String: Any] = [
            CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]
        ]
        // No Local Name for privacy
        return data
    }
    
    // No alias rotation or advertising restarts required.
}

// MARK: - Private Helpers

extension BLEService {
    
    /// Notify UI on the MainActor to satisfy Swift concurrency isolation
    private func notifyUI(_ block: @escaping @MainActor () -> Void) {
        // Always hop onto the MainActor so calls to @MainActor delegates are safe
        Task { @MainActor in
            block()
        }
    }

    private func emitTransportEvent(_ event: TransportEvent) {
        notifyUI { [weak self] in
            self?.deliverTransportEvent(event)
        }
    }

    @MainActor
    private func deliverTransportEvent(_ event: TransportEvent) {
        if let eventDelegate {
            eventDelegate.didReceiveTransportEvent(event)
        } else {
            delegate?.receiveTransportEvent(event)
        }
    }

    private func logBluetoothStatus(_ context: String) {
        scheduleBluetoothStatusSample(after: 0, context: context)
    }

    private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) {
        #if os(iOS)
        // Sample the main-actor background budget first (async hop, never a
        // sync wait), then log from bleQueue off the cache — bleQueue must
        // never block on main (see captureBluetoothStatus).
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
            guard let self else { return }
            self.refreshCachedBackgroundTimeRemaining()
            self.bleQueue.async { self.captureBluetoothStatus(context: context) }
        }
        #else
        bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
            self?.captureBluetoothStatus(context: context)
        }
        #endif
    }

    #if os(iOS)
    /// Main thread only (reads main-actor UIApplication state).
    private func refreshCachedBackgroundTimeRemaining() {
        dispatchPrecondition(condition: .onQueue(.main))
        let seconds = UIApplication.shared.backgroundTimeRemaining
        backgroundTimeLock.lock()
        _cachedBackgroundTimeRemaining = seconds
        backgroundTimeLock.unlock()
    }
    #endif

    private func captureBluetoothStatus(context: String) {
        assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue")

        let centralState = centralManager?.state ?? .unknown
        let isScanning = centralManager?.isScanning ?? false
        let peripheralState = peripheralManager?.state ?? .unknown
        let isAdvertising = peripheralManager?.isAdvertising ?? false

        let peerSummary = collectionsQueue.sync {
            (
                connected: peerRegistry.connectedCount,
                known: peerRegistry.count,
                candidates: connectionScheduler.candidateCount
            )
        }

        #if os(iOS)
        // INVARIANT: bleQueue must NEVER sync-dispatch to the main thread.
        // The main actor sync-waits on bleQueue along the send paths
        // (readLinkState), so a main.sync here completes an ABBA deadlock —
        // field-verified as a permanent freeze when a courier-drop storm put
        // an ack send (main → bleQueue.sync) up against a status capture
        // (bleQueue → main.sync). backgroundTimeRemaining is main-actor
        // state, so it is sampled on main and cached.
        let backgroundSeconds = cachedBackgroundTimeRemaining
        let backgroundDescriptor: String
        if backgroundSeconds == .greatestFiniteMagnitude {
            backgroundDescriptor = " bgRemaining=∞"
        } else {
            backgroundDescriptor = String(format: " bgRemaining=%.1fs", backgroundSeconds)
        }
        let appPhase = isAppActive ? "foreground" : "background"
        #else
        let backgroundDescriptor = ""
        let appPhase = "foreground"
        #endif

        SecureLogger.info(
            "📊 BLE status [\(context)]: phase=\(appPhase) central=\(centralState) scanning=\(isScanning) peripheral=\(peripheralState) advertising=\(isAdvertising) connected=\(peerSummary.connected) known=\(peerSummary.known) candidates=\(peerSummary.candidates)\(backgroundDescriptor)",
            category: .session
        )
    }

    private func routingData(for peerID: PeerID) -> Data? {
        peerID.toShort().routingData
    }

    private func refreshLocalTopology() {
        let neighbors: [Data] = collectionsQueue.sync {
            peerRegistry.connectedRoutingData
        }
        meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors)
    }

    private func computeRoute(to peerID: PeerID) -> [Data]? {
        // Version-gated: every hop and the recipient must have been observed
        // speaking v2, since a v1-only node drops v2 frames on decode.
        meshTopology.computeRoute(
            from: myPeerIDData,
            to: routingData(for: peerID),
            maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops,
            requiringVersion: 2
        )
    }

    private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
        let now = Date()
        let route = BLESourceRouteOriginationPolicy.route(
            for: packet,
            to: recipient,
            localPeerIDData: myPeerIDData,
            isRecipientConnected: { self.isPeerConnected($0) },
            shouldAttemptRoute: { peer in
                self.collectionsQueue.sync(flags: .barrier) {
                    self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now)
                }
            },
            computeRoute: { self.computeRoute(to: $0) }
        )
        guard let route else { return packet }
        // Create new packet with route applied and version upgraded to 2
        let routedPacket = BitchatPacket(
            type: packet.type,
            senderID: packet.senderID,
            recipientID: packet.recipientID,
            timestamp: packet.timestamp,
            payload: packet.payload,
            signature: nil, // Will be re-signed below
            ttl: packet.ttl,
            version: 2,
            route: route
        )
        // Re-sign the packet since route and version changed
        guard let signedPacket = noiseService.signPacket(routedPacket) else {
            SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
            return packet // Return original packet if signing fails
        }
        collectionsQueue.sync(flags: .barrier) {
            sourceRouteFailures.noteRoutedSend(to: recipient, now: now)
        }
        return signedPacket
    }

    private func routingPeer(from data: Data) -> PeerID? {
        PeerID(routingData: data)
    }

    // MARK: - Mesh Diagnostics (/ping, /trace, topology map)

    /// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
    /// The completion fires exactly once on the main actor: with RTT/hops
    /// when the matching pong returns, or nil after the timeout window.
    func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
        messageQueue.async { [weak self] in
            guard let self,
                  let recipientData = peerID.toShort().routingData,
                  let payload = MeshPingPayload(
                    nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
                    originTTL: self.messageTTL
                  ) else {
                Task { @MainActor in completion(nil) }
                return
            }
            let nonce = payload.nonce
            let packet = BitchatPacket(
                type: MessageType.ping.rawValue,
                senderID: self.myPeerIDData,
                recipientID: recipientData,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: payload.encode(),
                signature: nil,
                ttl: self.messageTTL
            )
            let timeout = DispatchWorkItem { [weak self] in
                guard let self else { return }
                let expired = self.collectionsQueue.sync(flags: .barrier) {
                    self.pendingMeshPings.removeValue(forKey: nonce)
                }
                guard let expired else { return }
                Task { @MainActor in expired.completion(nil) }
            }
            self.collectionsQueue.sync(flags: .barrier) {
                self.pendingMeshPings[nonce] = PendingMeshPing(
                    peerID: PeerID(hexData: recipientData),
                    sentAt: Date(),
                    completion: completion,
                    timeout: timeout
                )
            }
            self.messageQueue.asyncAfter(
                deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
                execute: timeout
            )
            self.broadcastPacket(packet)
        }
    }

    /// Answers a ping addressed to us with a pong echoing its nonce; pings
    /// addressed elsewhere are left to the generic directed-relay path.
    ///
    /// `linkPeerID` is the directly connected peer that delivered the packet
    /// (the ingress link), NOT the packet's claimed sender: pings are
    /// unsigned, so `packet.senderID` is attacker-controlled, and keying the
    /// response budget on it would let one connected peer rotate forged
    /// sender IDs to emit unbounded pongs. The budget is per physical link;
    /// the pong still goes to the claimed sender (that's the protocol).
    private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
        guard packet.recipientID == myPeerIDData else { return }
        guard let ping = MeshPingPayload.decode(packet.payload) else {
            SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
            return
        }
        let allowed = collectionsQueue.sync(flags: .barrier) {
            meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
        }
        guard allowed else {
            if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
                SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
            }
            return
        }
        guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
        let reply = BitchatPacket(
            type: MessageType.pong.rawValue,
            senderID: myPeerIDData,
            recipientID: packet.senderID,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: pong.encode(),
            signature: nil,
            ttl: messageTTL
        )
        broadcastPacket(reply)
    }

    /// Resolves a pong against its outstanding probe. The unguessable echoed
    /// nonce plus the sender check bind the reply to the probed peer; hops
    /// come from the pong's TTL decrements on the return path.
    private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
        guard packet.recipientID == myPeerIDData else { return }
        guard let pong = MeshPingPayload.decode(packet.payload) else { return }
        let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
            guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
            return pendingMeshPings.removeValue(forKey: pong.nonce)
        }
        guard let pending else { return }
        pending.timeout.cancel()
        let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
        let result = MeshPingResult(
            rttMs: max(0, rttMs),
            hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
        )
        Task { @MainActor in pending.completion(result) }
    }

    /// Estimated intermediate hops toward `peerID`, BFS over gossiped
    /// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
    func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
        refreshLocalTopology()
        if let route = computeRoute(to: peerID) {
            return route.compactMap { PeerID(routingData: $0) }
        }
        // Confirmed claims can lag a brand-new link (the peer's next announce
        // hasn't arrived yet); a live direct connection is still a known path.
        return isPeerConnected(peerID) ? [] : nil
    }

    /// Mesh graph for the topology map. Edges are advisory: announces cap
    /// neighbor lists at 10, so an edge claimed by either endpoint counts.
    func currentMeshTopology() -> MeshTopologySnapshot? {
        refreshLocalTopology()
        let claims = meshTopology.adjacencySnapshot()
        var nodes = Set<PeerID>()
        var edges = Set<MeshTopologyEdge>()
        for (source, neighbors) in claims {
            guard let sourcePeer = PeerID(routingData: source) else { continue }
            nodes.insert(sourcePeer)
            for neighborData in neighbors {
                guard let neighborPeer = PeerID(routingData: neighborData),
                      neighborPeer != sourcePeer else { continue }
                nodes.insert(neighborPeer)
                edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
            }
        }
        nodes.insert(myPeerID)
        return MeshTopologySnapshot(
            localPeerID: myPeerID,
            nodes: nodes.sorted(),
            edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
        )
    }

    private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
        let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
        let plan = BLERouteForwardingPolicy.plan(
            for: packet,
            localPeerID: myPeerID,
            localRoutingData: myRoutingData,
            routingPeer: routingPeer(from:),
            isPeerConnected: isPeerConnected(_:)
        )

        if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop {
            sendPacketDirected(forwardPacket, to: nextHop)
        }

        return plan.shouldSuppressFloodRelay
    }

    /// Safely fetch the current direct-link state for a peer using the BLE queue.
    private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
        let state = readLinkState { $0.directLinkState(for: peerID) }
        return (state.hasPeripheral, state.hasCentral)
    }

    private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
        readLinkState { $0.links(to: peerID) }
    }

    private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
        switch link {
        case .peripheral(let peripheralUUID):
            store.peerID(forPeripheralID: peripheralUUID)
        case .central(let centralUUID):
            store.peerID(forCentralUUID: centralUUID)
        }
    }

    /// Marks the exact physical ingress link that completed a fresh Noise
    /// handshake. An old session keyed only by peer ID is insufficient: a
    /// replayed announce can rebind an attacker's link to that ID.
    private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
        guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
        readLinkState { store in
            guard boundPeerID(for: link, in: store) == peerID else { return }
            noiseAuthenticatedLinkOwners[link] = peerID
        }
    }

    private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
        guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
        return readLinkState { store in
            noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
        }
    }

    private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
        !currentNoiseAuthenticatedLinks(to: peerID).isEmpty
    }

    private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
        readLinkState { store in
            Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
                owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
            })
        }
    }
    
    private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
        service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
            SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
            self?.messageQueue.async { [weak self] in
                self?.sendPendingMessagesAfterHandshake(for: peerID)
                self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
            }
            self?.messageQueue.async { [weak self] in
                self?.sendAnnounce(forceSend: true)
            }
        }
    }

    /// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
    /// The swap runs as a `messageQueue` barrier so in-flight work items that
    /// read the identity (e.g. `sendMessage` building packets) complete
    /// against the old value and everything after sees the new one atomically.
    /// Callers (init, panic reset on the main thread) are never on
    /// `messageQueue`; the re-entrancy check keeps any future on-queue caller
    /// from deadlocking.
    private func refreshPeerIdentity() {
        let swap = {
            let fingerprint = self.noiseService.getIdentityFingerprint()
            self.myPeerID = PeerID(str: fingerprint.prefix(16))
            self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
            self.meshTopology.reset()
        }
        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
            swap()
        } else {
            messageQueue.sync(flags: .barrier, execute: swap)
        }
    }


    
    private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {
        // Hop like sendMessage: the Transport-facing wrappers (verify/vouch/
        // group payloads) call this from the main actor, and the send path
        // sync-waits on bleQueue for link state.
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.sendNoisePayload(typedPayload, to: peerID)
            }
            return
        }
        guard noiseService.hasSession(with: peerID) else {
            // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
            // to prevent race where fast handshake completion drains empty queue
            collectionsQueue.sync(flags: .barrier) {
                self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
                SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session)
            }
            initiateNoiseHandshake(with: peerID)
            return
        }
        do {
            broadcastPacket(try makeEncryptedNoisePacket(typedPayload, to: peerID))
        } catch {
            SecureLogger.error("Failed to send verification payload: \(error)")
        }
    }

    private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
        let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
        return BitchatPacket(
            type: MessageType.noiseEncrypted.rawValue,
            senderID: myPeerIDData,
            recipientID: Data(hexString: peerID.id),
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: encrypted,
            signature: nil,
            ttl: messageTTL
        )
    }

    // MARK: Courier Store-and-Forward

    /// Seal `content` for the recipient and hand the envelope to the given
    /// couriers for physical delivery. When a verified one-time prekey bundle
    /// is cached for the recipient, sealing targets one of its prekeys
    /// (forward secret, envelope v2); otherwise it falls back to their static
    /// key (one-way Noise X, v1) exactly as before. Returns false when no
    /// courier is connected, the payload cannot be built, or sealing fails;
    /// link writes are queued asynchronously after the envelope is ready.
    func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
        let connected = couriers.filter { isPeerConnected($0) }
        guard !connected.isEmpty,
              let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
            return false
        }

        let payload: Data
        do {
            let now = Date()
            let sealed: Data
            let prekeyID: UInt32?
            if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
                sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
                prekeyID = prekey.id
            } else {
                sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
                prekeyID = nil
            }
            let envelope = CourierEnvelope(
                recipientTag: CourierEnvelope.recipientTag(
                    noiseStaticKey: recipientNoiseKey,
                    epochDay: CourierEnvelope.epochDay(for: now)
                ),
                expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
                ciphertext: sealed,
                copies: TransportConfig.courierInitialCopies,
                prekeyID: prekeyID
            )
            guard let encoded = envelope.encode() else { return false }
            payload = encoded
        } catch {
            SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
            return false
        }

        messageQueue.async { [weak self] in
            guard let self else { return }
            for courier in connected {
                SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
                self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
            }
        }
        return true
    }

    // MARK: Courier over the bridge

    /// Seals `content` into a courier envelope for relay parking (a bridge
    /// courier drop). Same sealing rules as `sendCourierMessage` — prekey
    /// (v2) when a verified bundle is cached, static Noise X (v1) otherwise —
    /// but carry-only: a relay copy never sprays.
    func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
        guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
            return nil
        }
        do {
            let now = Date()
            let sealed: Data
            let prekeyID: UInt32?
            if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
                sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
                prekeyID = prekey.id
            } else {
                sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
                prekeyID = nil
            }
            return CourierEnvelope(
                recipientTag: CourierEnvelope.recipientTag(
                    noiseStaticKey: recipientNoiseKey,
                    epochDay: CourierEnvelope.epochDay(for: now)
                ),
                expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
                ciphertext: sealed,
                copies: 1,
                prekeyID: prekeyID
            )
        } catch {
            SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
            return nil
        }
    }

    /// Opens a courier envelope that arrived as a bridge drop (relay fetch,
    /// not a directed mesh packet). Returns false when the rotating tag does
    /// not match our static key — a drop for someone else, or a stale tag.
    /// The inner Noise X seal authenticates the sender; there is no packet
    /// signature to check on this path.
    @discardableResult
    func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
        guard !envelope.isExpired else { return false }
        let myKey = noiseService.getStaticPublicKeyData()
        guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
            return false
        }
        return openCourierEnvelope(envelope)
    }

    /// Hands a bridge-fetched envelope directly to the matching local peer
    /// as a directed courier packet. Delivery-only by design: the recipient's
    /// tag matched, so this never lands in a stranger's carry quota.
    /// Returns true only if a current Noise-authenticated physical link
    /// accepted the packet; a stale peer-level session, reachability record,
    /// replay-rebound link, or process-local spool is not delivery.
    @discardableResult
    func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
        guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false }
        guard let payload = envelope.encode() else { return false }
        let packet = makeCourierPacket(payload, to: peerID)
        let send = { [weak self] in
            self?.sendPacketDirected(
                packet,
                to: peerID,
                requireDirectPeerLink: true,
                requireNoiseAuthenticatedPeerLink: true
            ) ?? false
        }
        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
            return send()
        }
        return messageQueue.sync(execute: send)
    }

    /// Our own Noise static public key (for computing our courier tags).
    func myNoiseStaticPublicKey() -> Data {
        noiseService.getStaticPublicKeyData()
    }

    /// Verified reachable peers with known Noise keys — the set a bridge
    /// gateway watches courier drops for.
    func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
        let now = Date()
        return collectionsQueue.sync {
            peerRegistry.snapshotByID.values.compactMap { info in
                guard info.isVerifiedNickname,
                      let key = info.noisePublicKey,
                      peerRegistry.isReachable(info.peerID, now: now) else { return nil }
                return (info.peerID, key)
            }
        }
    }

    /// The prekey to seal a courier message with, or nil to fall back to
    /// static sealing. The real signal is a verified, unexpired bundle with a
    /// spare prekey; the advertised `.prekeys` capability only acts as a veto
    /// for peers we currently see on the mesh (a cached bundle can outlive a
    /// peer's downgrade to a build that no longer holds the privates).
    /// Re-deposits of the same message reuse its assigned prekey, so one
    /// message consumes exactly one prekey ID regardless of courier count.
    private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
        let shortID = PeerID(publicKey: recipientNoiseKey)
        let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
        if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
            return nil
        }
        return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
    }

    private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
        let packet = BitchatPacket(
            type: MessageType.courierEnvelope.rawValue,
            senderID: myPeerIDData,
            recipientID: Data(hexString: peerID.id),
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: messageTTL
        )
        // Signed so a courier can authenticate the depositor before carrying
        // mail under their quota. Handover to the recipient doesn't need the
        // packet signature — the inner Noise X seal authenticates the sender.
        return noiseService.signPacket(packet) ?? packet
    }

    /// Handles both courier roles for an incoming envelope addressed to us:
    /// recipient (the rotating tag matches our static key → open and deliver)
    /// or courier (a trusted peer is depositing mail for someone else → store).
    private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
        // Directed packets only; envelopes addressed elsewhere ride the
        // generic relay path untouched.
        guard packet.recipientID == myPeerIDData else { return }
        guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }

        let myKey = noiseService.getStaticPublicKeyData()
        if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
            openCourierEnvelope(envelope)
        } else {
            acceptCourierDeposit(envelope, from: peerID, packet: packet)
        }
    }

    @discardableResult
    private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
        do {
            let typedPayload: Data
            let senderStaticKey: Data
            if let prekeyID = envelope.prekeyID {
                // Envelope v2: sealed to one of our one-time prekeys. Opening
                // consumes the prekey (48h redelivery grace), which shrinks our
                // published bundle under a strictly newer generatedAt. Re-gossip
                // so peers replace their cached copy and stop assigning the
                // consumed ID before the grace lapses; force the broadcast when
                // the batch also topped back up (low-water), otherwise let the
                // rebroadcast throttle coalesce bursts.
                let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
                (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
                if opened.consumedPrekey {
                    let replenished = noiseService.replenishPrekeysIfNeeded()
                    sendPrekeyBundle(force: replenished)
                }
            } else {
                (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
            }
            guard let typeRaw = typedPayload.first,
                  let payloadType = NoisePayloadType(rawValue: typeRaw),
                  payloadType == .privateMessage else {
                SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
                return true // decrypted but deterministically unsupported
            }
            let payload = Data(typedPayload.dropFirst())
            guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
                SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
                return true // decrypted but deterministically malformed
            }
            // Redundant copies of one message arrive as distinct envelopes
            // (fresh seal each: mesh couriers, bridge drops across relays),
            // so dedup here on the inner message ID — before delivery, ack,
            // and handshake work. A duplicate costs only the decrypt above
            // and at most one ack ever goes out per message ID.
            let firstOpen = collectionsQueue.sync(flags: .barrier) {
                openedCourierMessageIDs.insert(innerMessageID)
            }
            guard firstOpen else {
                SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session)
                return true
            }
            // Couriered mail arrives while the sender is absent, so the UI's
            // block check can't resolve their fingerprint from a live session.
            // Gate here, where the full static key is in hand.
            guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
                SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
                return true
            }
            // A present sender resolves to their live mesh thread via the
            // derived short ID. An absent sender — the usual courier case —
            // uses the full noise-key ID so the message lands on the stable
            // favorite conversation instead of an unresolvable short-ID
            // thread labeled "Unknown".
            let shortID = PeerID(publicKey: senderStaticKey)
            let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
            let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
            SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
            sfMetrics?.record(.courierOpened)
            notifyUI { [weak self] in
                self?.deliverTransportEvent(.noisePayloadReceived(
                    peerID: senderPeerID,
                    type: payloadType,
                    payload: payload,
                    timestamp: Date()
                ))
            }
            return true
        } catch {
            // Tag collision or stale key: not addressed to us after all.
            SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
            return false
        }
    }

    private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
        // A deposit must come from its depositor over the direct link: the
        // claimed sender has to be the ingress peer, and the packet signature
        // has to verify against that peer's announced signing key. Otherwise
        // an untrusted sender could route an envelope through any trusted
        // neighbor and have us carry it under the neighbor's quota.
        guard PeerID(hexData: packet.senderID) == peerID else {
            SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
            return
        }
        let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
        guard let depositorKey = depositorInfo?.noisePublicKey else {
            SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
            return
        }
        guard let signingKey = depositorInfo?.signingPublicKey,
              noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
            SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
            return
        }
        let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
        let store = courierStore
        let policy = courierDepositPolicy
        let metrics = sfMetrics
        Task { @MainActor in
            guard let tier = policy(depositorKey, isVerifiedPeer) else {
                SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
                return
            }
            if store.deposit(envelope, from: depositorKey, tier: tier) {
                SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
                metrics?.record(.courierAccepted)
            }
        }
    }

    /// Hand over any carried envelopes addressed to a peer we just heard from.
    private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
        let metrics = sfMetrics
        let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
            guard let self,
                  let payload = envelope.encode(),
                  self.sendPacketDirected(
                      self.makeCourierPacket(payload, to: peerID),
                      to: peerID,
                      requireDirectPeerLink: true,
                      requireNoiseAuthenticatedPeerLink: true
                  ) else {
                return false
            }
            metrics?.record(.courierHandedOver)
            return true
        }
        if accepted > 0 {
            SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
        }
    }

    /// Speculative handover toward a recipient heard only via a relayed
    /// announce: the envelope floods the mesh as a directed packet (relays
    /// treat it like a directed DM). Non-destructive — the carried copy stays
    /// until a direct handover or expiry, throttled per envelope so repeated
    /// announces don't re-flood.
    private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
        let envelopes = courierStore.envelopesForRemoteHandover(
            recipientNoiseKey: noiseKey,
            cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
        )
        guard !envelopes.isEmpty else { return }
        SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
        for envelope in envelopes {
            guard let payload = envelope.encode() else { continue }
            broadcastPacket(makeCourierPacket(payload, to: peerID))
            sfMetrics?.record(.courierRemoteHandover)
        }
    }

    /// Spray-and-wait: split copy budgets with another courier we just
    /// encountered, so carried mail diffuses through a moving crowd instead
    /// of riding a single carrier. Only favorites and verified peers qualify,
    /// mirroring the deposit policy they would apply to us.
    private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
        let store = courierStore
        let metrics = sfMetrics
        let sendSpray: () -> Void = { [weak self] in
            guard let self else { return }
            let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
                guard let payload = envelope.encode(),
                      self.sendPacketDirected(
                          self.makeCourierPacket(payload, to: peerID),
                          to: peerID,
                          requireDirectPeerLink: true,
                          requireNoiseAuthenticatedPeerLink: true
                      ) else {
                    return false
                }
                metrics?.record(.courierSprayed)
                return true
            }
            if accepted > 0 {
                SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
            }
        }
        let policy = courierDepositPolicy
        Task { @MainActor in
            // Same trust gate as deposits: don't hand mail to a peer who
            // would reject it from us.
            guard policy(noiseKey, isVerifiedPeer) != nil else { return }
            sendSpray()
        }
    }

    // MARK: One-Time Prekey Bundles

    /// Broadcasts our signed prekey bundle and tracks it for gossip sync.
    /// Unforced sends (piggybacked on announces) are throttled — gossip does
    /// the spreading, the broadcast just keeps our own gossip entry fresh.
    /// Forced sends (bundle changed after consumption) go immediately.
    private func sendPrekeyBundle(force: Bool = false) {
        let now = Date()
        let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
            if !force,
               let last = lastPrekeyBundleSentAt,
               now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
                return false
            }
            lastPrekeyBundleSentAt = now
            return true
        }
        guard shouldSend else { return }
        guard let bundle = noiseService.currentPrekeyBundle(),
              let payload = bundle.encode() else {
            SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
            return
        }
        let packet = BitchatPacket(
            type: MessageType.prekeyBundle.rawValue,
            senderID: myPeerIDData,
            recipientID: nil,
            timestamp: UInt64(now.timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: messageTTL
        )
        guard let signedPacket = noiseService.signPacket(packet) else {
            SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
            return
        }
        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
            broadcastPacket(signedPacket)
        } else {
            messageQueue.async { [weak self] in
                self?.broadcastPacket(signedPacket)
            }
        }
        gossipSyncManager?.onPublicPacketSeen(signedPacket)
    }

    /// Ingests a gossiped prekey bundle. Attribution is layered: the outer
    /// packet must originate from the bundle owner (fabricated sender IDs, used
    /// to multiply cache/gossip entries, are rejected), and BOTH the inner
    /// bundle signature and the outer packet signature must verify against the
    /// owner's announce-bound signing key. Verifying the outer packet — whose
    /// signed bytes cover senderID and timestamp — stops a valid bundle from
    /// being replayed under a fresh timestamp or spoofed sender to pass
    /// freshness or poison attribution. Only after that does the packet enter
    /// our own gossip store, so we never help spread a bundle we couldn't
    /// attribute.
    private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
        guard let bundle = PrekeyBundle.decode(packet.payload) else {
            SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security)
            return
        }
        // Our own bundle is tracked at send time; a copy echoing back adds nothing.
        guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
        let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
        // The owner's genuine bundle (direct or relayed) always carries the
        // owner's senderID + outer signature; gossip resends preserve both. A
        // packet whose senderID isn't the owner can't be authenticated here.
        guard PeerID(hexData: packet.senderID) == owner else {
            SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security)
            return
        }
        // Look up the announce-bound signing key and stash-if-unbound in ONE
        // barrier: the receive queue is concurrent, so this bundle can race
        // ahead of the announce that binds the key. Reading the live registry
        // and stashing atomically closes the check-then-act gap against
        // handleAnnounce's drain (see drainPendingPrekeyBundles).
        let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
            if let info = peerRegistry.info(for: owner),
               info.noisePublicKey == bundle.noiseStaticPublicKey,
               let key = info.signingPublicKey {
                return key
            }
            // Offline-verified identities are stable across this race.
            for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
            where candidate.publicKey == bundle.noiseStaticPublicKey {
                if let key = candidate.signingPublicKey { return key }
            }
            // No binding yet: retain the latest bundle per owner, bounded, and
            // retry once the verified announce lands.
            if pendingPrekeyBundles[owner] != nil
                || pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
                pendingPrekeyBundles[owner] = packet
            }
            return nil
        }
        guard let signingKey else {
            SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
            return
        }
        ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
    }

    /// Verify a bundle's inner + outer signatures against the owner's bound
    /// signing key and, on success, cache it and let it enter our gossip store.
    private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
        guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
              noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
            SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
            return
        }
        if prekeyBundleStore.ingest(bundle) {
            SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
        }
        gossipSyncManager?.onPublicPacketSeen(packet)
    }

    /// Re-attempt any prekey bundle that arrived before this owner's announce
    /// bound a signing key. Called from handleAnnounce after a verified
    /// announce, in a barrier ordered after the registry write, so a bundle
    /// stashed before the write is always observed here.
    private func drainPendingPrekeyBundles(for owner: PeerID) {
        let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
            pendingPrekeyBundles.removeValue(forKey: owner)
        }
        guard let packet = pending,
              let bundle = PrekeyBundle.decode(packet.payload),
              let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
        ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
    }

    /// Ed25519 signing key bound to a Noise static key by a verified
    /// announce: from the live registry when the owner is on the mesh, else
    /// from identities persisted for offline verification.
    private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
        let shortID = PeerID(publicKey: noiseKey)
        if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
           info.noisePublicKey == noiseKey,
           let signingKey = info.signingPublicKey {
            return signingKey
        }
        for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
        where candidate.publicKey == noiseKey {
            if let signingKey = candidate.signingPublicKey {
                return signingKey
            }
        }
        return nil
    }

    // MARK: Gateway carrier (nostrCarrier)

    /// Sign and send an encoded `toGateway` carrier payload directed at a
    /// gateway peer. The packet is signed so the gateway can key its uplink
    /// quotas to an authenticated depositor; the carried Nostr event has its
    /// own Schnorr signature for content authenticity. Returns false when
    /// the gateway is not reachable or signing fails.
    func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
        guard isPeerReachable(gatewayPeer) else { return false }
        let packet = BitchatPacket(
            type: MessageType.nostrCarrier.rawValue,
            senderID: myPeerIDData,
            recipientID: Data(hexString: gatewayPeer.id),
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: messageTTL
        )
        guard let signed = noiseService.signPacket(packet) else { return false }
        messageQueue.async { [weak self] in
            // broadcastPacket applies a known route when one exists and
            // otherwise floods the directed packet like a DM, so a gateway
            // that is reachable but multi-hop still gets the deposit.
            self?.broadcastPacket(signed)
        }
        return true
    }

    /// Broadcast an encoded `fromGateway` carrier payload on the mesh with
    /// the default TTL. Unsigned at the packet layer — receivers verify the
    /// carried event's own Schnorr signature.
    func broadcastNostrCarrier(_ payload: Data) {
        let packet = BitchatPacket(
            type: MessageType.nostrCarrier.rawValue,
            senderID: myPeerIDData,
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: messageTTL
        )
        messageQueue.async { [weak self] in
            self?.broadcastPacket(packet)
        }
    }

    /// Transport-level handling for a received nostrCarrier packet; policy
    /// (verification of the carried event, quotas, loop prevention) lives in
    /// `GatewayService` behind `onNostrCarrierPacket`.
    private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) {
        let senderID = PeerID(hexData: packet.senderID)
        let directedToUs: Bool
        if let recipientID = packet.recipientID {
            // Carriers addressed elsewhere ride the generic relay path untouched.
            guard recipientID == myPeerIDData else { return }
            // Uplink deposit: quotas are keyed by the depositor, so the
            // packet signature must verify against the sender's announced
            // signing key. Unlike courier deposits the depositor may be
            // multi-hop away, so ingress-link identity is not required.
            let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
            guard let signingKey,
                  noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
                SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
                return
            }
            directedToUs = true
        } else {
            directedToUs = false
        }
        let payload = packet.payload
        notifyUI { [weak self] in
            self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
        }
    }

    // MARK: Link capability snapshots (thread-safe via bleQueue)

    private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
            return body(linkStateStore)
        } else {
            return bleQueue.sync { body(linkStateStore) }
        }
    }

    private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
        readLinkState { $0.directPeripheralState(for: peerID) }
    }

    private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] {
        readLinkState(\.peripheralStates)
    }

    private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot {
        readLinkState(\.subscribedCentralSnapshot)
    }
    
    // MARK: Helpers: IDs, selection, and write backpressure
    
    private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority) {
        // BLE operations run on bleQueue; keep queue affinity
        bleQueue.async { [weak self] in
            guard let self = self else { return }
            let uuid = peripheral.identifier.uuidString
            if peripheral.canSendWriteWithoutResponse {
                peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
            } else {
                self.collectionsQueue.async(flags: .barrier) {
                    let result = self.pendingPeripheralWrites.enqueue(
                        data: data,
                        for: uuid,
                        priority: priority,
                        capBytes: TransportConfig.blePendingWriteBufferCapBytes
                    )

                    switch result {
                    case .oversized(let bytes):
                        SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
                    case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
                        SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
                    case .enqueued:
                        break
                    }
                }
            }
        }
    }

    /// Writes immediately or synchronously admits the packet to this
    /// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
    /// value distinguishes a retained queue item from one rejected or trimmed
    /// immediately, which lets durable courier state commit truthfully.
    private func writeOrEnqueueIfAccepted(
        _ data: Data,
        to peripheral: CBPeripheral,
        characteristic: CBCharacteristic,
        priority: BLEOutboundWritePriority,
        requiredAuthenticatedPeer: PeerID?
    ) -> Bool {
        let accept = { [self] in
            let uuid = peripheral.identifier.uuidString
            guard let state = linkStateStore.state(forPeripheralID: uuid),
                  state.isConnected,
                  state.characteristic?.uuid == characteristic.uuid else {
                return false
            }
            if let peerID = requiredAuthenticatedPeer {
                let link = BLEIngressLinkID.peripheral(uuid)
                guard state.peerID == peerID,
                      noiseAuthenticatedLinkOwners[link] == peerID else {
                    return false
                }
            }

            if peripheral.canSendWriteWithoutResponse {
                peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
                return true
            }

            let attempt = collectionsQueue.sync(flags: .barrier) {
                pendingPeripheralWrites.enqueueReportingAcceptance(
                    data: data,
                    for: uuid,
                    priority: priority,
                    capBytes: TransportConfig.blePendingWriteBufferCapBytes
                )
            }
            switch attempt.result {
            case .oversized(let bytes):
                SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
            case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
                SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
            case .enqueued:
                break
            }
            return attempt.accepted
        }

        if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
            return accept()
        }
        return bleQueue.sync(execute: accept)
    }

    private func drainPendingWrites(for peripheral: CBPeripheral) {
        let uuid = peripheral.identifier.uuidString
        bleQueue.async { [weak self] in
            guard let self = self else { return }
            guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }

            // Atomically take all pending items from the queue to avoid race conditions
            // where new items could be enqueued between read and update
            let itemsToSend: [BLEPendingWrite] = self.collectionsQueue.sync(flags: .barrier) {
                self.pendingPeripheralWrites.takeAll(for: uuid)
            }
            guard !itemsToSend.isEmpty else { return }

            // Send as many as possible
            var sent = 0
            for item in itemsToSend {
                if peripheral.canSendWriteWithoutResponse {
                    peripheral.writeValue(item.data, for: ch, type: .withoutResponse)
                    sent += 1
                } else {
                    break
                }
            }

            // Re-enqueue any items that couldn't be sent (maintaining order)
            let unsent = Array(itemsToSend.dropFirst(sent))
            if !unsent.isEmpty {
                self.collectionsQueue.async(flags: .barrier) {
                    self.pendingPeripheralWrites.prepend(unsent, for: uuid)
                }
            }
        }
    }

    /// Periodically try to drain pending notifications as a backup mechanism
    private func drainPendingNotificationsIfPossible() {
        drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent")
    }

    /// Periodically try to drain pending writes for all connected peripherals
    private func drainAllPendingWrites() {
        let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs }
        for uuid in uuids {
            guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue }
            drainPendingWrites(for: state.peripheral)
        }
    }

    // MARK: Application State Handlers (iOS)

    #if os(iOS)
    @objc private func appDidBecomeActive() {
        isAppActive = true
        refreshCachedBackgroundTimeRemaining()
        // Restart scanning with allow duplicates when app becomes active
        if centralManager?.state == .poweredOn {
            centralManager?.stopScan()
            startScanning()
        }
        cancelStalePendingConnects()
        logBluetoothStatus("became-active")
        scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
        // No Local Name; nothing to refresh for advertising policy
    }

    @objc private func appDidEnterBackground() {
        isAppActive = false
        refreshCachedBackgroundTimeRemaining()
        // Restart scanning without allow duplicates in background
        if centralManager?.state == .poweredOn {
            centralManager?.stopScan()
            startScanning()
        }
        armPendingBackgroundConnects()
        // Backgrounding may precede a kill; flush the public-history archive
        // outside its 30s maintenance cadence.
        gossipSyncManager?.persistNow()
        logBluetoothStatus("entered-background")
        scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
        // No Local Name; nothing to refresh for advertising policy
    }

    /// Issue indefinite `connect()` requests to recently seen peripherals on
    /// backgrounding. Pending connects live in the Bluetooth controller's
    /// allowlist — no scanning and no app CPU — and complete whenever a peer
    /// comes into range, waking (or relaunching) the app. A couple of central
    /// slots stay reserved for connects driven by live background discovery —
    /// except on the disconnect re-arm path, which may consume the slot the
    /// disconnect itself just freed (a dense mesh with 4+ remaining links
    /// would otherwise compute a zero budget and never re-arm the lost peer).
    private func armPendingBackgroundConnects(
        slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
    ) {
        bleQueue.async { [weak self] in
            guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
            let budget = TransportConfig.bleMaxCentralLinks
                - slotReserve
                - self.linkStateStore.connectedOrConnectingPeripheralCount
            let now = Date()
            let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
                let state = self.linkStateStore.state(forPeripheralID: peripheralID)
                return state?.isConnected == true || state?.isConnecting == true
            }
            guard !targets.isEmpty else { return }
            for target in targets {
                // lastConnectionAttempt stays nil: an indefinite pending connect
                // has no attempt clock, and nil marks it always-stale so
                // cancelStalePendingConnects() reclaims it on foreground even
                // after a quick background→foreground bounce.
                self.linkStateStore.setPeripheralState(
                    BLEPeripheralLinkState(
                        peripheral: target.peripheral,
                        characteristic: nil,
                        peerID: nil,
                        isConnecting: true,
                        isConnected: false,
                        lastConnectionAttempt: nil,
                        assembler: NotificationStreamAssembler()
                    ),
                    for: target.peripheralID
                )
                target.peripheral.delegate = self
                central.connect(target.peripheral, options: [
                    CBConnectPeripheralOptionNotifyOnConnectionKey: true,
                    CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
                    CBConnectPeripheralOptionNotifyOnNotificationKey: true
                ])
            }
            SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
        }
    }

    /// Foreground restores normal connection management: pending connects
    /// older than the connect timeout (including ones rebuilt by state
    /// restoration after a relaunch) are cancelled so live scanning and the
    /// scheduler take over. Anything still nearby is rediscovered within
    /// seconds by the allow-duplicates foreground scan.
    private func cancelStalePendingConnects() {
        bleQueue.async { [weak self] in
            guard let self, let central = self.centralManager else { return }
            let now = Date()
            var cancelled = 0
            for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
                let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
                guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
                let peripheralID = state.peripheral.identifier.uuidString
                central.cancelPeripheralConnection(state.peripheral)
                self.collectionsQueue.sync(flags: .barrier) {
                    self.pendingPeripheralWrites.discardAll(for: peripheralID)
                }
                self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
                _ = self.linkStateStore.removePeripheral(peripheralID)
                cancelled += 1
            }
            if cancelled > 0 {
                SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
                self.tryConnectFromQueue()
            }
        }
    }
    #endif
    
    // MARK: Private Message Handling
    
    private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
        // Hop like sendMessage: the Transport-facing wrappers call this from
        // the main actor (router sends, favorite notifications), and the send
        // path sync-waits on bleQueue for link state.
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.sendPrivateMessage(content, to: recipientID, messageID: messageID)
            }
            return
        }
        // Sessions and wire recipient IDs are keyed by the short 16-hex form;
        // callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
        let recipientID = recipientID.toShort()
        SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)

        // Check if we have an established Noise session
        if noiseService.hasEstablishedSession(with: recipientID) {
            // Encrypt and send
            do {
                guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
                    SecureLogger.error("Failed to encode private message with TLV")
                    return
                }
                
                broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: recipientID))
                
                // Notify delegate that message was sent
                notifyUI { [weak self] in
                    self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sent))
                }
            } catch {
                SecureLogger.error("Failed to encrypt message: \(error)")
            }
        } else {
            // Queue message for sending after handshake completes
            SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session)
            
            // Queue the message (especially important for favorite notifications)
            collectionsQueue.sync(flags: .barrier) {
                pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID)
            }
            
            initiateNoiseHandshake(with: recipientID)
            
            // Notify delegate that message is pending
            notifyUI { [weak self] in
                self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sending))
            }
        }
    }
    
    private func initiateNoiseHandshake(with peerID: PeerID) {
        // Use NoiseEncryptionService for handshake
        guard !noiseService.hasSession(with: peerID) else { return }
        
        do {
            let handshakeData = try noiseService.initiateHandshake(with: peerID)
            
            // Send handshake init
            let packet = BitchatPacket(
                type: MessageType.noiseHandshake.rawValue,
                senderID: myPeerIDData,
                recipientID: Data(hexString: peerID.id),
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: handshakeData,
                signature: nil,
                ttl: messageTTL
            )
            broadcastPacket(packet)
        } catch {
            SecureLogger.error("Failed to initiate handshake: \(error)")
        }
    }
    
    private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
        // Atomically take all pending messages to process (prevents concurrent modification)
        let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in
            pendingNoiseSessionQueues.takePrivateMessages(for: peerID)
        }

        guard !pendingMessages.isEmpty else { return }

        SecureLogger.debug("📤 Sending \(pendingMessages.count) pending messages after handshake to \(peerID.id.prefix(8))…", category: .session)

        // Track failed messages for re-queuing
        var failedMessages: [BLEPendingPrivateMessage] = []

        // Send each pending message directly (we know session is established)
        for message in pendingMessages {
            do {
                // Use the same TLV format as normal sends to keep receiver decoding consistent
                guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: message.content, messageID: message.messageID) else {
                    SecureLogger.error("Failed to encode pending private message TLV")
                    failedMessages.append(message)
                    continue
                }

                // We're already on messageQueue from the callback
                broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: peerID))

                // Notify delegate that message was sent
                notifyUI { [weak self] in
                    self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .sent))
                }

                SecureLogger.debug("✅ Sent pending message id=\(message.messageID.prefix(8))… to \(peerID.id.prefix(8))… after handshake", category: .session)
            } catch {
                SecureLogger.error("Failed to send pending message after handshake: \(error)")
                failedMessages.append(message)

                // Notify delegate of failure
                notifyUI { [weak self] in
                    self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
                }
            }
        }

        // Re-queue any failed messages for retry on next handshake
        if !failedMessages.isEmpty {
            collectionsQueue.async(flags: .barrier) { [weak self] in
                guard let self = self else { return }
                // Prepend failed messages to maintain order
                self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID)
                SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID.id.prefix(8))…", category: .session)
            }
        }
    }
    
    // MARK: Fragmentation (Required for messages > BLE MTU)
    
    @discardableResult
    private func sendFragmentedPacket(
        _ packet: BitchatPacket,
        pad: Bool,
        maxChunk: Int? = nil,
        directedOnlyPeer: PeerID? = nil,
        transferId: String? = nil,
        requireDirectPeerLink: Bool = false,
        requireNoiseAuthenticatedPeerLink: Bool = false
    ) -> Bool {
        let request = BLEOutboundFragmentTransferRequest(
            packet: packet,
            pad: pad,
            maxChunk: maxChunk,
            directedPeer: directedOnlyPeer,
            transferId: transferId,
            requireDirectPeerLink: requireDirectPeerLink,
            requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
        )

        let result = collectionsQueue.sync(flags: .barrier) {
            outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
        }
        return handleFragmentTransferSubmitResult(result)
    }

    @discardableResult
    private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
        switch result {
        case let .start(request, reservedTransferId):
            return startFragmentedPacket(request, reservedTransferId: reservedTransferId)

        case let .queued(_, transferId, _):
            if let transferId {
                SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session)
            } else {
                SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
            }
            return false

        case let .rejectedStrict(_, transferId):
            SecureLogger.debug(
                "🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
                category: .session
            )
            return false

        case let .droppedDuplicate(_, activeTransferId):
            SecureLogger.debug(
                "🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…",
                category: .session
            )
            return false
        }
    }

    @discardableResult
    private func startFragmentedPacket(
        _ request: BLEOutboundFragmentTransferRequest,
        reservedTransferId: String?
    ) -> Bool {
        let releaseReservedSlot: (String) -> Void = { [weak self] id in
            guard let self = self else { return }
            TransferProgressManager.shared.cancel(id: id)
            self.collectionsQueue.async(flags: .barrier) { [weak self] in
                _ = self?.outboundFragmentTransfers.releaseReservation(id)
            }
            self.messageQueue.async { [weak self] in
                self?.startNextPendingTransferIfNeeded()
            }
        }

        guard let plan = BLEOutboundFragmentPlanner.makePlan(
            for: request,
            defaultChunkSize: defaultFragmentSize,
            bleMaxMTU: bleMaxMTU
        ) else {
            if let id = reservedTransferId {
                releaseReservedSlot(id)
            }
            return false
        }

        // Lightweight pacing to reduce floods and allow BLE buffers to drain
        // Also briefly pause scanning during long fragment trains to save battery
        if plan.shouldPauseScanning {
            bleQueue.async { [weak self] in
                guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
                if c.isScanning { c.stopScan() }
                let totalFragments = plan.totalFragments
                let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs)
                self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
                    self?.startScanning()
                }
            }
        }

        let transferIdentifier: String? = {
            guard let id = reservedTransferId else { return nil }
            collectionsQueue.sync(flags: .barrier) {
                _ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: [])
            }
            TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments)
            return id
        }()

        let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
            guard let self else { return false }
            if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
                return self.sendPacketDirected(
                    fragmentPacket,
                    to: directedPeer,
                    requireDirectPeerLink: true,
                    requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
                )
            }
            self.broadcastPacket(fragmentPacket)
            return true
        }

        // Strict courier handoff is transactional at the fragment-admission
        // boundary: every fragment must enter the intended authenticated
        // link or its bounded retry queue before the durable owner may commit.
        // A partial train is harmlessly abandoned and the envelope stays
        // retryable with a fresh fragment ID on the next encounter.
        if request.requireDirectPeerLink {
            let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
                guard sendFragment(fragmentPacket) else { return false }
                if let transferId = transferIdentifier {
                    markFragmentSent(transferId: transferId)
                }
                return true
            }
            guard admitted else {
                if let id = reservedTransferId {
                    releaseReservedSlot(id)
                }
                return false
            }
            return true
        }

        var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []

        for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
            let workItem = DispatchWorkItem { [weak self] in
                guard let self = self else { return }
                if let transferId = transferIdentifier {
                    let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) }
                    guard isActive else { return }
                }
                if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
                    self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
                }
                _ = sendFragment(fragmentPacket)
                if let transferId = transferIdentifier {
                    self.markFragmentSent(transferId: transferId)
                }
            }

            scheduledItems.append((item: workItem, index: index))
        }

        if let transferId = transferIdentifier {
            let workItems = scheduledItems.map { $0.item }
            collectionsQueue.async(flags: .barrier) { [weak self] in
                _ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId)
            }
        }

        for (workItem, index) in scheduledItems {
            let delayMs = index * plan.spacingMs
            messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
        }
        return true
    }
    
    // MARK: - Fragmentation (Required for messages > BLE MTU)

    private func markFragmentSent(transferId: String) {
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }

            switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) {
            case .progress, .complete:
                TransferProgressManager.shared.recordFragmentSent(id: transferId)

            case .missing:
                return
            }

            if !self.outboundFragmentTransfers.isActive(transferId) {
                self.messageQueue.async { [weak self] in
                    self?.startNextPendingTransferIfNeeded()
                }
            }
        }
    }

    private func startNextPendingTransferIfNeeded() {
        let results = collectionsQueue.sync(flags: .barrier) {
            outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
        }

        for result in results {
            messageQueue.async { [weak self] in
                self?.handleFragmentTransferSubmitResult(result)
            }
        }
    }
    
    private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
        if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
            fragmentHandler.handle(packet, from: peerID)
        } else {
            messageQueue.async(flags: .barrier) { [weak self] in
                self?.fragmentHandler.handle(packet, from: peerID)
            }
        }
    }

    /// Builds the fragment handler environment. All queue hops stay here so
    /// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
    private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
        BLEFragmentHandlerEnvironment(
            localPeerID: { [weak self] in
                self?.myPeerID ?? PeerID(str: "")
            },
            trackPacketSeen: { [weak self] packet in
                self?.gossipSyncManager?.onPublicPacketSeen(packet)
            },
            appendFragment: { [weak self] header in
                guard let self = self else {
                    return .stored(header: header, started: false)
                }
                return self.collectionsQueue.sync(flags: .barrier) {
                    self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
                }
            },
            isAcceptedIngressPayload: { [weak self] packet, innerSender in
                self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
            },
            processReassembledPacket: { [weak self] packet, peerID in
                self?.handleReceivedPacket(packet, from: peerID)
            }
        )
    }
    
    // MARK: Packet Reception
    
    private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
        // Call directly if already on messageQueue, otherwise dispatch
        if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
            messageQueue.async { [weak self] in
                self?.handleReceivedPacket(packet, from: peerID)
            }
            return
        }

        let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
        let senderID = context.senderID
        let messageID = context.messageID
        
        // Only log non-announce packets to reduce noise
        if context.logsHandlingDetails {
            // Log packet details for debugging
            SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session)
        }
        
        if dropDuplicatePacketIfNeeded(context: context, messageID: messageID) { return }
        
        // Update peer info without verbose logging - update the peer we received from, not the original sender
        updatePeerLastSeen(peerID)

        // Track recent traffic timestamps for adaptive behavior; the same
        // barrier hop confirms route health for the packet's originator.
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            self.recentTrafficTracker.recordPacket(at: Date())
            self.sourceRouteFailures.noteInboundActivity(from: senderID)
        }

        // Per-peer protocol version: originated source routes only use hops
        // observed speaking v2 (a v1-only node cannot decode v2 frames).
        if packet.version >= 2 {
            meshTopology.recordObservedVersion(packet.version, for: packet.senderID)
            if peerID != senderID {
                meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID))
            }
        }

        #if os(iOS)
        // The maintenance timer is suspended with the app, so a packet arriving
        // while backgrounded means the radio woke us — use the wake window to
        // run the announce/flush/drain pass the timer would have run.
        if !isAppActive {
            bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
        }
        #endif


        // Process by type
        switch context.messageType {
        case .announce:
            handleAnnounce(packet, from: senderID)
            
        case .message:
            handleMessage(packet, from: senderID)
            
        case .requestSync:
            handleRequestSync(packet, from: senderID)
            
        case .noiseHandshake:
            handleNoiseHandshake(packet, from: senderID)
            
        case .noiseEncrypted:
            handleNoiseEncrypted(packet, from: senderID)
            
        case .fragment:
            handleFragment(packet, from: senderID)
            
        case .fileTransfer:
            // Broadcast files that fail sender authentication must not spread
            // to downstream (possibly older, ungated) nodes; skip the relay
            // step below, like invalid board posts and voice frames.
            guard handleFileTransfer(packet, from: senderID) else { return }

        case .courierEnvelope:
            handleCourierEnvelope(packet, from: peerID)

        case .groupMessage:
            handleGroupMessage(packet, from: senderID)

        case .prekeyBundle:
            handlePrekeyBundle(packet, from: senderID)

        case .boardPost:
            // Invalid or deleted posts must not spread; skip the relay step.
            guard handleBoardPost(packet, from: senderID) else { return }
        case .nostrCarrier:
            handleNostrCarrier(packet, from: peerID)

        case .voiceFrame:
            // Rejected frames (unsigned/stale/spoofed) must not spread; skip
            // the relay step below, like invalid board posts.
            guard handleVoiceFrame(packet, from: senderID) else { return }

        case .ping:
            // Rate limiting must key on the ingress link (`peerID`), not the
            // packet-claimed sender: pings are unsigned, so `senderID` is
            // attacker-controlled and rotating it would reset the budget.
            handleMeshPing(packet, fromLink: peerID)

        case .pong:
            handleMeshPong(packet, from: senderID)

        case .leave:
            handleLeave(packet, from: senderID)

        case .none:
            SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
        }
        
        if forwardAlongRouteIfNeeded(packet) {
            return
        }
        
        scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID)
    }

    private func dropDuplicatePacketIfNeeded(context: BLEReceivedPacketContext, messageID: String) -> Bool {
        guard context.shouldDeduplicate, messageDeduplicator.isDuplicate(messageID) else {
            return false
        }

        if context.logsHandlingDetails {
            SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session)
        }

        let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
        if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) {
            collectionsQueue.async(flags: .barrier) { [weak self] in
                self?.scheduledRelays.cancel(messageID: messageID)
            }
        }

        return true
    }

    private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) {
        let degree = collectionsQueue.sync { peerRegistry.connectedCount }
        let decision = BLEReceivePipeline.relayDecision(
            for: packet,
            senderID: senderID,
            localPeerID: myPeerID,
            degree: degree,
            highDegreeThreshold: highDegreeThreshold
        )
        guard decision.shouldRelay else { return }

        let work = DispatchWorkItem { [weak self] in
            guard let self = self else { return }
            self.collectionsQueue.async(flags: .barrier) { [weak self] in
                self?.scheduledRelays.remove(messageID: messageID)
            }
            var relayPacket = packet
            relayPacket.ttl = decision.newTTL
            self.broadcastPacket(relayPacket)
        }

        collectionsQueue.async(flags: .barrier) { [weak self] in
            self?.scheduledRelays.schedule(work, messageID: messageID)
        }
        messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
    }
    
    private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
        let result = announceHandler.handle(packet, from: peerID)

        // A verified announce is the moment a signing key becomes bound to this
        // owner's noise key: retry any prekey bundle that raced ahead of it.
        if let result, result.isVerified {
            drainPendingPrekeyBundles(for: result.peerID)
        }

        // A verified direct announce proves the sender owns the link it came
        // in on: heal any stale binding left by a peer-ID rotation, and
        // consolidate duplicate same-role connections onto that link.
        if let result, result.isVerified, result.isDirectAnnounce {
            rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
            retireRedundantPeripheralLinks(packet, to: result.peerID)
        }

        // Bridge courier watch: a verified announce may add a peer whose
        // relay-parked drops we should start watching for.
        if let result, result.isVerified {
            onVerifiedPeerAnnounce?(result.peerID)
        }

        // Courier work: an announce is the moment we learn a peer's Noise
        // static key, so check whether we're carrying mail addressed to them
        // (or spray-able mail they could carry). Verified announces only.
        guard !courierStore.isEmpty,
              let result,
              result.isVerified else { return }
        let noiseKey = result.announcement.noisePublicKey
        let authenticatedIngress = result.isDirectAnnounce
            && canDeliverSecurely(to: result.peerID)
            && isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
        if authenticatedIngress {
            // The session was established on this still-bound ingress link.
            // A peer-level Noise session alone is not enough: it can outlive
            // its physical link while a replay rebinds an attacker's link to
            // the victim's ID.
            deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
            sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
        } else {
            // Relayed announce, or a direct-looking announce that has not yet
            // proved link ownership with Noise: push a speculative copy while
            // retaining the durable carried original.
            deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
            if result.isDirectAnnounce,
               !hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
                if noiseService.hasEstablishedSession(with: result.peerID) {
                    // A session with no surviving authenticated link is stale;
                    // force the current link to prove possession again.
                    noiseService.clearSession(for: result.peerID)
                }
                if !noiseService.hasSession(with: result.peerID) {
                    initiateNoiseHandshake(with: result.peerID)
                }
            }
        }
    }

    /// When a peer relaunches it rotates its ephemeral peer ID, but an
    /// already-open BLE connection keeps its old peripheral/central→peerID
    /// binding. Until that binding heals, the rotated peer shows up twice in
    /// the peer list and its directed traffic on this link is dropped as
    /// spoofed. A signature-verified direct announce proves the claimed
    /// sender owns the link it arrived on, so rebind the link to the new ID
    /// and retire the old identity.
    private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
        guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
        bleQueue.async { [weak self] in
            guard let self else { return }
            let linkUUID: String
            let previousPeerID: PeerID?
            switch link {
            case .peripheral(let peripheralUUID):
                linkUUID = peripheralUUID
                previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
            case .central(let centralUUID):
                linkUUID = centralUUID
                previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
            }
            guard let previousPeerID, previousPeerID != peerID else { return }

            // The signature does not authenticate directness (TTL is excluded
            // from signing because relays mutate it), so a "verified direct"
            // announce can be a replay of another peer's fresh announce with
            // its TTL restored. Contain what a forged rebind could do:
            // never steal an identity another live link already owns, and
            // allow at most one rebind per link per cooldown window so two
            // identities can't fight over a link in a replay flip-flop.
            guard self.linkStateStore.links(to: peerID).isEmpty else {
                SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security)
                return
            }
            let now = Date()
            self.lastLinkRebindAt = self.lastLinkRebindAt.filter {
                now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
            }
            guard self.lastLinkRebindAt[linkUUID] == nil else {
                SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
                return
            }
            self.lastLinkRebindAt[linkUUID] = now

            // A Noise proof belongs to the old physical binding. Never carry
            // it across an announce-driven rebind, whose direct TTL is
            // replayable; the new owner must complete a fresh handshake.
            self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
            switch link {
            case .peripheral(let peripheralUUID):
                self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
            case .central(let centralUUID):
                self.linkStateStore.bindCentral(centralUUID, to: peerID)
            }
            SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
            self.refreshLocalTopology()
            // The announce that triggered this rebind was upserted as
            // disconnected: the registry ran while the link still belonged
            // to the previous ID (the ambiguous state BLEAnnounceHandler
            // denies the connected shortcut). The rebind has now
            // containment-checked the claim and the identity owns a live
            // link, so promote it — otherwise a healed rotation leaves a
            // live link that reads as disconnected until the next announce.
            self.messageQueue.async { [weak self] in
                self?.promoteReboundPeerToConnected(peerID)
            }
            // Any other peripheral links still bound to the rotated-away ID
            // are stale duplicates of the same physical device (its restored
            // connections outlived the relaunch that rotated the ID): cancel
            // them now instead of leaving ghost links that spray duplicate
            // traffic until the inactivity timeout.
            self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID)
            // Retire the rotated-away ID only once its last link is gone; a
            // remaining stale link heals the same way or ages out.
            guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return }
            self.messageQueue.async { [weak self] in
                self?.retireRotatedPeer(previousPeerID)
            }
        }
    }

    /// After a restore relaunch the same phone can reappear under a fresh
    /// peripheral UUID while its restored connection lives on, leaving
    /// several live central-role connections to one peer that each carry
    /// every packet (field-verified: every voice frame arrived 2-3x). A
    /// verified direct announce is the consolidation point: keep the link it
    /// proves live (or the peer's most recently bound one) and cancel the
    /// rest. Only same-role duplicates are touched — one connection per role
    /// is the normal dual-role topology — and only connections we own as
    /// central: the peer's central subscriptions on our peripheral manager
    /// are its connections to cancel, and it runs this same policy.
    ///
    /// Directness is forgeable (TTL is unsigned), so a replayed announce
    /// could nominate the replayer's link as the survivor. Containment
    /// mirrors the rotation rebind: only links already BOUND to the peer are
    /// retired (announce-evidenced, never pre-announce links), at most one
    /// retirement per peer per cooldown window, and the peer keeps a live
    /// link either way.
    private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) {
        let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) }
        bleQueue.async { [weak self] in
            guard let self else { return }
            let now = Date()
            self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter {
                now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
            }
            guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return }

            var ingressPeripheralUUID: String?
            if case .peripheral(let uuid) = ingressLink {
                ingressPeripheralUUID = uuid
            }
            guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID(
                ingressPeripheralUUID: ingressPeripheralUUID,
                mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID],
                links: self.peripheralLinkPolicySnapshot(),
                peerID: peerID
            ) else { return }

            self.lastRedundantLinkRetirementAt[peerID] = now
            // The survivor becomes the peer's reverse-mapped link so directed
            // sends follow the consolidation.
            self.linkStateStore.bindPeripheral(keptUUID, to: peerID)
            self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID)
            self.refreshLocalTopology()
        }
    }

    /// Cancels our central-role connections whose link is bound to `peerID`,
    /// except `keptUUID`. bleQueue only. Each entry is removed from the link
    /// store BEFORE cancelling so didDisconnectPeripheral sees no peer
    /// binding and skips its peer-disconnect bookkeeping — the peer is still
    /// live (on the kept link, or under its rotated identity).
    private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) {
        let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire(
            links: peripheralLinkPolicySnapshot(),
            peerID: peerID,
            keeping: keptUUID ?? ""
        )
        for uuid in retiring {
            guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
            collectionsQueue.sync(flags: .barrier) {
                pendingPeripheralWrites.discardAll(for: uuid)
            }
            noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
            _ = linkStateStore.removePeripheral(uuid)
            SecureLogger.info(
                "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
                category: .session
            )
            centralManager?.cancelPeripheralConnection(state.peripheral)
        }
    }

    /// bleQueue only (reads the link store).
    private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] {
        linkStateStore.peripheralStates.map {
            BLERedundantLinkPolicy.PeripheralLink(
                uuid: $0.peripheral.identifier.uuidString,
                peerID: $0.peerID,
                isConnected: $0.isConnected,
                hasCharacteristic: $0.characteristic != nil
            )
        }
    }

    /// After a successful verified rebind the new identity owns a live link,
    /// but its announce was stored disconnected (the link was still bound to
    /// the rotated-away ID when the registry upsert ran). Flip it to
    /// connected and republish so routing and the peer list see the healed
    /// link. The `.peerConnected` UI event already fired from the announce
    /// path (new/reconnected + direct), so only list state needs refreshing.
    private func promoteReboundPeerToConnected(_ peerID: PeerID) {
        let promoted = collectionsQueue.sync(flags: .barrier) {
            peerRegistry.markConnected(peerID)
        }
        guard promoted else { return }
        refreshLocalTopology()
        publishFullPeerData()
        notifyUI { [weak self] in
            guard let self else { return }
            let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
            self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
        }
    }

    /// Rotation is an implicit leave of the old identity: drop it immediately
    /// instead of letting a ghost duplicate linger for the reachability
    /// retention window.
    private func retireRotatedPeer(_ peerID: PeerID) {
        let removed = collectionsQueue.sync(flags: .barrier) {
            peerRegistry.remove(peerID) != nil
        }
        guard removed else { return }
        gossipSyncManager?.removeAnnouncementForPeer(peerID)
        refreshLocalTopology()
        notifyUI { [weak self] in
            guard let self else { return }
            let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
            self.deliverTransportEvent(.peerDisconnected(peerID))
            self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
        }
    }

    /// Builds the announce handler environment. All queue hops stay here so
    /// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
    private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
        BLEAnnounceHandlerEnvironment(
            localPeerID: { [weak self] in
                self?.myPeerID ?? PeerID(str: "")
            },
            messageTTL: messageTTL,
            now: { Date() },
            existingNoisePublicKey: { [weak self] peerID in
                guard let self = self else { return nil }
                return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
            },
            verifySignature: { [weak self] packet, signingPublicKey in
                self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
            },
            linkState: { [weak self] peerID in
                self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
            },
            linkBoundToOtherPeer: { [weak self] packet, peerID in
                // Reads the CURRENT binding — i.e. the state before
                // rebindLinkAfterVerifiedDirectAnnounce (which runs after the
                // handler) may steal the link and promote the new owner to
                // connected. See the caller in BLEAnnounceHandler for why the
                // residual forged-presence window this leaves is accepted.
                guard let self else { return false }
                guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false }
                let boundPeerID: PeerID? = self.readLinkState { store in
                    switch link {
                    case .peripheral(let peripheralUUID):
                        return store.peerID(forPeripheralID: peripheralUUID)
                    case .central(let centralUUID):
                        return store.peerID(forCentralUUID: centralUUID)
                    }
                }
                guard let boundPeerID else { return false }
                return boundPeerID != peerID
            },
            withRegistryBarrier: { [weak self] body in
                self?.collectionsQueue.sync(flags: .barrier) { body() }
            },
            upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
                // Called from inside withRegistryBarrier; access registry directly.
                self?.peerRegistry.upsertVerifiedAnnounce(
                    peerID: peerID,
                    nickname: announcement.nickname,
                    noisePublicKey: announcement.noisePublicKey,
                    signingPublicKey: announcement.signingPublicKey,
                    isConnected: isConnected,
                    now: now,
                    capabilities: announcement.capabilities ?? [],
                    bridgeGeohash: announcement.bridgeGeohash
                ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
            },
            shouldEmitReconnectLog: { [weak self] peerID, now in
                // Called from inside withRegistryBarrier; access debouncer directly.
                self?.reconnectLogDebouncer.shouldEmit(
                    peerID: peerID,
                    now: now,
                    minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
                ) ?? false
            },
            updateTopology: { [weak self] peerID, neighbors in
                self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
            },
            persistIdentity: { [weak self] announcement in
                self?.identityManager.upsertCryptographicIdentity(
                    fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
                    noisePublicKey: announcement.noisePublicKey,
                    signingPublicKey: announcement.signingPublicKey,
                    claimedNickname: announcement.nickname
                )
            },
            dedupContains: { [weak self] id in
                self?.messageDeduplicator.contains(id) ?? true
            },
            dedupMarkProcessed: { [weak self] id in
                self?.messageDeduplicator.markProcessed(id)
            },
            deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
                // Single main-actor hop so event order is guaranteed:
                // .peerConnected → initial sync scheduling → .peerListUpdated.
                self?.notifyUI { [weak self] in
                    guard let self = self else { return }
                    if notifyPeerConnected {
                        self.deliverTransportEvent(.peerConnected(peerID))
                    }
                    if scheduleInitialSync {
                        self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
                    }
                    // Get current peer list (after addition)
                    let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
                    self.requestPeerDataPublish()
                    self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
                }
            },
            trackPacketSeen: { [weak self] packet in
                self?.gossipSyncManager?.onPublicPacketSeen(packet)
            },
            sendAnnounceBack: { [weak self] in
                self?.sendAnnounce(forceSend: true)
            },
            scheduleAfterglow: { [weak self] delay in
                self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
                    self?.sendAnnounce(forceSend: true)
                }
            }
        )
    }

    // MARK: - Board (geohash bulletin board)

    /// Validates and stores an incoming board post or tombstone. Returns
    /// whether the packet is worth relaying onward.
    private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
        guard let wire = BoardWire.decode(from: packet.payload) else {
            SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session)
            return false
        }
        // Posts are self-authenticating: the payload embeds the author's
        // Ed25519 key and signature, so verification does not depend on the
        // author still being around to announce.
        guard wire.verifySignature() else {
            if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
                SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security)
            }
            return false
        }
        switch boardStore.ingest(wire, packet: packet) {
        case .accepted, .duplicate:
            return true
        case .rejected:
            return false
        }
    }

    /// Broadcasts a pre-signed board payload (post or tombstone) built by the
    /// board manager, and ingests it locally so it shows up on our own board
    /// and joins gossip sync immediately.
    func sendBoardPayload(_ payload: Data) {
        guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
            SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
            return
        }
        messageQueue.async { [weak self] in
            guard let self = self else { return }
            let basePacket = BitchatPacket(
                type: MessageType.boardPost.rawValue,
                senderID: Data(hexString: self.myPeerID.id) ?? Data(),
                recipientID: nil,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: payload,
                signature: nil,
                ttl: self.messageTTL
            )
            guard let signedPacket = self.noiseService.signPacket(basePacket) else {
                SecureLogger.error("❌ Failed to sign board packet", category: .security)
                return
            }
            // Pre-mark our own broadcast as processed to avoid handling a relayed self copy
            let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
            self.messageDeduplicator.markProcessed(dedupID)
            self.boardStore.ingest(wire, packet: signedPacket)
            self.broadcastPacket(signedPacket)
        }
    }

    // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
    private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
        // REQUEST_SYNC is link-local by design (always sent with ttl 0): a
        // nonzero TTL means a crafted or relayed request, and answering one
        // would let a single small packet fan a full store replay out of
        // every node it reaches.
        guard packet.ttl == 0 else {
            if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") {
                SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security)
            }
            return
        }
        // A response can replay the entire gossip store, so require proof the
        // requester owns the claimed sender ID: the request must verify
        // against the signing key from that peer's announce.
        let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey }
        guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
            if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") {
                SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security)
            }
            return
        }
        guard let req = RequestSyncPacket.decode(from: packet.payload) else {
            SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
            return
        }
        gossipSyncManager?.handleRequestSync(from: peerID, request: req)
    }
    
    // Mention parsing moved to ChatViewModel
    
    private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
        publicMessageHandler.handle(packet, from: peerID)
    }

    /// Builds the public-message handler environment. All queue hops stay here
    /// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
    /// testable.
    private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
        BLEPublicMessageHandlerEnvironment(
            localPeerID: { [weak self] in
                self?.myPeerID ?? PeerID(str: "")
            },
            localNickname: { [weak self] in
                self?.myNickname ?? ""
            },
            now: { Date() },
            peersSnapshot: { [weak self] in
                guard let self = self else { return [:] }
                return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
            },
            verifyPacketSignature: { [weak self] packet, signingPublicKey in
                self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
            },
            signedSenderDisplayName: { [weak self] packet, peerID in
                self?.signedSenderDisplayName(for: packet, from: peerID)
            },
            trackPacketSeen: { [weak self] packet in
                self?.gossipSyncManager?.onPublicPacketSeen(packet)
            },
            linkState: { [weak self] peerID in
                self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
            },
            takeSelfBroadcastMessageID: { [weak self] packet in
                // Caller is on messageQueue, where the tracker is owned.
                self?.selfBroadcastTracker.takeMessageID(for: packet)
            },
            deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in
                // Single main-actor hop delivering `.publicMessageReceived`.
                self?.notifyUI { [weak self] in
                    self?.deliverTransportEvent(
                        .publicMessageReceived(
                            peerID: peerID,
                            nickname: nickname,
                            content: content,
                            timestamp: timestamp,
                            messageID: messageID
                        )
                    )
                }
            }
        )
    }

    /// Group broadcasts are opaque ciphertext to this layer: track them for
    /// gossip backfill and hand the payload to the UI layer, where the group
    /// coordinator decrypts and authenticates against the roster. Non-members
    /// still relay (generic broadcast relay path) but never decode.
    private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) {
        let isBroadcastRecipient: Bool = {
            guard let recipient = packet.recipientID else { return true }
            return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
        }()
        guard isBroadcastRecipient, !packet.payload.isEmpty else { return }

        gossipSyncManager?.onPublicPacketSeen(packet)

        let payload = packet.payload
        let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
        notifyUI { [weak self] in
            self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
        }
    }

    /// Inbound public live-voice packet: broadcast-only, freshness-gated, and
    /// signature-verified against the claimed sender's announce (mirrors the
    /// public-message identity gate — `senderID` is attacker-controlled, so a
    /// valid packet signature is required before any audio reaches the UI).
    /// Returns whether the packet was accepted; rejected packets must not be
    /// relayed either, or spoofed 0x29 floods would still amplify.
    private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
        guard peerID != myPeerID else { return false }
        guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
        guard !BLEPacketFreshnessPolicy.isStale(
            timestampMilliseconds: packet.timestamp,
            now: Date(),
            maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
        ) else { return false }

        let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
        let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
        let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
        let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
        guard verifiedViaRegistry || signedDisplayName != nil else {
            SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
            return false
        }
        guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
            peerID: peerID,
            localPeerID: myPeerID,
            localNickname: myNickname,
            peers: peersSnapshot,
            allowConnectedUnverified: false
        ) ?? signedDisplayName else {
            return false
        }

        let payload = packet.payload
        let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
        notifyUI { [weak self] in
            self?.deliverTransportEvent(.publicVoiceFrameReceived(
                peerID: peerID,
                nickname: senderNickname,
                payload: payload,
                timestamp: timestamp
            ))
        }
        return true
    }

    private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
        let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
        noisePacketHandler.handleHandshake(packet, from: peerID)
        if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
            markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
        }
    }

    private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
        noisePacketHandler.handleEncrypted(packet, from: peerID)
    }

    /// Builds the Noise packet handler environment. All queue hops and
    /// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
    /// remains queue-agnostic and synchronously testable.
    private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment {
        BLENoisePacketHandlerEnvironment(
            localPeerID: { [weak self] in
                self?.myPeerID ?? PeerID(str: "")
            },
            localPeerIDData: { [weak self] in
                self?.myPeerIDData ?? Data()
            },
            messageTTL: messageTTL,
            now: { Date() },
            processHandshakeMessage: { [weak self] peerID, message in
                try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
            },
            hasNoiseSession: { [weak self] peerID in
                self?.noiseService.hasSession(with: peerID) ?? false
            },
            initiateHandshake: { [weak self] peerID in
                self?.initiateNoiseHandshake(with: peerID)
            },
            broadcastPacket: { [weak self] packet in
                self?.broadcastPacket(packet)
            },
            updatePeerLastSeen: { [weak self] peerID in
                self?.updatePeerLastSeen(peerID)
            },
            decrypt: { [weak self] payload, peerID in
                guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
                return try self.noiseService.decrypt(payload, from: peerID)
            },
            clearSession: { [weak self] peerID in
                self?.noiseService.clearSession(for: peerID)
            },
            deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
                // Single main-actor hop delivering `.noisePayloadReceived`.
                self?.notifyUI { [weak self] in
                    self?.deliverTransportEvent(.noisePayloadReceived(
                        peerID: peerID,
                        type: type,
                        payload: payload,
                        timestamp: timestamp
                    ))
                }
            }
        )
    }

    // MARK: Helper Functions
    
    private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
        let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
            pendingNoiseSessionQueues.takeTypedPayloads(for: peerID)
        }
        guard !payloads.isEmpty else { return }
        SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
        for payload in payloads {
            do {
                broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
            } catch {
                SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
            }
        }
    }
    
    private func updatePeerLastSeen(_ peerID: PeerID) {
        // Use async to avoid deadlock - we don't need immediate consistency for last seen updates
        collectionsQueue.async(flags: .barrier) {
            self.peerRegistry.updateLastSeen(peerID, at: Date())
        }
    }

    // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
    @MainActor
    private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
        if disconnectNotifyDebouncer.shouldEmit(
            peerID: peerID,
            now: Date(),
            minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds
        ) {
            deliverTransportEvent(.peerDisconnected(peerID))
        }
    }
    
    // NEW: Publish peer snapshots to subscribers and notify Transport delegates
    private func publishFullPeerData() {
        let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
            peerRegistry.transportSnapshots(selfNickname: myNickname)
        }
        // Notify UI on MainActor via delegate
        Task { @MainActor [weak self] in
            self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
        }
    }
    
    // MARK: Consolidated Maintenance
    
    private func performMaintenance() {
        maintenanceCounter += 1
        lastMaintenanceAt = Date()

        let now = Date()
        let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
        let elapsed = announceThrottle.elapsed(since: now)
        let recentSeen = collectionsQueue.sync { () -> Bool in
            recentTrafficTracker.hasTraffic(within: 5.0, now: now)
        }
        let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty }
        let plan = BLEMaintenancePolicy.plan(
            cycle: maintenanceCounter,
            connectedCount: connectedCount,
            peerRegistryIsEmpty: hasNoPeers,
            elapsedSinceLastAnnounce: elapsed,
            hasRecentTraffic: recentSeen
        )

        if plan.shouldSendAnnounce {
            sendAnnounce(forceSend: true)
        }

        if plan.shouldEnsureAdvertising {
            // Ensure we're advertising as peripheral
            if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
                pm.startAdvertising(buildAdvertisementData())
            }
        }
        
        // Update scanning duty-cycle based on connectivity
        updateScanningDutyCycle(connectedCount: connectedCount)
        updateRSSIThreshold(connectedCount: connectedCount)

        // Drain the connection candidate queue. Weak-RSSI discoveries are
        // enqueued rather than connected immediately, and the event-driven
        // drains (disconnect/failure/timeout) never fire when we're idle —
        // without this, an isolated node surrounded only by weak (distant)
        // peers would queue them all and never connect to anyone.
        tryConnectFromQueue()
        
        // Check peer connectivity every cycle for snappier UI updates
        checkPeerConnectivity()
        
        // Every 30 seconds (3 cycles): Cleanup
        if plan.shouldRunCleanup {
            performCleanup()
        }

        // Attempt to flush any spooled directed messages periodically (~every 5 seconds)
        if plan.shouldFlushDirectedSpool {
            flushDirectedSpool()
        }

        // Periodically attempt to drain pending notifications and writes as backup
        // in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)
        drainPendingNotificationsIfPossible()
        drainAllPendingWrites()

        // No rotating alias: nothing to refresh
        
        // Reset counter to prevent overflow (every 60 seconds)
        if plan.shouldResetCounter {
            maintenanceCounter = 0
        }
    }
    
    #if os(iOS)
    /// Catch-up maintenance for background wake windows (bleQueue-confined).
    /// Rate-limited to the normal maintenance cadence so a burst of inbound
    /// packets during one wake still runs at most one extra pass.
    private func performBackgroundWakeMaintenanceIfStale() {
        guard meshBackgroundEnabled,
              !isAppActive,
              Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
        performMaintenance()
    }
    #endif

    private func checkPeerConnectivity() {
        let now = Date()
        let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
        var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:]
        for peerID in peerIDsForLinkState {
            let state = linkState(for: peerID)
            cachedLinkStates[peerID] = BLEPeerLinkPresence(
                hasPeripheral: state.hasPeripheral,
                hasCentral: state.hasCentral
            )
        }
        
        let changes = collectionsQueue.sync(flags: .barrier) {
            peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates)
        }
        for removedPeer in changes.removedPeers {
            SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session)
            gossipSyncManager?.removeAnnouncementForPeer(removedPeer.peerID)
        }
        
        // Update UI if there were direct disconnections or offline removals
        if !changes.disconnectedPeerIDs.isEmpty || !changes.removedPeers.isEmpty {
            notifyUI { [weak self] in
                guard let self else { return }
                
                // Get current peer list (after removal)
                let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
                
                for peerID in changes.disconnectedPeerIDs {
                    self.deliverTransportEvent(.peerDisconnected(peerID))
                }
                // Publish snapshots so UnifiedPeerService updates connection/reachability icons
                self.requestPeerDataPublish()
                self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
            }
        }
        
        // Refresh local topology to keep our own entry fresh and sync any changes
        refreshLocalTopology()
        // Prune stale topology nodes (using safe retention window)
        meshTopology.prune(olderThan: 60.0)
    }
    
    private func performCleanup() {
        let now = Date()
        
        // Clean old processed messages efficiently
        messageDeduplicator.cleanup()
        
        // Clean old fragments (> configured seconds old), then ask peers for
        // the specific fragment streams whose reassembly has stalled instead
        // of waiting for the next periodic GCS fragment round.
        let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
            let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
            fragmentAssemblyBuffer.removeExpired(before: cutoff)
            sourceRouteFailures.prune(now: now)
            return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs(
                stalledAfter: TransportConfig.bleFragmentResyncStallSeconds,
                retryAfter: TransportConfig.bleFragmentResyncRetrySeconds,
                now: now
            )
        }
        if !stalledFragmentIDs.isEmpty {
            gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs)
        }

        // Clean old connection timeout backoff entries (> window)
        let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
        connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff)

        // Clean up stale scheduled relays that somehow persisted (> 2s)
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            // Nothing to compare times to; just cap the size defensively
            self.scheduledRelays.removeAllIfOverCapacity(512)
        }

        // Clean ingress link records older than configured seconds
        collectionsQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
            if !self.ingressLinks.isEmpty {
                self.ingressLinks.prune(before: cutoff)
            }
            // Clean expired directed spooled items
            self.pendingDirectedRelays.pruneExpired(
                now: now,
                window: TransportConfig.bleDirectedSpoolWindowSeconds
            )
        }

        messageQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }
            guard !self.selfBroadcastTracker.isEmpty else { return }
            let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds)
            self.selfBroadcastTracker.prune(before: cutoff)
        }
    }

    private func updateScanningDutyCycle(connectedCount: Int) {
        guard let central = centralManager, central.state == .poweredOn else { return }
        // Duty cycle only when app is active and at least one peer connected
        #if os(iOS)
        let active = isAppActive
        #else
        let active = true
        #endif
        // Force full-time scanning if we have very few neighbors or very recent traffic
        let hasRecentTraffic: Bool = collectionsQueue.sync {
            recentTrafficTracker.hasTraffic(
                within: TransportConfig.bleRecentTrafficForceScanSeconds,
                now: Date()
            )
        }
        let scanPlan = BLEScanDutyPolicy.plan(
            dutyEnabled: dutyEnabled,
            appIsActive: active,
            connectedCount: connectedCount,
            hasRecentTraffic: hasRecentTraffic
        )

        switch scanPlan {
        case .dutyCycle(let onDuration, let offDuration):
            let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration
            dutyOnDuration = onDuration
            dutyOffDuration = offDuration

            if scanDutyTimer == nil {
                // Start timer to toggle scanning on/off
                let t = DispatchSource.makeTimerSource(queue: bleQueue)
                // Start with scanning ON; we'll turn OFF after onDuration
                if !central.isScanning { startScanning() }
                dutyActive = true
                t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
                t.setEventHandler { [weak self] in
                    guard let self = self, let c = self.centralManager else { return }
                    if self.dutyActive {
                        // Turn OFF scanning for offDuration
                        if c.isScanning { c.stopScan() }
                        self.dutyActive = false
                        // Schedule turning back ON after offDuration
                        self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
                            if self.centralManager?.state == .poweredOn { self.startScanning() }
                            self.dutyActive = true
                        }
                    }
                }
                t.resume()
                scanDutyTimer = t
            } else if durationsChanged {
                scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
                if !central.isScanning { startScanning() }
                dutyActive = true
            }
        case .continuous:
            // Cancel duty cycle and ensure scanning is ON for discovery
            scanDutyTimer?.cancel()
            scanDutyTimer = nil
            if !central.isScanning { startScanning() }
        }
    }

    private func updateRSSIThreshold(connectedCount: Int) {
        connectionScheduler.updateRSSIThreshold(
            connectedCount: connectedCount,
            connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
            now: Date()
        )
    }
}
