import BitFoundation
import BitLogger
import Combine
import Foundation

struct ChatViewModelServiceBundle {
    let commandProcessor: CommandProcessor
    let messageRouter: MessageRouter
    let privateChatManager: PrivateChatManager
    let unifiedPeerService: UnifiedPeerService
    let autocompleteService: AutocompleteService
    let deduplicationService: MessageDeduplicationService
    let publicMessagePipeline: PublicMessagePipeline

    @MainActor
    init(
        keychain: KeychainManagerProtocol,
        idBridge: NostrIdentityBridge,
        identityManager: SecureIdentityStateManagerProtocol,
        meshService: Transport,
        outboxStore: MessageOutboxStore? = nil,
        sfMetrics: StoreAndForwardMetrics? = nil
    ) {
        let commandProcessor = CommandProcessor(identityManager: identityManager)
        let privateChatManager = PrivateChatManager(meshService: meshService)
        let unifiedPeerService = UnifiedPeerService(
            meshService: meshService,
            idBridge: idBridge,
            identityManager: identityManager
        )
        let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
        nostrTransport.senderPeerID = meshService.myPeerID
        let messageRouter = MessageRouter(
            transports: [meshService, nostrTransport],
            outboxStore: outboxStore,
            metrics: sfMetrics
        )

        self.commandProcessor = commandProcessor
        self.messageRouter = messageRouter
        self.privateChatManager = privateChatManager
        self.unifiedPeerService = unifiedPeerService
        self.autocompleteService = AutocompleteService()
        // Persist processed gift-wrap event IDs: NIP-59 randomizes their
        // timestamps, so the 24h-lookback DM subscriptions redeliver the same
        // events on every launch and only a cross-launch record stops the
        // reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
        self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
        self.publicMessagePipeline = PublicMessagePipeline()
    }
}

@MainActor
final class ChatViewModelBootstrapper {
    private unowned let viewModel: ChatViewModel

    init(viewModel: ChatViewModel) {
        self.viewModel = viewModel
    }

    static func loadPersistedReadReceipts(userDefaults: UserDefaults = .standard) -> Set<String> {
        guard let data = userDefaults.data(forKey: "sentReadReceipts"),
              let receipts = try? JSONDecoder().decode([String].self, from: data) else {
            return []
        }
        return Set(receipts)
    }

    func configure() {
        wireServiceGraph()
        bindFeatureObjectChanges()
        loadPersistedViewState()
        configureTransport()
        startRuntimeServices()
        bindPeerService()
        configureNoiseCallbacks()
        bindTransferProgress()
        configureGeoChannels()
        configureGateway()
        configureBridge()
        configureBridgeCourier()
        bindTeleportState()
        requestNotifications()
        registerObservers()
    }
}

private extension ChatViewModelBootstrapper {
    func wireServiceGraph() {
        viewModel.privateChatManager.conversationStore = viewModel.conversations
        viewModel.privateChatManager.messageRouter = viewModel.messageRouter
        viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
        viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
        // Surface silent outbox drops (attempt cap, TTL expiry, overflow
        // eviction) as a visible failure. The store's no-downgrade rule does
        // not cover `.failed` over confirmed receipts, so guard here: a drop
        // of an already-delivered/read message (e.g. a stale retained copy)
        // must not downgrade its status.
        viewModel.messageRouter.onMessageDropped = { [weak viewModel] messageID, peerID in
            guard let viewModel else { return }
            switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
            case .delivered, .read:
                // Field proof of the no-downgrade guard: the drop arrived
                // after a confirmed receipt, so the `.failed` write is
                // deliberately skipped.
                SecureLogger.warning(
                    "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → .failed skipped (already delivered/read)",
                    category: .session
                )
            default:
                SecureLogger.warning(
                    "📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → marked failed",
                    category: .session
                )
                viewModel.conversations.setDeliveryStatus(
                    .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
                    forMessageID: messageID
                )
            }
        }
        // A message with no reachable transport that was handed to a courier
        // shows a distinct "carried" state instead of sitting in "sending"
        // forever. Never downgrade a confirmed receipt: the courier copy can
        // race direct delivery when the peer reappears.
        viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
            guard let viewModel else { return }
            switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
            case .delivered, .read:
                break
            default:
                SecureLogger.debug(
                    "📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
                    category: .session
                )
                viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
            }
        }
        viewModel.commandProcessor.contextProvider = viewModel
        viewModel.commandProcessor.meshService = viewModel.meshService
        viewModel.participantTracker.configure(context: viewModel)
    }

    func bindFeatureObjectChanges() {
        viewModel.privateChatManager.objectWillChange
            .sink { [weak viewModel] _ in
                viewModel?.objectWillChange.send()
            }
            .store(in: &viewModel.cancellables)

        // Private message state flows through the single-writer
        // `ConversationStore` intents and its `changes` subject; selection
        // is owned by the store too (`PrivateChatManager.selectedPeer` is a
        // read-only mirror), so no selection bridge is needed here.
        viewModel.participantTracker.objectWillChange
            .sink { [weak viewModel] _ in
                viewModel?.objectWillChange.send()
            }
            .store(in: &viewModel.cancellables)

        viewModel.participantTracker.$visiblePeople
            .receive(on: DispatchQueue.main)
            .sink { [weak viewModel] people in
                Task { @MainActor [weak viewModel] in
                    let visible = Set(people.map { $0.id })
                    viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
                    viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
                }
            }
            .store(in: &viewModel.cancellables)
    }

    func loadPersistedViewState() {
        viewModel.loadNickname()
        viewModel.loadVerifiedFingerprints()
    }

    func configureTransport() {
        viewModel.meshService.delegate = viewModel
        viewModel.meshService.eventDelegate = viewModel

        DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
            guard let viewModel else { return }
            _ = viewModel.getMyFingerprint()
        }

        viewModel.meshService.setNickname(viewModel.nickname)
    }

    func startRuntimeServices() {
        viewModel.meshService.startServices()

        viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator

        loadArchivedEchoes()

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
            guard let viewModel,
                  let radio = viewModel.meshService as? BluetoothStateReporting else { return }
            viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
        }

        viewModel.nostrRelayManager = NostrRelayManager.shared
        viewModel.messageRouter.flushAllOutbox()

        Task { @MainActor [weak viewModel] in
            guard let viewModel else { return }
            try? await Task.sleep(
                nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)
            )
            viewModel.isStartupPhase = false
        }
    }

    /// Surfaces the carried store-and-forward window (up to 6h of public
    /// mesh messages, persisted across restarts) as dimmed "heard here
    /// earlier" rows, so the mesh timeline opens with the place's memory
    /// instead of a void. The archive restore runs async on the sync queue
    /// right after transport start, so give it a beat before asking.
    private func loadArchivedEchoes() {
        DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
            guard let viewModel,
                  let archive = viewModel.meshService as? MeshPublicArchiving else { return }
            archive.collectArchivedPublicMessages { [weak viewModel] allArchived in
                guard let viewModel else { return }
                // A previous /clear dismissed everything heard up to its
                // watermark; only newer archive entries come back. Blocking a
                // peer purges their carried messages from the archive at
                // block time (when the fingerprint↔peerID mapping is known);
                // the filter here is defense-in-depth for entries that slip
                // past the purge (e.g. re-synced from a nearby peer), and it
                // only resolves connected peers or favorites.
                let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast
                let archived = allArchived.filter {
                    $0.timestamp > clearedThrough && !viewModel.isPeerBlocked($0.senderPeerID)
                }
                guard !archived.isEmpty else { return }
                // Seed only an untouched timeline: with live rows already
                // present (or after /clear) splicing history back in would
                // be wrong.
                guard viewModel.conversations.conversationsByID[.mesh]?.messages.isEmpty != false else { return }

                for item in archived {
                    let echo = BitchatMessage(
                        id: BitchatMessage.archivedEchoIDPrefix + item.packetIdHex,
                        sender: item.senderNickname,
                        content: item.content,
                        timestamp: item.timestamp,
                        isRelay: false,
                        senderPeerID: item.senderPeerID
                    )
                    viewModel.publicConversationCoordinator.registerArchivedEcho(
                        senderPeerID: item.senderPeerID,
                        timestamp: item.timestamp,
                        content: item.content
                    )
                    _ = viewModel.appendPublicMessage(echo, to: .mesh)
                }

                if let firstTimestamp = archived.map(\.timestamp).min() {
                    // Echo-prefixed ID so the divider joins the tinted,
                    // dimmed echo block in the timeline.
                    let divider = BitchatMessage(
                        id: BitchatMessage.archivedEchoIDPrefix + "divider",
                        sender: "system",
                        content: String(localized: "content.echoes.divider", comment: "System line shown above dimmed archived messages replayed on the mesh timeline at launch"),
                        timestamp: firstTimestamp.addingTimeInterval(-1),
                        isRelay: false
                    )
                    _ = viewModel.appendPublicMessage(divider, to: .mesh)
                }
            }
        }
    }

    func bindPeerService() {
        viewModel.unifiedPeerService.$peers
            .receive(on: DispatchQueue.main)
            .sink { [weak viewModel] peers in
                Task { @MainActor [weak viewModel] in
                    guard let viewModel else { return }

                    viewModel.allPeers = peers

                    var uniquePeers: [PeerID: BitchatPeer] = [:]
                    for peer in peers {
                        if uniquePeers[peer.peerID] == nil {
                            uniquePeers[peer.peerID] = peer
                        } else {
                            SecureLogger.warning(
                                "⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))",
                                category: .session
                            )
                        }
                    }
                    viewModel.peerIndex = uniquePeers

                    if viewModel.hasTrackedPrivateChatSelection {
                        viewModel.updatePrivateChatPeerIfNeeded()
                    }
                }
            }
            .store(in: &viewModel.cancellables)
    }

    func configureNoiseCallbacks() {
        viewModel.setupNoiseCallbacks()
    }

    func bindTransferProgress() {
        TransferProgressManager.shared.publisher
            .receive(on: DispatchQueue.main)
            .sink { [weak viewModel] event in
                Task { @MainActor [weak viewModel] in
                    viewModel?.handleTransferEvent(event)
                }
            }
            .store(in: &viewModel.cancellables)
    }

    func configureGeoChannels() {
        viewModel.geoChannelCoordinator = GeoChannelCoordinator(
            locationManager: viewModel.locationManager,
            context: viewModel
        )
    }

    /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
    /// transport, the relay manager, and the inbound Nostr pipeline. All
    /// dependencies are closures so the service stays unit-testable with
    /// fakes.
    func configureGateway() {
        // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
        // has no carrier packets to bridge.
        guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
        let gateway = GatewayService.shared

        gateway.publishToRelays = { event, geohash in
            let relays = GeoRelayDirectory.shared.closestRelays(
                toGeohash: geohash,
                count: TransportConfig.nostrGeoRelayCount
            )
            // Symmetric with the local send path (GeohashSubscriptionManager
            // .sendGeohash): with no known geo relay, refuse rather than
            // publish to default relays no geo subscriber reads — that would
            // be silent dead traffic, not delivery.
            guard !relays.isEmpty else {
                SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
                return
            }
            NostrRelayManager.shared.sendEvent(event, to: relays)
        }
        gateway.broadcastToMesh = { [weak bleService] payload in
            bleService?.broadcastNostrCarrier(payload)
        }
        gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
            bleService?.sendNostrCarrier(payload, to: peer) ?? false
        }
        gateway.availableGatewayPeers = { [weak bleService] in
            bleService?.reachableGatewayPeers() ?? []
        }
        gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
        gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
        // Carried events enter the same pipeline as relay-received events so
        // blocking, rate limits, dedup, and rendering behave identically.
        gateway.injectInbound = { [weak viewModel] event in
            viewModel?.handleNostrEvent(event)
        }
        // The capability bit is advertised ONLY while the toggle is on; a
        // change forces a re-announce so peers learn promptly.
        gateway.onEnabledChanged = { [weak bleService] enabled in
            bleService?.setLocalCapability(.gateway, enabled: enabled)
        }
        bleService.onNostrCarrierPacket = { payload, from, directedToUs in
            // One decode, two policy engines: geohash-channel carriers go to
            // the gateway, mesh-bridge carriers to the bridge.
            guard let carrier = NostrCarrierPacket.decode(payload) else {
                SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(from.id.prefix(8))…", category: .session)
                return
            }
            switch carrier.direction {
            case .toGateway, .fromGateway:
                GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
            case .toBridge, .fromBridge:
                BridgeService.shared.handleMeshCarrier(carrier, from: from, directedToUs: directedToUs)
            }
        }

        // Uplinks deposited while relays were unreachable flush on reconnect.
        // The publisher re-emits `true` on every relay state recompute, so
        // dedupe: field logs showed presence published 5x in one second.
        NostrRelayManager.shared.$isConnected
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink { connected in
                if connected {
                    GatewayService.shared.flushQueuedUplinks()
                    BridgeService.shared.flushQueuedUplinks()
                    BridgeService.shared.publishPresence()
                }
            }
            .store(in: &viewModel.cancellables)

        // Apply the persisted toggle at launch.
        if gateway.isEnabled {
            bleService.setLocalCapability(.gateway, enabled: true)
        }
    }

    /// Wires the mesh-bridge policy layer (`BridgeService`) to the mesh
    /// transport, the relay manager, location, and the public timeline. Same
    /// closure-injection style as `configureGateway`.
    func configureBridge() {
        guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
        let bridge = BridgeService.shared
        let idBridge = viewModel.idBridge

        bridge.publishToRelays = { event, cell in
            let relays = GeoRelayDirectory.shared.closestRelays(
                toGeohash: cell,
                count: TransportConfig.nostrGeoRelayCount
            )
            guard !relays.isEmpty else {
                SecureLogger.warning("🌉 Bridge: no geo relays for cell \(cell); not publishing", category: .session)
                return
            }
            NostrRelayManager.shared.sendEvent(event, to: relays)
        }
        bridge.openSubscription = { cells in
            guard let cell = cells.first else { return }
            let relays = GeoRelayDirectory.shared.closestRelays(
                toGeohash: cell,
                count: TransportConfig.nostrGeoRelayCount
            )
            NostrRelayManager.shared.subscribe(
                filter: .bridgeRendezvous(cells, since: Date().addingTimeInterval(-BridgeService.Limits.maxEventAgeSeconds)),
                id: Self.bridgeSubscriptionID,
                relayUrls: relays.isEmpty ? nil : relays,
                handler: { event in
                    BridgeService.shared.handleRendezvousEvent(event)
                }
            )
        }
        bridge.closeSubscription = {
            NostrRelayManager.shared.unsubscribe(id: Self.bridgeSubscriptionID)
        }
        bridge.relaysConnected = { NostrRelayManager.shared.isConnected }
        bridge.locationCell = { [weak viewModel] in
            viewModel?.locationManager.availableChannels
                .first { $0.level == .neighborhood }?
                .geohash
        }
        bridge.requestLocationFix = { [weak viewModel] in
            viewModel?.locationManager.refreshChannels()
        }
        bridge.meshAdvertisedCell = { [weak bleService] in
            bleService?.advertisedBridgeGeohash()
        }
        bridge.sendToBridgePeer = { [weak bleService] payload, peer in
            bleService?.sendNostrCarrier(payload, to: peer) ?? false
        }
        bridge.availableBridgePeers = { [weak bleService] in
            bleService?.reachableBridgePeers() ?? []
        }
        bridge.broadcastToMesh = { [weak bleService] payload in
            bleService?.broadcastNostrCarrier(payload)
        }
        bridge.injectInbound = { [weak viewModel] inbound in
            viewModel?.handlePublicMessage(BitchatMessage(
                id: inbound.messageID,
                sender: inbound.senderNickname,
                content: inbound.content,
                timestamp: inbound.timestamp,
                isRelay: false,
                senderPeerID: PeerID(bridge: inbound.senderPubkey),
                isBridged: true
            ))
        }
        bridge.removeInjectedInbound = { [weak viewModel] messageID in
            viewModel?.removeBridgeInjectedPublicMessage(withID: messageID)
        }
        bridge.isInjectedInboundPresent = { [weak viewModel] messageID in
            viewModel?.bridgeInjectedPublicMessageIsPresent(withID: messageID) ?? false
        }
        bridge.isMessageSeenLocally = { [weak viewModel] messageID in
            viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
        }
        bridge.deriveIdentity = { cell in
            try idBridge.deriveIdentity(forBridgeRendezvous: cell)
        }
        bridge.myNickname = { [weak viewModel] in viewModel?.nickname ?? "" }

        // The `.bridge` capability + cell TLV advertise serving duty: "send
        // me deposits, and this is the island's cell". One switch: bridging
        // with a known cell is serving (deposits queue through connectivity
        // gaps, so the advertisement doesn't flap with the relays).
        let updateAdvertisement: @MainActor () -> Void = { [weak bleService] in
            let advertise = BridgeService.shared.isEnabled
                && BridgeService.shared.activeCell != nil
            bleService?.setLocalBridgeGeohash(advertise ? BridgeService.shared.activeCell : nil)
            bleService?.setLocalCapability(.bridge, enabled: advertise)
        }
        bridge.onEnabledChanged = { [weak viewModel] enabled in
            updateAdvertisement()
            // One switch collapses further: the bridge toggle also drives
            // the geohash-channel gateway — bridging with internet means
            // sharing it with the mesh around you, full stop.
            GatewayService.shared.setEnabled(enabled)
            // Flipping the switch is the user-initiated moment to ask for
            // location if it was never asked; otherwise the bridge sits
            // cell-less with only a settings caption explaining why.
            if enabled, viewModel?.locationManager.permissionState == .notDetermined {
                viewModel?.locationManager.enableLocationChannels()
            }
        }
        bridge.onActiveCellChanged = { _ in updateAdvertisement() }
        // Align a persisted split state (e.g. gateway enabled back when it
        // had its own toggle) to the single switch at launch.
        if GatewayService.shared.isEnabled != bridge.isEnabled {
            GatewayService.shared.setEnabled(bridge.isEnabled)
        }

        // Location fixes (or losing them) move the rendezvous cell.
        viewModel.locationManager.$availableChannels
            .receive(on: DispatchQueue.main)
            .sink { _ in BridgeService.shared.refreshRendezvous() }
            .store(in: &viewModel.cancellables)
        // The authorization callback lands asynchronously after launch; the
        // bootstrap-time location request races it and silently no-ops, so
        // re-enter when the permission state resolves (field bug: bridge
        // stayed cell-less for a whole session).
        viewModel.locationManager.$permissionState
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink { _ in BridgeService.shared.refreshRendezvous() }
            .store(in: &viewModel.cancellables)

        // Apply the persisted toggle at launch.
        if bridge.isEnabled {
            bridge.refreshRendezvous()
            updateAdvertisement()
        }
    }

    /// Wires courier-over-bridge (`BridgeCourierService`) to the relay
    /// manager, the mesh transport's sealing/opening primitives, the courier
    /// store, and the message router's deposit path.
    func configureBridgeCourier() {
        guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
        let courier = BridgeCourierService.shared

        courier.bridgeEnabled = { BridgeService.shared.isEnabled }
        // A geo/custom relay does not make a global courier drop durable.
        // Require an actually connected default (DM) relay so `sendEvent`
        // writes to at least one intended relay instead of only entering its
        // process-local pending queue.
        courier.relaysConnected = { NostrRelayManager.shared.isDMRelayConnected }
        courier.publishEvent = { event, completion in
            // Default (DM) relays: drops need the standing global relay set,
            // not geo relays — sender and recipient share no cell.
            // This confirmed path never falls back to the volatile relay
            // queue; bridge dedup is committed only after NIP-20 OK.
            NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
        }
        courier.openSubscription = { tagsHex in
            NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
            NostrRelayManager.shared.subscribe(
                filter: .courierDrops(
                    recipientTagsHex: tagsHex,
                    since: Date().addingTimeInterval(-CourierEnvelope.maxLifetimeSeconds)
                ),
                id: Self.courierDropSubscriptionID,
                handler: { event in
                    BridgeCourierService.shared.handleDropEvent(event)
                }
            )
        }
        courier.closeSubscription = {
            NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
        }
        courier.myNoiseKey = { [weak bleService] in
            bleService?.myNoiseStaticPublicKey()
        }
        courier.localVerifiedPeers = { [weak bleService] in
            bleService?.verifiedPeersWithNoiseKeys() ?? []
        }
        courier.sealEnvelope = { [weak bleService] content, messageID, recipientKey in
            bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
        }
        courier.openEnvelope = { [weak bleService] envelope in
            bleService?.openBridgedCourierEnvelope(envelope) ?? false
        }
        courier.deliverToPeer = { [weak bleService] envelope, peerID in
            bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
        }
        courier.heldEnvelopes = { cooldown in
            CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
        }
        courier.markHeldEnvelopePublished = { envelope in
            CourierStore.shared.markBridgePublished(envelope)
        }

        viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey, completion in
            BridgeCourierService.shared.depositDrop(
                content: content,
                messageID: messageID,
                recipientNoiseKey: recipientKey,
                completion: completion
            )
        }
        // The completion flows back only after a default relay accepts the
        // event, so a rejected or unacknowledged write never becomes carried.
        viewModel.messageRouter.startBridgeDepositSweep()
        bleService.onVerifiedPeerAnnounce = { _ in
            Task { @MainActor in
                BridgeCourierService.shared.refreshAfterVerifiedAnnounce()
            }
        }

        // Relay connectivity gates everything; refresh (re)opens or closes.
        // Deduped: refresh() resubscribes, and the raw publisher re-emits on
        // every relay state recompute (6x in 300ms in field logs).
        NostrRelayManager.shared.$isDMRelayConnected
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink { _ in BridgeCourierService.shared.refresh() }
            .store(in: &viewModel.cancellables)
        // Toggle changes re-evaluate the watch set.
        BridgeService.shared.$isEnabled
            .dropFirst()
            .receive(on: DispatchQueue.main)
            .sink { _ in BridgeCourierService.shared.refresh() }
            .store(in: &viewModel.cancellables)

        courier.refresh()
    }

    private static let bridgeSubscriptionID = "bridge-rendezvous"
    private static let courierDropSubscriptionID = "bridge-courier-drops"

    func bindTeleportState() {
        viewModel.locationManager.$teleported
            .receive(on: DispatchQueue.main)
            .sink { [weak viewModel] isTeleported in
                guard let viewModel else { return }
                Task { @MainActor [weak viewModel] in
                    guard let viewModel,
                          case .location(let channel) = viewModel.activeChannel,
                          let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
                    else {
                        return
                    }

                    let key = identity.publicKeyHex.lowercased()
                    let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
                    let inRegional = viewModel.locationManager.availableChannels.contains {
                        $0.geohash == channel.geohash
                    }

                    if isTeleported && hasRegional && !inRegional {
                        viewModel.locationPresenceStore.markTeleported(key)
                    } else {
                        viewModel.locationPresenceStore.clearTeleported(key)
                    }
                }
            }
            .store(in: &viewModel.cancellables)
    }

    func requestNotifications() {
        NotificationService.shared.requestAuthorization()
    }

    func registerObservers() {
        NotificationCenter.default.addObserver(
            viewModel,
            selector: #selector(ChatViewModel.handleFavoriteStatusChanged(_:)),
            name: .favoriteStatusChanged,
            object: nil
        )

        NotificationCenter.default.addObserver(
            viewModel,
            selector: #selector(ChatViewModel.handlePeerStatusUpdate(_:)),
            name: Notification.Name("peerStatusUpdated"),
            object: nil
        )
    }
}
