//
// BLEServiceCoreTests.swift
// bitchatTests
//
// Focused BLEService tests for packet handling behavior.
//

import Testing
import Foundation
import CoreBluetooth
import BitFoundation
@testable import bitchat

struct BLEServiceCoreTests {

    @Test
    func duplicatePacket_isDeduped() async throws {
        let ble = makeService()
        let delegate = PublicCaptureDelegate()
        ble.delegate = delegate

        // Public messages must carry a valid signature from the claimed sender;
        // sign the packet and preseed the sender's signing key so the receiver
        // can verify it (production `sendMessage` signs public broadcasts too).
        let signer = NoiseEncryptionService(keychain: MockKeychain())
        let sender = PeerID(str: "1122334455667788")
        let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
        let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
        let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
        let signingKey = signer.getSigningPublicKeyData()

        ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
        let receivedFirst = await TestHelpers.waitUntil(
            { delegate.publicMessagesSnapshot().count == 1 },
            timeout: TestConstants.longTimeout
        )
        #expect(receivedFirst)

        ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
        let receivedDuplicate = await TestHelpers.waitUntil(
            { delegate.publicMessagesSnapshot().count > 1 },
            timeout: TestConstants.shortTimeout
        )
        #expect(!receivedDuplicate)

        let messages = delegate.publicMessagesSnapshot()
        #expect(messages.count == 1)
        #expect(messages.first?.content == "Hello")
    }

    @Test
    func staleBroadcast_isIgnored() async {
        let ble = makeService()
        let delegate = PublicCaptureDelegate()
        ble.delegate = delegate

        let sender = PeerID(str: "A1B2C3D4E5F60708")
        let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
        let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)

        ble._test_handlePacket(packet, fromPeerID: sender)

        let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
        #expect(!didReceive)
        #expect(delegate.publicMessagesSnapshot().isEmpty)
    }

    @Test
    func announceSenderMismatch_isRejected() async throws {
        let ble = makeService()

        let signer = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "Spoof",
            noisePublicKey: signer.getStaticPublicKeyData(),
            signingPublicKey: signer.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let payload = try #require(announcement.encode(), "Failed to encode announcement")

        let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
        let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
        let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
        let wrongPeerID = PeerID(str: wrongBare)
        let packet = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: wrongPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")

        ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)

        _ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
        #expect(ble.currentPeerSnapshots().isEmpty)
    }

    @Test
    func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
        let ble = makeService()
        let alice = NoiseEncryptionService(keychain: MockKeychain())
        let mallory = NoiseEncryptionService(keychain: MockKeychain())
        let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = outbound.record

        let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
        ble._test_handlePacket(
            unsigned,
            fromPeerID: alicePeerID,
            signingPublicKey: alice.getSigningPublicKeyData()
        )

        let unsignedRelayed = await TestHelpers.waitUntil(
            { outbound.count(ofType: .leave) > 0 },
            timeout: TestConstants.shortTimeout
        )
        #expect(!unsignedRelayed)
        #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })

        let badSignature = try #require(
            mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
        )
        ble._test_handlePacket(
            badSignature,
            fromPeerID: alicePeerID,
            signingPublicKey: alice.getSigningPublicKeyData()
        )

        let badSignatureRelayed = await TestHelpers.waitUntil(
            { outbound.count(ofType: .leave) > 0 },
            timeout: TestConstants.shortTimeout
        )
        #expect(!badSignatureRelayed)
        #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
    }

    @Test
    func validSignedLeaveEvictsSessionAndRelays() async throws {
        let ble = makeService()
        let alice = NoiseEncryptionService(keychain: MockKeychain())
        let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())

        // Establish a real session so the leave regression also verifies that
        // stale secure-delivery state is retired, not just the peer-list row.
        let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
        let message2 = try #require(
            try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
        )
        let message3 = try #require(
            try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
        )
        _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
        #expect(ble.canDeliverSecurely(to: alicePeerID))
        let centralUUID = "central-valid-leave"
        ble._test_bindCentral(centralUUID, to: alicePeerID)
        ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
        #expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))

        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = outbound.record
        let signedLeave = try #require(
            alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
        )
        ble._test_handlePacket(
            signedLeave,
            fromPeerID: alicePeerID,
            signingPublicKey: alice.getSigningPublicKeyData()
        )

        let evicted = await TestHelpers.waitUntil(
            {
                !ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
                    && !ble.canDeliverSecurely(to: alicePeerID)
                    && !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
            },
            timeout: TestConstants.longTimeout
        )
        #expect(evicted)
        let relayed = await TestHelpers.waitUntil(
            { outbound.count(ofType: .leave) == 1 },
            timeout: TestConstants.longTimeout
        )
        #expect(relayed)
    }

    @Test
    func ingressAllowsRelayedSenderOnBoundLink() async throws {
        let ble = makeService()
        let boundPeer = PeerID(str: "1122334455667788")
        let relayedSender = PeerID(str: "8899aabbccddeeff")
        let packet = makePublicPacket(
            content: "Relayed",
            sender: relayedSender,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )

        #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
    }

    @Test
    func ingressAllowsDirectAnnounceThatConflictsWithBoundLink() async throws {
        // Peer-ID rotation heal: the announce must reach signature
        // verification, which decides whether the link rebinds.
        let ble = makeService()
        let boundPeer = PeerID(str: "1122334455667788")
        let claimedPeer = PeerID(str: "8899aabbccddeeff")
        let packet = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: claimedPeer.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: Data(),
            signature: nil,
            ttl: 7
        )

        #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
    }

    @Test
    func ingressRejectsRequestSyncThatConflictsWithBoundLink() async throws {
        let ble = makeService()
        let boundPeer = PeerID(str: "1122334455667788")
        let claimedPeer = PeerID(str: "8899aabbccddeeff")
        let packet = BitchatPacket(
            type: MessageType.requestSync.rawValue,
            senderID: Data(hexString: claimedPeer.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: Data(),
            signature: nil,
            ttl: 0
        )

        #expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
    }

    @Test
    func verifiedDirectAnnounceRebindsRotatedLinkAndRetiresOldPeer() async throws {
        let ble = makeService()
        let oldPeerID = PeerID(str: "1122334455667788")
        let centralUUID = "central-rotation"

        // A connected peer whose link binding predates its relaunch.
        ble._test_seedConnectedPeer(oldPeerID, nickname: "alice")
        ble._test_bindCentral(centralUUID, to: oldPeerID)

        // The relaunched device re-announces its rotated identity over the
        // still-open link.
        let signer = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "alice",
            noisePublicKey: signer.getStaticPublicKeyData(),
            signingPublicKey: signer.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let payload = try #require(announcement.encode(), "Failed to encode announcement")
        let newPeerID = PeerID(publicKey: announcement.noisePublicKey)
        let unsigned = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: newPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet")

        #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID))
        ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false)

        let rebound = await TestHelpers.waitUntil(
            { ble._test_centralBinding(centralUUID) == newPeerID },
            timeout: TestConstants.longTimeout
        )
        #expect(rebound)

        let retired = await TestHelpers.waitUntil(
            {
                let peerIDs = ble.currentPeerSnapshots().map(\.peerID)
                return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID)
            },
            timeout: TestConstants.longTimeout
        )
        #expect(retired)
    }

    @Test
    func replayedDirectAnnounceCannotStealBoundIdentity() async throws {
        let ble = makeService()
        let attackerPeerID = PeerID(str: "1122334455667788")
        let victimLink = "central-victim"
        let attackerLink = "central-attacker"

        // The victim's identity, genuinely bound on its own link.
        let victimSigner = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "victim",
            noisePublicKey: victimSigner.getStaticPublicKeyData(),
            signingPublicKey: victimSigner.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let payload = try #require(announcement.encode(), "Failed to encode announcement")
        let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
        let courierStore = CourierStore(persistsToDisk: false)
        ble.courierStore = courierStore
        let carriedEnvelope = CourierEnvelope(
            recipientTag: CourierEnvelope.recipientTag(
                noiseStaticKey: announcement.noisePublicKey,
                epochDay: CourierEnvelope.epochDay(for: Date())
            ),
            expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
            ciphertext: Data(repeating: 0xA5, count: 128)
        )
        #expect(courierStore.deposit(
            carriedEnvelope,
            from: Data(repeating: 0xC0, count: 32),
            tier: .favorite
        ))
        let sprayRecipientKey = Data(repeating: 0xB4, count: 32)
        let sprayEnvelope = CourierEnvelope(
            recipientTag: CourierEnvelope.recipientTag(
                noiseStaticKey: sprayRecipientKey,
                epochDay: CourierEnvelope.epochDay(for: Date())
            ),
            expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
            ciphertext: Data(repeating: 0xB5, count: 128),
            copies: 4
        )
        #expect(courierStore.deposit(
            sprayEnvelope,
            from: Data(repeating: 0xC0, count: 32),
            tier: .favorite
        ))
        ble._test_seedConnectedPeer(victimPeerID, nickname: "victim")
        ble._test_bindCentral(victimLink, to: victimPeerID)
        ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
        ble._test_bindCentral(attackerLink, to: attackerPeerID)

        // Preserve the hard case: a valid victim session still exists on the
        // victim's own physical link when the announce is replayed elsewhere.
        let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
        let message2 = try #require(
            try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message1)
        )
        let message3 = try #require(
            try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
        )
        _ = try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message3)
        #expect(ble.canDeliverSecurely(to: victimPeerID))
        ble._test_markNoiseAuthenticatedCentral(victimLink, to: victimPeerID)

        // The victim's fresh signed announce replayed on the attacker's bound
        // link with its direct TTL restored (TTL is excluded from signing, so
        // the signature still verifies).
        let unsigned = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: victimPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet")

        #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink))
        ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false)

        // The rebind must be refused: the identity already owns a live link.
        let stolen = await TestHelpers.waitUntil(
            { ble._test_centralBinding(attackerLink) == victimPeerID },
            timeout: 0.3
        )
        #expect(!stolen)
        #expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
        #expect(ble._test_centralBinding(victimLink) == victimPeerID)
        // A valid signature authenticates the announce contents, not the
        // unsigned direct TTL. Without a Noise-authenticated session on the
        // ingress link, the replay must not retire mail or consume spray state.
        #expect(!courierStore.isEmpty)
        await Task.yield()
        await Task.yield()
        let stillEligibleForSpray = courierStore.takeSprayCopies(for: announcement.noisePublicKey)
        #expect(stillEligibleForSpray.map(\.copies) == [2])
        #expect(courierStore.takeEnvelopes(for: announcement.noisePublicKey) == [carriedEnvelope])
        #expect(ble.canDeliverSecurely(to: victimPeerID))
        // And the replay must not retire the link's real bound peer.
        #expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
    }

    @Test
    func replayedDirectAnnounceForAbsentPeerNeverYieldsSecureDelivery() async throws {
        // Residual heal-path gap: the victim has NO live link, so the
        // identity-owns-a-link containment cannot refuse the rebind. The
        // replay steals the link binding, and because a successful rebind
        // promotes its new owner to connected (a legitimate rotation heal
        // requires that), the absent victim may read as connected. That
        // forged presence is display-only and accepted — the invariant that
        // holds is that the stolen link can never produce an established
        // Noise session, so MessageRouter's canDeliverSecurely gate routes
        // DMs through retain + courier instead of trusting it outright.
        let ble = makeService()
        let attackerPeerID = PeerID(str: "1122334455667788")
        let attackerLink = "central-attacker-absent-victim"
        ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
        ble._test_bindCentral(attackerLink, to: attackerPeerID)

        // The absent victim's fresh signed announce, replayed on the
        // attacker's bound link with its direct TTL restored.
        let victimSigner = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "victim",
            noisePublicKey: victimSigner.getStaticPublicKeyData(),
            signingPublicKey: victimSigner.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let payload = try #require(announcement.encode(), "Failed to encode announcement")
        let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
        let unsigned = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: victimPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet")

        #expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink))
        ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false)

        // The rebind steals the link (no live link owns the victim's
        // identity, so containment cannot refuse) …
        let rebound = await TestHelpers.waitUntil(
            { ble._test_centralBinding(attackerLink) == victimPeerID },
            timeout: TestConstants.longTimeout
        )
        #expect(rebound)
        // … and the promote marks the absent victim connected: the accepted,
        // display-only forged-presence residue (documented at
        // BLEAnnounceHandler's linkBoundToOtherPeer check) …
        let forgedPresence = await TestHelpers.waitUntil(
            { ble.isPeerConnected(victimPeerID) },
            timeout: TestConstants.longTimeout
        )
        #expect(forgedPresence)
        // … but secure delivery stays impossible — the DM gate holds, and
        // MessageRouter retains + couriers instead of trusting the link.
        #expect(!ble.canDeliverSecurely(to: victimPeerID))
    }

    @Test
    func replayedDirectAnnounceWithStaleVictimSessionCannotBridgeThroughForeignLink() async throws {
        let ble = makeService()
        // Keep announce handling out of the carried-mail path: the regression
        // is specifically BridgeCourierService's direct delivery preflight.
        ble.courierStore = CourierStore(persistsToDisk: false)
        let attackerPeerID = PeerID(str: "1122334455667788")
        let attackerLink = "central-attacker-stale-victim-session"
        ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
        ble._test_bindCentral(attackerLink, to: attackerPeerID)

        let victim = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "victim",
            noisePublicKey: victim.getStaticPublicKeyData(),
            signingPublicKey: victim.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)

        // Establish a real peer-level victim session without associating it
        // with the attacker's physical link. This is the stale-session case
        // that a plain `canDeliverSecurely` check cannot distinguish.
        let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
        let message2 = try #require(
            try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
        )
        let message3 = try #require(
            try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
        )
        _ = try victim.processHandshakeMessage(from: ble.myPeerID, message: message3)
        #expect(ble.canDeliverSecurely(to: victimPeerID))

        let payload = try #require(announcement.encode(), "Failed to encode announcement")
        let unsigned = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: victimPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
        #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
        ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)

        let rebound = await TestHelpers.waitUntil(
            { ble._test_centralBinding(attackerLink) == victimPeerID },
            timeout: TestConstants.longTimeout
        )
        #expect(rebound)
        #expect(ble.canDeliverSecurely(to: victimPeerID))

        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = { outbound.record($0) }
        let envelope = CourierEnvelope(
            recipientTag: CourierEnvelope.recipientTag(
                noiseStaticKey: announcement.noisePublicKey,
                epochDay: CourierEnvelope.epochDay(for: Date())
            ),
            expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
            ciphertext: Data(repeating: 0xA5, count: 128)
        )

        #expect(!ble.deliverBridgedEnvelope(envelope, to: victimPeerID))
        // Reject before even entering the outbound pipeline: otherwise a
        // real attacker CBCentral could accept the opaque courier packet and
        // cause the relay drop's persisted seen ID to be consumed forever.
        #expect(outbound.count(ofType: .courierEnvelope) == 0)
    }

    @Test
    func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
        let ble = makeService()
        let victim = NoiseEncryptionService(keychain: MockKeychain())
        let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())

        // Preserve a working victim session while an unauthenticated
        // replacement candidate arrives on a newly bound physical link.
        let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
        let message2 = try #require(
            try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
        )
        let message3 = try #require(
            try ble._test_noiseProcessHandshakeMessage(
                from: victimPeerID,
                message: message2
            )
        )
        _ = try victim.processHandshakeMessage(
            from: ble.myPeerID,
            message: message3
        )
        #expect(ble.canDeliverSecurely(to: victimPeerID))

        let centralUUID = "central-replacement-xx-message-one"
        ble._test_bindCentral(centralUUID, to: victimPeerID)
        #expect(
            !ble._test_isNoiseAuthenticatedCentral(
                centralUUID,
                for: victimPeerID
            )
        )

        // XX message one may legally carry a payload, so its length is not a
        // reliable signal that the replacement handshake completed.
        let unauthenticatedInitiator = NoiseHandshakeState(
            role: .initiator,
            pattern: .XX,
            keychain: MockKeychain()
        )
        let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
            payload: Data([0xA5])
        )
        #expect(
            replacementMessage1.count
                > NoiseSecurityConstants.xxInitialMessageSize
        )

        let packet = BitchatPacket(
            type: MessageType.noiseHandshake.rawValue,
            senderID: Data(hexString: victimPeerID.id) ?? Data(),
            recipientID: Data(hexString: ble.myPeerID.id),
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: replacementMessage1,
            signature: nil,
            ttl: TransportConfig.messageTTLDefault
        )
        #expect(
            ble._test_recordIngressIfNew(
                packet: packet,
                linkID: centralUUID
            )
        )

        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = outbound.record
        ble._test_handlePacket(
            packet,
            fromPeerID: victimPeerID,
            preseedPeer: false
        )

        // Waiting for the responder's message two proves the candidate was
        // processed before checking its exact authentication result.
        let candidateProcessed = await TestHelpers.waitUntil(
            { outbound.count(ofType: .noiseHandshake) == 1 },
            timeout: TestConstants.longTimeout
        )
        #expect(candidateProcessed)
        #expect(
            !ble._test_isNoiseAuthenticatedCentral(
                centralUUID,
                for: victimPeerID
            )
        )
        #expect(ble.canDeliverSecurely(to: victimPeerID))
    }

    /// A legitimate rotation announce necessarily arrives on a link still
    /// bound to the OLD ID, so its registry upsert stores the new peer
    /// disconnected. The successful rebind must promote it: a healed
    /// rotation with a live link has to read as connected again for routing
    /// and outbox flushes.
    @Test
    func rotationHealPromotesRotatedPeerToConnected() async throws {
        let ble = makeService()
        let oldPeerID = PeerID(str: "1122334455667788")
        let centralUUID = "central-rotation-promote"

        ble._test_seedConnectedPeer(oldPeerID, nickname: "alice")
        ble._test_bindCentral(centralUUID, to: oldPeerID)

        let signer = NoiseEncryptionService(keychain: MockKeychain())
        let announcement = AnnouncementPacket(
            nickname: "alice",
            noisePublicKey: signer.getStaticPublicKeyData(),
            signingPublicKey: signer.getSigningPublicKeyData(),
            directNeighbors: nil
        )
        let payload = try #require(announcement.encode(), "Failed to encode announcement")
        let newPeerID = PeerID(publicKey: announcement.noisePublicKey)
        let unsigned = BitchatPacket(
            type: MessageType.announce.rawValue,
            senderID: Data(hexString: newPeerID.id) ?? Data(),
            recipientID: nil,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
            payload: payload,
            signature: nil,
            ttl: 7
        )
        let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet")

        #expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID))
        ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false)

        let rebound = await TestHelpers.waitUntil(
            { ble._test_centralBinding(centralUUID) == newPeerID },
            timeout: TestConstants.longTimeout
        )
        #expect(rebound)

        let connected = await TestHelpers.waitUntil(
            { ble.isPeerConnected(newPeerID) },
            timeout: TestConstants.longTimeout
        )
        #expect(connected)
    }

    /// Noise sessions are keyed by the short wire ID, but routers may key
    /// sends by the full 64-hex Noise key (favorites resolution does). The
    /// secure-delivery gate must normalize like isPeerConnected, or an
    /// established session is misread as insecure and every DM needlessly
    /// retains + couriers until an ack.
    @Test
    func canDeliverSecurelyNormalizesFullNoiseKeyPeerIDs() async throws {
        let ble = makeService()
        let remote = NoiseEncryptionService(keychain: MockKeychain())
        let remoteKey = remote.getStaticPublicKeyData()
        let shortID = PeerID(publicKey: remoteKey)
        let fullKeyID = PeerID(hexData: remoteKey)
        #expect(fullKeyID.toShort() == shortID)
        #expect(!ble.canDeliverSecurely(to: shortID))

        // Full XX handshake; the local side keys the session by the short
        // wire ID, exactly as packets present it in production.
        let m1 = try ble._test_noiseInitiateHandshake(with: shortID)
        let m2 = try #require(try remote.processHandshakeMessage(from: ble.myPeerID, message: m1))
        let m3 = try #require(try ble._test_noiseProcessHandshakeMessage(from: shortID, message: m2))
        _ = try remote.processHandshakeMessage(from: ble.myPeerID, message: m3)

        #expect(ble.canDeliverSecurely(to: shortID))
        #expect(ble.canDeliverSecurely(to: fullKeyID))
    }

    @Test
    func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws {
        let ble = makeService()
        let packet = makePublicPacket(
            content: "Loopback",
            sender: ble.myPeerID,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )

        #expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788")))
    }

    @Test
    func ingressAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() async throws {
        let ble = makeService()
        var packet = makePublicPacket(
            content: "Recovered by sync",
            sender: ble.myPeerID,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )
        packet.isRSR = true
        packet.ttl = 0

        #expect(ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788")))
    }

    @Test
    func ingressRecordSuppressesSecondLinkDuplicate() async throws {
        let ble = makeService()
        let packet = makePublicPacket(
            content: "Duplicate link copy",
            sender: PeerID(str: "1122334455667788"),
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )

        #expect(ble._test_recordIngressIfNew(packet: packet, linkID: "central-a"))
        #expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b"))
    }

    @Test
    func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws {
        let ble = makeService()
        let originalPeerID = ble.myPeerID
        let originalFingerprint = ble.noiseIdentityFingerprint()
        #expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16)))

        ble.resetIdentityForPanic(currentNickname: "anon")

        // The Noise identity is regenerated and the peer ID swaps with it
        // (atomically, behind a messageQueue barrier).
        let newFingerprint = ble.noiseIdentityFingerprint()
        #expect(newFingerprint != originalFingerprint)
        #expect(ble.myPeerID != originalPeerID)
        #expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
    }

    @Test
    func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
        let ble = makeService()
        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = outbound.record
        let packet = makePublicPacket(
            content: "late callback",
            sender: ble.myPeerID,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )

        ble.suspendForPanicReset()
        ble.sendPacket(packet)
        #expect(outbound.count(ofType: .message) == 0)

        ble.completePanicReset(restartServices: false)
        ble.sendPacket(packet)
        #expect(outbound.count(ofType: .message) == 1)
    }

    @Test @MainActor
    func panicSuspension_invalidatesQueuedMainActorIngress() async {
        let ble = makeService()
        let delegate = TransportEventCaptureDelegate()
        ble.eventDelegate = delegate
        let message = BitchatMessage(
            id: "pre-panic-ingress",
            sender: "Peer",
            content: "must not survive panic",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            recipientNickname: "Me",
            senderPeerID: PeerID(str: "1122334455667788")
        )

        // The test already owns MainActor, so this task cannot run until the
        // synchronous panic boundary below has invalidated its generation.
        ble._test_emitTransportEvent(.messageReceived(message))
        ble.suspendForPanicReset()
        await Task.yield()
        #expect(delegate.messageIDs.isEmpty)

        ble.completePanicReset(restartServices: false)
        ble._test_emitTransportEvent(.messageReceived(message))
        await Task.yield()
        #expect(delegate.messageIDs == [message.id])
    }

    @Test @MainActor
    func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
        let ble = makeService()
        let gate = ReceivePacketHandoffGate()
        ble._test_beforeReceivePacketHandoff = gate.pause
        ble._test_onReceivePacketHandoff = gate.recordHandoff
        defer {
            gate.release()
            ble._test_beforeReceivePacketHandoff = nil
            ble._test_onReceivePacketHandoff = nil
        }

        let sender = PeerID(str: "1122334455667788")
        let packet = makePublicPacket(
            content: "must not cross panic",
            sender: sender,
            timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
        )
        ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
        #expect(await TestHelpers.waitUntil(
            { gate.hasPaused },
            timeout: TestConstants.longTimeout
        ))

        // Panic closes the lifecycle before waiting for the paused bleQueue
        // callback. Releasing it afterward lets the callback enqueue its
        // messageQueue handoff, where the captured generation must be rejected
        // before packet processing starts.
        let panicIngressObserver = PanicIngressObserver(service: ble)
        let didObservePanicClosure = await withCheckedContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let didObserveClosure = panicIngressObserver.waitUntilClosed(
                    timeout: TestConstants.defaultTimeout
                )
                gate.release()
                continuation.resume(returning: didObserveClosure)
            }
            ble.suspendForPanicReset()
        }

        #expect(didObservePanicClosure)
        #expect(gate.handoffCount == 0)

        // A packet captured under the reopened lifecycle still crosses the
        // same handoff, proving the test did not merely disable the hook.
        ble.completePanicReset(restartServices: false)
        ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
        #expect(await TestHelpers.waitUntil(
            { gate.handoffCount == 1 },
            timeout: TestConstants.longTimeout
        ))
    }

    @Test
    func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
        let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")

        #expect(BLEService._test_shouldRediscoverBitChatService(
            invalidatedServiceUUIDs: [BLEService.serviceUUID],
            cachedServiceUUIDs: [otherService]
        ))
    }

    @Test
    func modifiedServices_rediscoverWhenCachedServicesNoLongerIncludeBitChat() async throws {
        let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")

        #expect(BLEService._test_shouldRediscoverBitChatService(
            invalidatedServiceUUIDs: [otherService],
            cachedServiceUUIDs: [otherService]
        ))
    }

    @Test
    func modifiedServices_ignoreUnrelatedInvalidationWhenBitChatIsStillCached() async throws {
        let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")

        #expect(!BLEService._test_shouldRediscoverBitChatService(
            invalidatedServiceUUIDs: [otherService],
            cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
        ))
    }

    /// Pings are unsigned, so their claimed sender is attacker-controlled.
    /// The pong budget must be keyed on the ingress link (the directly
    /// connected peer that delivered the packet): rotating forged sender IDs
    /// over one link exhausts one budget instead of resetting it, so a single
    /// malicious link cannot turn /ping into an amplification primitive.
    @Test
    func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
        let ble = makeService()
        let outbound = OutboundPacketTap()
        ble._test_onOutboundPacket = outbound.record

        let link = PeerID(str: "1122334455667788")
        let budget = TransportConfig.meshPingInboundMaxPerLink
        let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))

        for i in 0..<(budget * 2) {
            // A fresh forged sender for every ping, all arriving on one link.
            let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
            var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
            nonce[0] = UInt8(i)
            let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
            let packet = BitchatPacket(
                type: MessageType.ping.rawValue,
                senderID: Data(hexString: forgedSender.id) ?? Data(),
                recipientID: myRecipientData,
                timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
                payload: payload.encode(),
                signature: nil,
                ttl: 7
            )
            ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
        }

        let reachedBudget = await TestHelpers.waitUntil(
            { outbound.count(ofType: .pong) >= budget },
            timeout: TestConstants.longTimeout
        )
        #expect(reachedBudget)
        // Give any over-budget pong a chance to surface, then confirm the
        // rotated sender IDs never bought a sixth response.
        let exceededBudget = await TestHelpers.waitUntil(
            { outbound.count(ofType: .pong) > budget },
            timeout: TestConstants.shortTimeout
        )
        #expect(!exceededBudget)
        #expect(outbound.count(ofType: .pong) == budget)
    }
}

/// Thread-safe capture of packets leaving the service under test.
private final class OutboundPacketTap {
    private let lock = NSLock()
    private var packets: [BitchatPacket] = []

    func record(_ packet: BitchatPacket) {
        lock.lock(); packets.append(packet); lock.unlock()
    }

    func count(ofType type: MessageType) -> Int {
        lock.lock(); defer { lock.unlock() }
        return packets.filter { $0.type == type.rawValue }.count
    }
}

private final class ReceivePacketHandoffGate: @unchecked Sendable {
    private let condition = NSCondition()
    private var paused = false
    private var released = false
    private var recordedHandoffCount = 0

    var hasPaused: Bool {
        condition.lock()
        defer { condition.unlock() }
        return paused
    }

    var handoffCount: Int {
        condition.lock()
        defer { condition.unlock() }
        return recordedHandoffCount
    }

    func pause() {
        condition.lock()
        paused = true
        condition.broadcast()
        while !released {
            condition.wait()
        }
        condition.unlock()
    }

    func release() {
        condition.lock()
        released = true
        condition.broadcast()
        condition.unlock()
    }

    func recordHandoff() {
        condition.lock()
        recordedHandoffCount += 1
        condition.unlock()
    }
}

/// Lets a dedicated dispatch worker observe the lock-protected panic gate
/// without treating the full BLE service as generally Sendable.
private final class PanicIngressObserver: @unchecked Sendable {
    private let service: BLEService

    init(service: BLEService) {
        self.service = service
    }

    func waitUntilClosed(timeout: TimeInterval) -> Bool {
        let deadline = DispatchTime.now().uptimeNanoseconds
            + UInt64(timeout * 1_000_000_000)
        while service._test_isPanicIngressOpen,
              DispatchTime.now().uptimeNanoseconds < deadline {
            Thread.sleep(forTimeInterval: 0.001)
        }
        return !service._test_isPanicIngressOpen
    }
}

private func makeService() -> BLEService {
    let keychain = MockKeychain()
    let identityManager = MockIdentityManager(keychain)
    let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
    return BLEService(
        keychain: keychain,
        idBridge: idBridge,
        identityManager: identityManager,
        initializeBluetoothManagers: false
    )
}

private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
    BitchatPacket(
        type: MessageType.message.rawValue,
        senderID: Data(hexString: sender.id) ?? Data(),
        recipientID: nil,
        timestamp: timestamp,
        payload: Data(content.utf8),
        signature: nil,
        ttl: 3
    )
}

private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
    BitchatPacket(
        type: MessageType.leave.rawValue,
        senderID: Data(hexString: sender.id) ?? Data(),
        recipientID: nil,
        timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
        payload: Data(marker.utf8),
        signature: nil,
        ttl: TransportConfig.messageTTLDefault
    )
}

private final class PublicCaptureDelegate: BitchatDelegate {
    private let lock = NSLock()
    private(set) var publicMessages: [BitchatMessage] = []

    func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
        let message = BitchatMessage(
            id: messageID,
            sender: nickname,
            content: content,
            timestamp: timestamp,
            isRelay: false,
            originalSender: nil,
            isPrivate: false,
            recipientNickname: nil,
            senderPeerID: peerID,
            mentions: nil
        )
        lock.lock()
        publicMessages.append(message)
        lock.unlock()
    }

    func didReceiveMessage(_ message: BitchatMessage) {}
    func didConnectToPeer(_ peerID: PeerID) {}
    func didDisconnectFromPeer(_ peerID: PeerID) {}
    func didUpdatePeerList(_ peers: [PeerID]) {}
    func didUpdateBluetoothState(_ state: CBManagerState) {}

    func publicMessagesSnapshot() -> [BitchatMessage] {
        lock.lock()
        defer { lock.unlock() }
        return publicMessages
    }
}

@MainActor
private final class TransportEventCaptureDelegate: TransportEventDelegate {
    private(set) var messageIDs: [String] = []

    func didReceiveTransportEvent(_ event: TransportEvent) {
        guard case .messageReceived(let message) = event else { return }
        messageIDs.append(message.id)
    }
}
