//
// ChatViewModelExtensionsTests.swift
// bitchatTests
//
// Tests for ChatViewModel extensions (PrivateChat, Nostr, Tor).
//

import Testing
import Foundation
import Combine
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat

// MARK: - Test Helpers

@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
    let keychain = MockKeychain()
    let keychainHelper = MockKeychainHelper()
    let idBridge = NostrIdentityBridge(keychain: keychainHelper)
    let identityManager = MockIdentityManager(keychain)
    let transport = MockTransport()

    let viewModel = ChatViewModel(
        keychain: keychain,
        idBridge: idBridge,
        identityManager: identityManager,
        transport: transport
    )

    return (viewModel, transport)
}

// MARK: - Private Chat Extension Tests

struct ChatViewModelPrivateChatExtensionTests {

    @Test @MainActor
    func sendPrivateMessage_mesh_storesAndSends() async {
        let (viewModel, transport) = makeTestableViewModel()
        // Use valid hex string for PeerID (32 bytes = 64 hex chars for Noise key usually, or just valid hex)
        let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
        let peerID = PeerID(str: validHex)
        
        // Simulate connection
        transport.connectedPeers.insert(peerID)
        transport.peerNicknames[peerID] = "MeshUser"
        
        viewModel.sendPrivateMessage("Hello Mesh", to: peerID)
        
        // Verify transport was called
        // Note: MockTransport stores sent messages
        // Since sendPrivateMessage delegates to MessageRouter which delegates to Transport...
        // We need to ensure MessageRouter is using our MockTransport.
        // ChatViewModel init sets up MessageRouter with the passed transport.
        
        // Wait for async processing
        try? await Task.sleep(nanoseconds: 100_000_000)
        
        // Verify message stored locally
        #expect(viewModel.privateChats[peerID]?.count == 1)
        #expect(viewModel.privateChats[peerID]?.first?.content == "Hello Mesh")
        
        // Verify message sent to transport (MockTransport captures sendPrivateMessage)
        // MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
        // Check MockTransport implementation... it might need update or verification
    }

    /// An unreachable recipient no longer means instant failure: the message
    /// is routed anyway so the router's outbox/courier/bridge machinery can
    /// deliver it, and it stays "sending" until a router callback resolves it.
    @Test @MainActor
    func sendPrivateMessage_unreachable_staysSendingForStoreAndForward() async {
        let (viewModel, _) = makeTestableViewModel()
        let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
        let peerID = PeerID(str: validHex)

        viewModel.sendPrivateMessage("Hello", to: peerID)

        #expect(viewModel.privateChats[peerID]?.count == 1)
        #expect(viewModel.privateChats[peerID]?.last?.deliveryStatus == .sending)
    }
    
    @Test @MainActor
    func handlePrivateMessage_storesMessage() async {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "SENDER_001")
        
        let message = BitchatMessage(
            id: "msg-1",
            sender: "Sender",
            content: "Private Content",
            timestamp: Date(),
            isRelay: false,
            originalSender: nil,
            isPrivate: true,
            recipientNickname: "Me",
            senderPeerID: peerID
        )
        
        // Simulate receiving a private message via the handlePrivateMessage extension method
        viewModel.handlePrivateMessage(message)
        
        // Verify stored
        #expect(viewModel.privateChats[peerID]?.count == 1)
        #expect(viewModel.privateChats[peerID]?.first?.content == "Private Content")
        
        // Verify notification trigger (unread count should increase if not viewing)
        #expect(viewModel.unreadPrivateMessages.contains(peerID))
    }
    
    @Test @MainActor
    func handlePrivateMessage_deduplicates() async {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "SENDER_001")
        
        let message = BitchatMessage(
            id: "msg-1",
            sender: "Sender",
            content: "Content",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            senderPeerID: peerID
        )
        
        viewModel.handlePrivateMessage(message)
        viewModel.handlePrivateMessage(message) // Duplicate
        
        #expect(viewModel.privateChats[peerID]?.count == 1)
    }
    
    @Test @MainActor
    func handlePrivateMessage_sendsReadReceipt_whenViewing() async {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "SENDER_001")
        
        // Set as currently viewing
        viewModel.selectedPrivateChatPeer = peerID
        
        let message = BitchatMessage(
            id: "msg-1",
            sender: "Sender",
            content: "Content",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            senderPeerID: peerID
        )
        
        viewModel.handlePrivateMessage(message)
        
        // Should NOT be marked unread
        #expect(!viewModel.unreadPrivateMessages.contains(peerID))
    }
    
    @Test @MainActor
    func migratePrivateChats_consolidatesHistory_onFingerprintMatch() async {
        let (viewModel, _) = makeTestableViewModel()
        let oldPeerID = PeerID(str: "OLD_PEER")
        let newPeerID = PeerID(str: "NEW_PEER")
        let fingerprint = "fp_123"
        
        // Setup old chat
        let oldMessage = BitchatMessage(
            id: "msg-old",
            sender: "User",
            content: "Old message",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            senderPeerID: oldPeerID
        )
        viewModel.seedPrivateChat([oldMessage], for: oldPeerID)
        viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
        
        // Setup new peer fingerprint
        viewModel.peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
        
        // Trigger migration
        viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "User")
        
        // Verify migration
        #expect(viewModel.privateChats[newPeerID]?.count == 1)
        #expect(viewModel.privateChats[newPeerID]?.first?.content == "Old message")
        #expect(viewModel.privateChats[oldPeerID] == nil) // Old chat removed
    }
    
    @Test @MainActor
    func isMessageBlocked_filtersBlockedUsers() async {
        let (viewModel, _) = makeTestableViewModel()
        let blockedPeerID = PeerID(str: "BLOCKED_PEER")
        
        // Block the peer
        // MockIdentityManager stores state based on fingerprint
        // We need to map peerID to a fingerprint
        viewModel.peerIDToPublicKeyFingerprint[blockedPeerID] = "fp_blocked"
        viewModel.identityManager.setBlocked("fp_blocked", isBlocked: true)
        
        // Also ensure UnifiedPeerService can resolve the fingerprint.
        // UnifiedPeerService uses its own cache or delegates to meshService/Peer list.
        // Since we are mocking, we can't easily inject into UnifiedPeerService's internal cache.
        // However, ChatViewModel's isMessageBlocked uses:
        // 1. isPeerBlocked(peerID) -> unifiedPeerService.isBlocked(peerID) -> getFingerprint -> identityManager.isBlocked
        
        // We need UnifiedPeerService.getFingerprint(for: blockedPeerID) to return "fp_blocked"
        // UnifiedPeerService tries: cache -> meshService -> getPeer
        
        // Option 1: Mock the transport (meshService) to return the fingerprint
        // (viewModel.transport is MockTransport, but UnifiedPeerService holds a reference to it)
        // Check if MockTransport has `getFingerprint`
        
        // If not, we might need to rely on the fallback: ChatViewModel.isMessageBlocked also checks Nostr blocks.
        
        // Let's assume MockTransport needs `getFingerprint` implementation or update it.
        // For now, let's try to verify if `MockTransport` supports `getFingerprint`.
        
        // Actually, let's just use the Nostr block path which is simpler and also tested here.
        // "Check geohash (Nostr) blocks using mapping to full pubkey"
        
        let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
        viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID)
        viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
        
        // Force isGeoChat/isGeoDM check to be true by setting prefix?
        // Or ensure the logic covers it.
        // The logic is:
        // if peerID.isGeoChat || peerID.isGeoDM { check nostr }
        // We need a peerID that looks like geo.
        
        let geoPeerID = PeerID(nostr_: hexPubkey)
        viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID)
        
        let geoMessage = BitchatMessage(
            id: "msg-geo-blocked",
            sender: "BlockedGeoUser",
            content: "Spam",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            senderPeerID: geoPeerID
        )
        
        #expect(viewModel.isMessageBlocked(geoMessage))
    }
}

// MARK: - Nostr Extension Tests

struct ChatViewModelNostrExtensionTests {
    
    @Test @MainActor
    func switchLocationChannel_mesh_clearsGeo() async {
        let (viewModel, _) = makeTestableViewModel()
        
        // Setup some geo state
        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
        #expect(viewModel.currentGeohash == "u4pruydq")
        
        // Switch to mesh
        viewModel.switchLocationChannel(to: .mesh)
        
        #expect(viewModel.activeChannel == .mesh)
        #expect(viewModel.currentGeohash == nil)
    }
    
    @Test @MainActor
    func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
        let geohash = "u4pruydq"
        let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))

        LocationChannelManager.shared.select(channel)
        defer { LocationChannelManager.shared.select(.mesh) }

        _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })

        let (viewModel, _) = makeTestableViewModel()
        
        _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
        
        let signer = try NostrIdentity.generate()
        let event = NostrEvent(
            pubkey: signer.publicKeyHex,
            createdAt: Date(),
            kind: .ephemeralEvent,
            tags: [["g", geohash]],
            content: "Hello Geo"
        )
        let signed = try event.sign(with: signer.schnorrSigningKey())
        viewModel.handleNostrEvent(signed)
        
        let didAppend = await TestHelpers.waitUntil({
            viewModel.publicMessagePipeline.flushIfNeeded()
            if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
            // LocationChannelManager is a process-wide singleton: a suite
            // running in parallel (e.g. CommandProcessorTests) can flip the
            // selected channel mid-test, which reroutes or drops the event
            // permanently — no amount of waiting recovers it. Re-assert the
            // channel and redeliver on each poll: every channel switch clears
            // the processed-event set and the store dedups by message ID, so
            // redelivery is idempotent and interference heals on the next
            // poll while a genuine failure still times out.
            if LocationChannelManager.shared.selectedChannel != channel {
                LocationChannelManager.shared.select(channel)
            }
            if viewModel.activeChannel == channel {
                viewModel.handleNostrEvent(signed)
            }
            return false
        }, timeout: TestConstants.longTimeout)
        #expect(didAppend)
    }

    @Test @MainActor
    func handleNostrEvent_ignoresRecentSelfEcho() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)

        let event = NostrEvent(
            pubkey: identity.publicKeyHex,
            createdAt: Date(),
            kind: .ephemeralEvent,
            tags: [["g", geohash]],
            content: "Self echo"
        )
        let signed = try event.sign(with: identity.schnorrSigningKey())
        viewModel.handleNostrEvent(signed)

        try? await Task.sleep(nanoseconds: 100_000_000)
        viewModel.publicMessagePipeline.flushIfNeeded()

        #expect(!viewModel.messages.contains { $0.content == "Self echo" })
    }

    @Test @MainActor
    func handleNostrEvent_skipsBlockedSender() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let blockedIdentity = try NostrIdentity.generate()
        let blockedPubkey = blockedIdentity.publicKeyHex

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)

        let event = NostrEvent(
            pubkey: blockedPubkey,
            createdAt: Date(),
            kind: .ephemeralEvent,
            tags: [["g", geohash]],
            content: "Blocked"
        )
        let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
        viewModel.handleNostrEvent(signed)

        try? await Task.sleep(nanoseconds: 100_000_000)
        viewModel.publicMessagePipeline.flushIfNeeded()

        #expect(!viewModel.messages.contains { $0.content == "Blocked" })
    }

    @Test @MainActor
    func handleNostrEvent_rejectsInvalidSignature() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let identity = try NostrIdentity.generate()

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))

        let event = NostrEvent(
            pubkey: identity.publicKeyHex,
            createdAt: Date(),
            kind: .ephemeralEvent,
            tags: [["g", geohash]],
            content: "Valid"
        )
        var signed = try event.sign(with: identity.schnorrSigningKey())
        signed.id = "deadbeef"

        viewModel.handleNostrEvent(signed)

        try? await Task.sleep(nanoseconds: 100_000_000)
        viewModel.publicMessagePipeline.flushIfNeeded()

        #expect(!viewModel.messages.contains { $0.content == "Tampered" })
    }

    @Test @MainActor
    func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()

        let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
        let content = "bitchat1:" + base64URLEncode(oversized)
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.subscribeGiftWrap(giftWrap, id: recipient)

        try? await Task.sleep(nanoseconds: 100_000_000)
        #expect(viewModel.privateChats.isEmpty)
    }

    @Test @MainActor
    func switchLocationChannel_clearsNostrDedupCache() async {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"

        viewModel.deduplicationService.recordNostrEvent("evt-cache")
        #expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))

        #expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
    }

    @Test @MainActor
    func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let identity = try NostrIdentity.generate()

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))

        let event = NostrEvent(
            pubkey: identity.publicKeyHex,
            createdAt: Date(),
            kind: .geohashPresence,
            tags: [["g", geohash]],
            content: ""
        )
        let signed = try event.sign(with: identity.schnorrSigningKey())

        viewModel.handleNostrEvent(signed)

        try? await Task.sleep(nanoseconds: 50_000_000)
        #expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
        viewModel.publicMessagePipeline.flushIfNeeded()
        #expect(viewModel.messages.isEmpty)
    }

    @Test @MainActor
    func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()
        let convKey = PeerID(nostr_: sender.publicKeyHex)
        let messageID = "geo-ack-delivered"

        viewModel.seedPrivateChat([
            BitchatMessage(
                id: messageID,
                sender: viewModel.nickname,
                content: "Hello",
                timestamp: Date(),
                isRelay: false,
                isPrivate: true,
                recipientNickname: "Friend",
                senderPeerID: viewModel.meshService.myPeerID,
                deliveryStatus: .sent
            )
        ], for: convKey)

        let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.subscribeGiftWrap(giftWrap, id: recipient)

        let didUpdate = await TestHelpers.waitUntil(
            { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
            timeout: 5.0
        )
        #expect(didUpdate)
    }

    @Test @MainActor
    func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()
        let convKey = PeerID(nostr_: sender.publicKeyHex)
        let messageID = "geo-ack-read"

        viewModel.seedPrivateChat([
            BitchatMessage(
                id: messageID,
                sender: viewModel.nickname,
                content: "Hello",
                timestamp: Date(),
                isRelay: false,
                isPrivate: true,
                recipientNickname: "Friend",
                senderPeerID: viewModel.meshService.myPeerID,
                deliveryStatus: .delivered(to: "Friend", at: Date())
            )
        ], for: convKey)

        let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.subscribeGiftWrap(giftWrap, id: recipient)

        let didUpdate = await TestHelpers.waitUntil(
            { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
            timeout: 5.0
        )
        #expect(didUpdate)
    }

    @Test @MainActor
    func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()
        let messageID = "gift-private"
        let convKey = PeerID(nostr_: sender.publicKeyHex)

        let content = try privateMessageContent(
            text: "Hello from gift wrap",
            messageID: messageID,
            senderPeerID: PeerID(str: "0123456789abcdef")
        )
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.handleGiftWrap(giftWrap, id: recipient)

        let didStore = await TestHelpers.waitUntil(
            { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
            timeout: 5.0
        )
        #expect(didStore)
        #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
    }

    @Test @MainActor
    func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()
        let messageID = "gift-blocked"
        let convKey = PeerID(nostr_: sender.publicKeyHex)

        viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)

        let content = try privateMessageContent(
            text: "Blocked",
            messageID: messageID,
            senderPeerID: PeerID(str: "0123456789abcdef")
        )
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.handleGiftWrap(giftWrap, id: recipient)

        try? await Task.sleep(nanoseconds: 50_000_000)
        #expect(viewModel.privateChats[convKey] == nil)
        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
    }

    @Test @MainActor
    func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let sender = try NostrIdentity.generate()
        let recipient = try NostrIdentity.generate()
        let convKey = PeerID(nostr_: sender.publicKeyHex)
        let messageID = "gift-delivered"

        viewModel.seedPrivateChat([
            BitchatMessage(
                id: messageID,
                sender: viewModel.nickname,
                content: "Hello",
                timestamp: Date(),
                isRelay: false,
                isPrivate: true,
                recipientNickname: "Friend",
                senderPeerID: viewModel.meshService.myPeerID,
                deliveryStatus: .sent
            )
        ], for: convKey)

        let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
        let giftWrap = try NostrProtocol.createPrivateMessage(
            content: content,
            recipientPubkey: recipient.publicKeyHex,
            senderIdentity: sender
        )

        viewModel.handleGiftWrap(giftWrap, id: recipient)

        let didUpdate = await TestHelpers.waitUntil(
            { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
            timeout: 5.0
        )
        #expect(didUpdate)
    }

    @Test @MainActor
    func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let identity = try NostrIdentity.generate()
        let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })

        FavoritesPersistenceService.shared.addFavorite(
            peerNoisePublicKey: noiseKey,
            peerNostrPublicKey: identity.npub,
            peerNickname: "Alice"
        )
        defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }

        #expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
    }

    @Test @MainActor
    func findNoiseKey_matchesFavoriteStoredAsHex() async {
        let (viewModel, _) = makeTestableViewModel()
        let nostrHex = String(repeating: "ab", count: 32)
        let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })

        FavoritesPersistenceService.shared.addFavorite(
            peerNoisePublicKey: noiseKey,
            peerNostrPublicKey: nostrHex,
            peerNickname: "Bob"
        )
        defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }

        #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
    }

    /// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
    /// out of the conversation transcript.
    @Test @MainActor
    func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let identity = try NostrIdentity.generate()
        let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })

        FavoritesPersistenceService.shared.addFavorite(
            peerNoisePublicKey: noiseKey,
            peerNostrPublicKey: identity.npub,
            peerNickname: "Alice"
        )
        defer {
            FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
            FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
        }

        // The inbound pipeline resolves a known sender to their noise-key ID.
        let convKey = PeerID(hexData: noiseKey)
        let payloadData = try #require(
            PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
        )
        let payload = NoisePayload(type: .privateMessage, data: payloadData)

        viewModel.handlePrivateMessage(
            payload,
            senderPubkey: identity.publicKeyHex,
            convKey: convKey,
            id: identity,
            messageTimestamp: Date()
        )

        let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
        #expect(relationship?.theyFavoritedUs == true)
        #expect(relationship?.isMutual == true)
        #expect(relationship?.peerNostrPublicKey == identity.npub)
        #expect(viewModel.privateChats[convKey, default: []].isEmpty)
    }

    @Test @MainActor
    func geohashDMHelpers_exposeMappingAndDisplayName() async {
        let (viewModel, _) = makeTestableViewModel()
        let nostrHex = String(repeating: "cd", count: 32)
        let convKey = PeerID(nostr_: nostrHex)

        viewModel.geoNicknames[nostrHex] = "Alice"
        viewModel.startGeohashDM(withPubkeyHex: nostrHex)

        #expect(viewModel.selectedPrivateChatPeer == convKey)
        #expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
        #expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
        #expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
    }
}

// MARK: - Geohash Queue Tests

struct ChatViewModelGeohashQueueTests {

    @Test @MainActor
    func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"

        viewModel.addGeohashOnlySystemMessage("Queued system")
        #expect(!viewModel.messages.contains { $0.content == "Queued system" })

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))

        #expect(viewModel.messages.contains { $0.content == "Queued system" })
    }
}

// MARK: - GeoDM Tests

struct ChatViewModelGeoDMTests {

    @Test @MainActor
    func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
        let messageID = "pm-1"

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)

        let convKey = PeerID(nostr_: senderPubkey)
        let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
        let payloadData = try #require(packet.encode(), "Failed to encode private message")
        let payload = NoisePayload(type: .privateMessage, data: payloadData)

        viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
        viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())

        #expect(viewModel.privateChats[convKey]?.count == 1)
        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
    }

    @Test @MainActor
    func sendGeohashDM_requiresActiveLocationChannel() async {
        let (viewModel, _) = makeTestableViewModel()
        let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")

        viewModel.sendGeohashDM("hello", to: convKey)

        // The failure is surfaced inside the geoDM thread, not on the public
        // timeline (matches the sibling in-thread errors from #1415).
        #expect(viewModel.messages.isEmpty)
        #expect(viewModel.privateChats[convKey]?.count == 1)
        #expect(viewModel.privateChats[convKey]?.last?.sender == "system")
    }

    @Test @MainActor
    func sendGeohashDM_missingRecipientMapping_marksFailed() async {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        viewModel.sendGeohashDM("hello", to: convKey)

        #expect(viewModel.privateChats[convKey]?.count == 1)
        #expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
    }

    /// The blocked notice belongs in the DM thread the person is typing in,
    /// not on the active location-channel timeline.
    @Test @MainActor
    func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessageInThread() async {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
        let convKey = PeerID(nostr_: recipientHex)

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        viewModel.registerNostrKeyMapping(recipientHex, for: convKey)
        viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)

        viewModel.sendGeohashDM("hello", to: convKey)

        let thread = viewModel.privateChats[convKey] ?? []
        #expect(thread.count == 2)
        #expect(isFailed(status: thread.first?.deliveryStatus))
        #expect(thread.last?.sender == "system")
        #expect(!viewModel.messages.contains(where: { $0.sender == "system" }))
    }

    @Test @MainActor
    func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
        let convKey = PeerID(nostr_: senderPubkey)
        let messageID = "pm-viewing"

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        viewModel.selectedPrivateChatPeer = convKey

        let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
        let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
        let payloadData = try #require(packet.encode(), "Failed to encode private message")
        let payload = NoisePayload(type: .privateMessage, data: payloadData)

        viewModel.handlePrivateMessage(
            payload,
            senderPubkey: senderPubkey,
            convKey: convKey,
            id: identity,
            messageTimestamp: Date()
        )

        #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
        #expect(viewModel.sentReadReceipts.contains(messageID))
        #expect(!viewModel.unreadPrivateMessages.contains(convKey))
    }
}

// MARK: - Single-Writer Intent Operation Tests

/// Contracts for the owner-side intent ops that are the sole mutation paths
/// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`,
/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo
/// subscription IDs, and the selected private chat hand-off).
struct ChatViewModelIntentOperationTests {

    @Test @MainActor
    func markGeoDeliveryAckSent_returnsFalseOnSecondCall() async {
        let (viewModel, _) = makeTestableViewModel()

        #expect(viewModel.markGeoDeliveryAckSent("geo-ack-1"))
        #expect(!viewModel.markGeoDeliveryAckSent("geo-ack-1"))
        #expect(viewModel.markGeoDeliveryAckSent("geo-ack-2"))
        #expect(viewModel.sentGeoDeliveryAcks == ["geo-ack-1", "geo-ack-2"])
    }

    @Test @MainActor
    func markReadReceiptSent_returnsFalseOnSecondCall() async {
        let (viewModel, _) = makeTestableViewModel()

        #expect(viewModel.markReadReceiptSent("read-1"))
        #expect(!viewModel.markReadReceiptSent("read-1"))
        #expect(viewModel.sentReadReceipts.contains("read-1"))
    }

    @Test @MainActor
    func pruneSentReadReceipts_dropsStaleIDsAndReturnsRemovedCount() async {
        let (viewModel, _) = makeTestableViewModel()
        viewModel.sentReadReceipts = ["keep-1", "keep-2", "drop-1", "drop-2"]

        let removed = viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2", "unrelated"])

        #expect(removed == 2)
        #expect(viewModel.sentReadReceipts == ["keep-1", "keep-2"])
        // Nothing stale left: a second prune removes nothing.
        #expect(viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2"]) == 0)
    }

    @Test @MainActor
    func registerNostrKeyMapping_isVisibleToNostrCoordinatorLookups() async {
        let (viewModel, _) = makeTestableViewModel()
        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
        let convKey = PeerID(nostr_: hex)

        // Registered through the owner intent op (as e.g. the private
        // conversation flow does) and resolved through the Nostr coordinator,
        // which reads the same backing dictionary via `ChatNostrContext`.
        viewModel.registerNostrKeyMapping(hex, for: convKey)

        #expect(viewModel.nostrKeyMapping[convKey] == hex)
        #expect(viewModel.nostrCoordinator.fullNostrHex(forSenderPeerID: convKey) == hex)
    }

    @Test @MainActor
    func removeNostrKeyMappings_dropsEveryMappingForThePubkeyCaseInsensitively() async {
        let (viewModel, _) = makeTestableViewModel()
        let hex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
        let otherHex = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
        viewModel.registerNostrKeyMapping(hex.uppercased(), for: PeerID(nostr: hex))
        viewModel.registerNostrKeyMapping(hex, for: PeerID(nostr_: hex))
        viewModel.registerNostrKeyMapping(otherHex, for: PeerID(nostr_: otherHex))

        viewModel.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)

        #expect(viewModel.nostrKeyMapping[PeerID(nostr: hex)] == nil)
        #expect(viewModel.nostrKeyMapping[PeerID(nostr_: hex)] == nil)
        #expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex)
    }

    @Test @MainActor
    func setPublicBatching_publishesBatchingState() async {
        let (viewModel, _) = makeTestableViewModel()

        #expect(!viewModel.isBatchingPublic)
        viewModel.setPublicBatching(true)
        #expect(viewModel.isBatchingPublic)
        viewModel.setPublicBatching(false)
        #expect(!viewModel.isBatchingPublic)
    }

    @Test @MainActor
    func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async {
        let (viewModel, _) = makeTestableViewModel()

        viewModel.setGeoChatSubscriptionID("geo-u4pruy")
        viewModel.setGeoDmSubscriptionID("geo-dm-u4pruy")
        #expect(viewModel.geoSubscriptionID == "geo-u4pruy")
        #expect(viewModel.geoDmSubscriptionID == "geo-dm-u4pruy")

        viewModel.setGeoChatSubscriptionID(nil)
        viewModel.setGeoDmSubscriptionID(nil)
        #expect(viewModel.geoSubscriptionID == nil)
        #expect(viewModel.geoDmSubscriptionID == nil)

        viewModel.addGeoSamplingSub("geo-sample-aaaa", forGeohash: "aaaa")
        viewModel.addGeoSamplingSub("geo-sample-bbbb", forGeohash: "bbbb")
        viewModel.removeGeoSamplingSub("geo-sample-aaaa")
        #expect(viewModel.geoSamplingSubs == ["geo-sample-bbbb": "bbbb"])

        let cleared = viewModel.clearGeoSamplingSubs()
        #expect(cleared == ["geo-sample-bbbb"])
        #expect(viewModel.geoSamplingSubs.isEmpty)
    }

    @Test @MainActor
    func handOffSelectedPrivateChat_movesSelectionOnlyWhenSelectedPeerIsMigrated() async {
        let (viewModel, _) = makeTestableViewModel()
        let oldPeer = PeerID(str: "aaaaaaaaaaaaaaaa")
        let unrelatedPeer = PeerID(str: "cccccccccccccccc")
        let newPeer = PeerID(str: "bbbbbbbbbbbbbbbb")

        // Selection not among the migrated peers: untouched.
        viewModel.selectedPrivateChatPeer = unrelatedPeer
        viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
        #expect(viewModel.selectedPrivateChatPeer == unrelatedPeer)

        // Selection being migrated away: handed off to the new peer.
        viewModel.selectedPrivateChatPeer = oldPeer
        viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
        #expect(viewModel.selectedPrivateChatPeer == newPeer)
    }
}

@Suite(.serialized)
struct ChatViewModelMediaTransferTests {

    @Test @MainActor
    func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
        let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
        let transferID = "transfer-1"

        viewModel.registerTransfer(transferId: transferID, messageID: message.id)
        viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
        #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))

        viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
        #expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))

        viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
        #expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
        #expect(viewModel.messageIDToTransferId[message.id] == nil)
        #expect(viewModel.transferIdToMessageIDs[transferID] == nil)
    }

    @Test @MainActor
    func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
        let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
        let transferID = "transfer-2"

        viewModel.registerTransfer(transferId: transferID, messageID: message.id)
        viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))

        #expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
        #expect(viewModel.messageIDToTransferId[message.id] == nil)
    }

    @Test @MainActor
    func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")

        try Data("voice".utf8).write(to: url)
        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))

        viewModel.sendVoiceNote(at: url)

        #expect(!FileManager.default.fileExists(atPath: url.path))
        #expect(viewModel.messages.contains(where: { $0.sender == "system" }))
    }

    @Test @MainActor
    func sendImage_outsideAllowedContextRunsCleanup() async {
        let (viewModel, _) = makeTestableViewModel()
        let geohash = "u4pruydq"
        var cleanupCalled = false

        viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
        viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
            cleanupCalled = true
        }

        #expect(cleanupCalled)
        #expect(viewModel.messages.contains(where: { $0.sender == "system" }))
    }

    @Test @MainActor
    func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
        let (viewModel, transport) = makeTestableViewModel()
        let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
        try Data("voice payload".utf8).write(to: url, options: .atomic)
        defer { try? FileManager.default.removeItem(at: url) }

        viewModel.selectedPrivateChatPeer = peerID
        viewModel.sendVoiceNote(at: url)

        // Media sends hop through Task.detached; the global executor is
        // shared with every parallel test worker, so a loaded runner can
        // exceed the 5s default. waitUntil returns as soon as the condition
        // holds, so passing runs never pay the longer timeout.
        let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
        #expect(didSend)
        #expect(transport.sentPrivateFiles.first?.peerID == peerID)
        #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
        #expect(viewModel.messageIDToTransferId.count == 1)
        #expect(viewModel.transferIdToMessageIDs.count == 1)
    }

    @Test @MainActor
    func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
        let (viewModel, transport) = makeTestableViewModel()
        let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
        try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)

        viewModel.selectedPrivateChatPeer = peerID
        viewModel.sendVoiceNote(at: url)

        let didFail = await TestHelpers.waitUntil({
            isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
        }, timeout: TestConstants.longTimeout)
        #expect(didFail)
        #expect(!FileManager.default.fileExists(atPath: url.path))
        #expect(transport.sentPrivateFiles.isEmpty)
    }

    @Test @MainActor
    func sendImage_privateChatProcessesAndTransfersImage() async throws {
        let (viewModel, transport) = makeTestableViewModel()
        let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
        let sourceURL = try makeTemporaryImageURL()
        defer { try? FileManager.default.removeItem(at: sourceURL) }

        viewModel.selectedPrivateChatPeer = peerID
        viewModel.sendImage(from: sourceURL)

        let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
        #expect(didSend)
        #expect(transport.sentPrivateFiles.first?.peerID == peerID)
        #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
        #expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
        #expect(viewModel.messageIDToTransferId.count == 1)
    }

    @Test @MainActor
    func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
        let (viewModel, transport) = makeTestableViewModel()
        let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
        try Data("not-an-image".utf8).write(to: url, options: .atomic)
        defer { try? FileManager.default.removeItem(at: url) }

        viewModel.selectedPrivateChatPeer = peerID
        viewModel.sendImage(from: url)

        let didNotify = await TestHelpers.waitUntil({
            viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
        }, timeout: TestConstants.longTimeout)
        #expect(didNotify)
        #expect(transport.sentPrivateFiles.isEmpty)
        #expect(viewModel.privateChats[peerID]?.isEmpty != false)
    }

    @Test @MainActor
    func clearTransferMapping_promotesQueuedTransferForSameID() async {
        let (viewModel, _) = makeTestableViewModel()
        viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
        viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")

        viewModel.clearTransferMapping(for: "first")

        #expect(viewModel.messageIDToTransferId["first"] == nil)
        #expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
        #expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
    }

    @Test @MainActor
    func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
        let (viewModel, transport) = makeTestableViewModel()
        let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
        let fileName = "cancel-\(UUID().uuidString).m4a"
        let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
        try Data("cancel me".utf8).write(to: fileURL, options: .atomic)

        let message = BitchatMessage(
            id: "cancel-msg",
            sender: viewModel.nickname,
            content: "[voice] \(fileName)",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            recipientNickname: "Peer",
            senderPeerID: viewModel.meshService.myPeerID,
            deliveryStatus: .sending
        )
        viewModel.seedPrivateChat([message], for: peerID)
        viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)

        viewModel.cancelMediaSend(messageID: message.id)

        #expect(transport.cancelledTransfers == ["transfer-cancel"])
        #expect(viewModel.privateChats[peerID] == nil)
        #expect(!FileManager.default.fileExists(atPath: fileURL.path))
    }

    @Test @MainActor
    func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
        let (viewModel, _) = makeTestableViewModel()
        let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
        let fileName = "delete-\(UUID().uuidString).jpg"
        let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
        try Data("image bytes".utf8).write(to: fileURL, options: .atomic)

        let message = BitchatMessage(
            id: "delete-msg",
            sender: viewModel.nickname,
            content: "[image] \(fileName)",
            timestamp: Date(),
            isRelay: false,
            isPrivate: true,
            recipientNickname: "Peer",
            senderPeerID: viewModel.meshService.myPeerID,
            deliveryStatus: .sent
        )
        viewModel.seedPrivateChat([message], for: peerID)
        viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)

        viewModel.deleteMediaMessage(messageID: message.id)

        #expect(viewModel.privateChats[peerID] == nil)
        #expect(viewModel.messageIDToTransferId[message.id] == nil)
        #expect(!FileManager.default.fileExists(atPath: fileURL.path))
    }

    @Test @MainActor
    func makeTransferID_isPrefixedByMessageIDAndUnique() async {
        let (viewModel, _) = makeTestableViewModel()

        let first = viewModel.makeTransferID(messageID: "base")
        let second = viewModel.makeTransferID(messageID: "base")

        #expect(first.hasPrefix("base-"))
        #expect(second.hasPrefix("base-"))
        #expect(first != second)
    }
}

private func base64URLEncode(_ data: Data) -> String {
    data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")
}

private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
    if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
        type: type,
        messageID: messageID,
        senderPeerID: senderPeerID
    ) {
        return content
    }
    throw ChatViewModelExtensionsTestError.invalidAckContent
}

private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
    if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
        content: text,
        messageID: messageID,
        senderPeerID: senderPeerID
    ) {
        return content
    }
    throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}

@MainActor
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
    viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
}

private func isFailed(status: DeliveryStatus?) -> Bool {
    if case .failed = status {
        return true
    }
    return false
}

private func isDelivered(status: DeliveryStatus?) -> Bool {
    if case .delivered = status {
        return true
    }
    return false
}

private func isRead(status: DeliveryStatus?) -> Bool {
    if case .read = status {
        return true
    }
    return false
}

private func isSent(status: DeliveryStatus?) -> Bool {
    if case .sent = status {
        return true
    }
    return false
}

private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
    if case .partiallyDelivered(let actualReached, let actualTotal) = status {
        return actualReached == reached && actualTotal == total
    }
    return false
}

private enum ChatViewModelExtensionsTestError: Error {
    case invalidAckContent
    case invalidPrivateMessageContent
}

private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
    let base = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    ).appendingPathComponent("files", isDirectory: true)
    let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
    try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
    return directory.appendingPathComponent(fileName)
}

private func makeTemporaryImageURL() throws -> URL {
    let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
    let data = try makeImageData()
    try data.write(to: url, options: .atomic)
    return url
}

private func makeImageData() throws -> Data {
    #if os(iOS)
    let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
        UIColor.systemTeal.setFill()
        context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
    }
    guard let data = image.pngData() else {
        throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
    }
    return data
    #else
    let image = NSImage(size: CGSize(width: 64, height: 64))
    image.lockFocus()
    NSColor.systemTeal.setFill()
    NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
    image.unlockFocus()
    guard
        let tiffData = image.tiffRepresentation,
        let bitmap = NSBitmapImageRep(data: tiffData),
        let data = bitmap.representation(using: .png, properties: [:])
    else {
        throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
    }
    return data
    #endif
}
