package com.wisp.app.viewmodel

import android.util.Log
import com.wisp.app.nostr.Blossom
import com.wisp.app.nostr.ClientMessage
import com.wisp.app.nostr.Filter
import com.wisp.app.nostr.Nip30
import com.wisp.app.nostr.Nip51
import com.wisp.app.db.EventPersistence
import com.wisp.app.relay.OutboxRouter
import com.wisp.app.relay.RelayConfig
import com.wisp.app.relay.RelayHealthTracker
import com.wisp.app.relay.RelayLifecycleManager
import com.wisp.app.relay.RelayPool
import com.wisp.app.relay.RelayScoreBoard
import com.wisp.app.relay.SubscriptionManager
import com.wisp.app.repo.BlossomRepository
import com.wisp.app.repo.BookmarkRepository
import com.wisp.app.repo.BookmarkSetRepository
import com.wisp.app.repo.ContactRepository
import com.wisp.app.repo.CustomEmojiRepository
import com.wisp.app.repo.DeletedEventsRepository
import com.wisp.app.repo.DiscoveryState
import com.wisp.app.repo.DmRepository
import com.wisp.app.repo.EventRepository
import com.wisp.app.repo.ExtendedNetworkRepository
import com.wisp.app.repo.KeyRepository
import com.wisp.app.repo.ListRepository
import com.wisp.app.repo.MetadataFetcher
import com.wisp.app.repo.MuteRepository
import com.wisp.app.repo.Nip05Repository
import com.wisp.app.repo.NotificationRepository
import com.wisp.app.repo.NwcRepository
import com.wisp.app.repo.SparkRepository
import com.wisp.app.repo.WalletModeRepository
import com.wisp.app.repo.PinRepository
import com.wisp.app.repo.ProfileRepository
import com.wisp.app.repo.DiagnosticLogger
import com.wisp.app.repo.RelayHintStore
import com.wisp.app.repo.RelayInfoRepository
import com.wisp.app.repo.RelayListRepository
import com.wisp.app.repo.InterestRepository
import com.wisp.app.repo.LiveStreamRepository
import com.wisp.app.repo.RelaySetRepository
import com.wisp.app.repo.ZapPreferences
import com.wisp.app.nostr.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import kotlin.coroutines.CoroutineContext

/**
 * Orchestrates startup sequencing (cold/warm paths), self-data fetching,
 * relay pool building, account switching, app lifecycle, and feed caching.
 * Extracted from FeedViewModel to reduce its size.
 */
class StartupCoordinator(
    private val relayPool: RelayPool,
    private val outboxRouter: OutboxRouter,
    private val subManager: SubscriptionManager,
    private val eventRepo: EventRepository,
    private val eventPersistence: EventPersistence?,
    private val contactRepo: ContactRepository,
    private val muteRepo: MuteRepository,
    private val notifRepo: NotificationRepository,
    private val listRepo: ListRepository,
    private val bookmarkRepo: BookmarkRepository,
    private val bookmarkSetRepo: BookmarkSetRepository,
    private val relaySetRepo: RelaySetRepository,
    private val pinRepo: PinRepository,
    private val blossomRepo: BlossomRepository,
    private val deletedEventsRepo: DeletedEventsRepository,
    private val interestRepo: InterestRepository,
    private val customEmojiRepo: CustomEmojiRepository,
    private val relayListRepo: RelayListRepository,
    private val relayScoreBoard: RelayScoreBoard,
    private val relayHintStore: RelayHintStore,
    private val healthTracker: RelayHealthTracker,
    private val keyRepo: KeyRepository,
    private val extendedNetworkRepo: ExtendedNetworkRepository,
    private val metadataFetcher: MetadataFetcher,
    private val profileRepo: ProfileRepository,
    private val relayInfoRepo: RelayInfoRepository,
    private val nip05Repo: Nip05Repository,
    private val nwcRepo: NwcRepository,
    private val sparkRepo: SparkRepository,
    private val walletModeRepo: WalletModeRepository,
    private val dmRepo: DmRepository,
    private val liveStreamRepo: LiveStreamRepository?,
    private val zapPrefs: ZapPreferences,
    private val lifecycleManager: RelayLifecycleManager,
    private val eventRouter: EventRouter,
    private val feedSub: FeedSubscriptionManager,
    private val scope: CoroutineScope,
    private val processingContext: CoroutineContext,
    private val pubkeyHex: String?,
    private val getUserPubkey: () -> String?,
    private val registerAuthSigner: () -> Unit,
    private val fetchEmojiSets: () -> Unit,
    private val getSigner: () -> NostrSigner?
) {
    private var eventProcessingJob: Job? = null
    private var metadataSweepJob: Job? = null
    private var ephemeralCleanupJob: Job? = null
    private var relayListRefreshJob: Job? = null
    private var followWatcherJob: Job? = null
    private var authCompletedJob: Job? = null
    private var notifRefreshJob: Job? = null
    private var startupJob: Job? = null
    private var healthSnapshotJob: Job? = null
    private var notifSeedJob: Job? = null

    var relaysInitialized = false
        private set

    fun resetForAccountSwitch() {
        // Cancel all background jobs
        eventProcessingJob?.cancel()
        metadataSweepJob?.cancel()
        ephemeralCleanupJob?.cancel()
        relayListRefreshJob?.cancel()
        followWatcherJob?.cancel()
        authCompletedJob?.cancel()
        notifRefreshJob?.cancel()
        startupJob?.cancel()
        healthSnapshotJob?.cancel()
        notifSeedJob?.cancel()
        feedSub.reset()

        // Stop lifecycle manager and disconnect relays
        lifecycleManager.stop()
        relayPool.disconnectAll()
        nwcRepo.disconnect()

        // Clear all repos
        metadataFetcher.clear()
        eventRepo.clearAll()
        customEmojiRepo.clear()
        dmRepo.clear()
        notifRepo.clear()
        contactRepo.clear()
        muteRepo.clear()
        bookmarkRepo.clear()
        bookmarkSetRepo.clear()
        relaySetRepo.clear()
        pinRepo.clear()
        listRepo.clear()
        blossomRepo.clear()
        interestRepo.clear()
        extendedNetworkRepo.clear()
        relayScoreBoard.clear()
        relayHintStore.clear()
        healthTracker.clear()
        relayListRepo.clear()
        nip05Repo.clear()
        relayPool.clearSeenEvents()
        eventRouter.clearSelfDataTimestamps()

        // Reset state
        relaysInitialized = false
    }

    fun reloadForNewAccount() {
        val newPubkey = getUserPubkey()

        // Clear stale data from previous account and re-key to new pubkey
        notifRepo.reload(newPubkey)
        eventRepo.clearAll()
        if (newPubkey != null) dmRepo.reload(newPubkey) else dmRepo.clear()

        // Reload per-account prefs for new pubkey
        eventRepo.currentUserPubkey = newPubkey
        eventPersistence?.currentUserPubkey = newPubkey
        keyRepo.reloadPrefs(newPubkey)
        contactRepo.reload(newPubkey)
        muteRepo.reload(newPubkey)
        bookmarkRepo.reload(newPubkey)
        bookmarkSetRepo.reload(newPubkey)
        relaySetRepo.reload(newPubkey)
        pinRepo.reload(newPubkey)
        listRepo.reload(newPubkey)
        blossomRepo.reload(newPubkey)
        deletedEventsRepo.reload(newPubkey)
        interestRepo.reload(newPubkey)
        nwcRepo.reload(newPubkey)
        sparkRepo.reload(newPubkey)
        walletModeRepo.reload(newPubkey)
        relayScoreBoard.reload(newPubkey)
        healthTracker.reload(newPubkey)
        extendedNetworkRepo.reload(newPubkey)
        customEmojiRepo.reload(newPubkey)
        zapPrefs.reload(newPubkey)
    }

    fun initRelays() {
        Log.d("StartupCoord", "initRelays() called, relaysInitialized=$relaysInitialized")
        if (relaysInitialized) { Log.d("StartupCoord", "initRelays: already initialized, returning"); return }
        relaysInitialized = true

        // Prune old events from ObjectBox in background
        eventPersistence?.let { persistence ->
            scope.launch(processingContext) { persistence.prune() }
        }

        relayPool.healthTracker = healthTracker
        relayPool.appIsActive = true
        healthTracker.onBadRelaysChanged = { recomputeAndMergeRelays() }
        relayPool.updateBlockedUrls(keyRepo.getBlockedRelays())
        val pinnedRelays = keyRepo.getRelays()
        // Merge pinned relays with cached scored relays immediately so the pool
        // starts with the full relay set instead of just pinned (5-10).
        // RelayScoreBoard rebuilds from persisted RelayListRepository data on init.
        val pinnedUrls = pinnedRelays.map { it.url }.toSet()
        relayPool.setPinnedRelays(pinnedUrls)
        val cachedScored = relayScoreBoard.getScoredRelayConfigs()
            .filter { it.url !in pinnedUrls }
        val initialRelays = pinnedRelays + cachedScored
        relayPool.updateRelays(initialRelays)
        val dmRelays = keyRepo.getDmRelays()
        relayPool.updateDmRelays(dmRelays)
        eventRepo.dmRelayUrls = dmRelays.toSet()

        // Connect local relay if configured
        relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey())

        // Observe local relay config changes
        scope.launch {
            keyRepo.localRelayFlow.drop(1).collectLatest { config ->
                relayPool.updateLocalRelay(config, getUserPubkey())
            }
        }

        scope.launch {
            relayInfoRepo.prefetchAll(initialRelays.map { it.url })
        }

        // Main event processing loop — runs on Default dispatcher to keep UI thread free
        eventProcessingJob = scope.launch(processingContext) {
            relayPool.relayEvents.collect { (event, relayUrl, subscriptionId) ->
                try {
                    eventRouter.processRelayEvent(event, relayUrl, subscriptionId)
                } catch (e: Exception) {
                    Log.e("StartupCoord", "processRelayEvent crashed", e)
                    if (DiagnosticLogger.isEnabled) {
                        DiagnosticLogger.log("CRASH", "processRelayEvent exception: ${e.message}\n" +
                            "  event: id=${event.id.take(12)} kind=${event.kind} sub=$subscriptionId relay=$relayUrl\n" +
                            "  stacktrace: ${e.stackTraceToString().take(500)}")
                    }
                }
            }
        }

        // Remove events that fail async signature verification
        scope.launch(processingContext) {
            relayPool.invalidEvents.collect { eventId ->
                eventRepo.removeEvent(eventId)
            }
        }

        // Profile sweep — eager burst at startup for fast profile coverage,
        // then relaxed periodic sweep as a safety net.
        metadataSweepJob = scope.launch(processingContext) {
            // Eager: sweep at 3s, 8s, 15s after startup
            delay(3_000)
            metadataFetcher.sweepMissingProfiles()
            delay(5_000)  // t=8s
            metadataFetcher.sweepMissingProfiles()
            delay(7_000)  // t=15s
            metadataFetcher.sweepMissingProfiles()
            // Relax — by now profiles should be loaded
            while (true) {
                delay(120_000)
                metadataFetcher.sweepMissingProfiles()
            }
        }

        // Periodic ephemeral relay cleanup + seen event trimming
        ephemeralCleanupJob = scope.launch {
            while (true) {
                delay(60_000)
                relayPool.cleanupEphemeralRelays()
                eventRepo.trimSeenEvents()
                relayHintStore.flush()
            }
        }

        // Periodic health snapshot (every 60s when diagnostics enabled)
        healthSnapshotJob = scope.launch(processingContext) {
            while (true) {
                delay(60_000)
                if (!DiagnosticLogger.isEnabled) continue
                val pk = getUserPubkey()
                DiagnosticLogger.log("HEALTH", "pubkey=${pk?.take(8)} " +
                    "eventProcessingJob.active=${eventProcessingJob?.isActive} " +
                    "connectedRelays=${relayPool.connectedCount.value} " +
                    "notifSeenSize=${notifRepo.getSeenEventsSize()} " +
                    "eventCacheSize=${eventRepo.getCacheSize()}")
            }
        }

        // Periodic DM + notification subscription refresh (every 3 minutes)
        // Relays can silently drop subscriptions server-side while the WebSocket stays alive.
        notifRefreshJob = scope.launch {
            while (true) {
                delay(3 * 60 * 1000L)
                val pk = getUserPubkey() ?: continue
                subscribeDmsAndNotifications(pk)
            }
        }

        // Periodic relay list refresh: check every 30 min, but only do a full network fetch
        // when the cache has gone stale (> FRESHNESS_MS). New follows are fetched immediately
        // via the followWatcher below, so the background loop is purely for staleness.
        relayListRefreshJob = scope.launch {
            while (true) {
                delay(30 * 60 * 1000L)
                val authors = contactRepo.getFollowList().map { it.pubkey }
                if (authors.isNotEmpty() && !relayListRepo.isSyncFresh()) {
                    outboxRouter.requestAllRelayLists(authors)
                    delay(15_000)
                    recomputeAndMergeRelays()
                    relayListRepo.markSyncComplete()
                }
            }
        }

        // Incrementally update scoreboard when follow list changes, then re-subscribe feed
        followWatcherJob = scope.launch {
            var previousFollows = contactRepo.getFollowList().map { it.pubkey }.toSet()
            contactRepo.followList.drop(1).collectLatest { entries ->
                val currentFollows = entries.map { it.pubkey }.toSet()
                val added = currentFollows - previousFollows
                val removed = previousFollows - currentFollows
                previousFollows = currentFollows

                for (pubkey in removed) relayScoreBoard.removeAuthor(pubkey)
                for (pubkey in added) {
                    outboxRouter.requestMissingRelayLists(listOf(pubkey))
                    delay(500)
                    relayScoreBoard.addAuthor(pubkey, excludeRelays = getExcludedRelayUrls())
                }

                if ((added.isNotEmpty() || removed.isNotEmpty()) &&
                    (feedSub.feedType.value == FeedType.FOR_YOU ||
                     feedSub.feedType.value == FeedType.FOLLOWS ||
                     feedSub.feedType.value == FeedType.EXTENDED_FOLLOWS)) {
                    rebuildRelayPool()
                    feedSub.resubscribeFeed()
                    feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)
                }
            }
        }

        // NIP-42 AUTH: sign challenges via signer (local or remote)
        registerAuthSigner()

        // Re-send DM subscription to relays after AUTH completes
        authCompletedJob = scope.launch {
            relayPool.authCompleted.collect { relayUrl ->
                val myPubkey = getUserPubkey() ?: return@collect
                val dmRelayUrls = relayPool.getDmRelayUrls()
                if (relayUrl in dmRelayUrls || relayUrl in relayPool.getRelayUrls()) {
                    val dmFilter = Filter(kinds = listOf(1059), pTags = listOf(myPubkey))
                    relayPool.sendToRelay(relayUrl, ClientMessage.req("dms", dmFilter))
                }
            }
        }

        // Start network-aware lifecycle manager — handles connectivity changes
        // and works regardless of which screen is active.
        lifecycleManager.start()

        getUserPubkey()?.let {
            listRepo.setOwner(it)
            bookmarkSetRepo.setOwner(it)
            relaySetRepo.setOwner(it)
        }

        // Unified startup: cold start shows profile discovery UI, warm start skips to feed.
        // Cold start = first login or cache invalidated; warm start = returning with valid cache.
        startupJob = scope.launch {
            val cachedFollows = contactRepo.getFollowList()
            val isColdStart = cachedFollows.isEmpty() || relayScoreBoard.needsRecompute()
            val relayCount = relayPool.getRelayUrls().size

            val follows: List<String>

            if (isColdStart) {
                Log.d("StartupCoord", "init: cold start (${cachedFollows.size} cached follows, needsRecompute=${relayScoreBoard.needsRecompute()})")

                // Show cached profile immediately if available (e.g. re-login same account)
                val myPubkey = getUserPubkey()
                val cachedProfile = myPubkey?.let { profileRepo.get(it) }
                if (cachedProfile != null) {
                    feedSub._initLoadingState.value = InitLoadingState.FoundProfile(cachedProfile.displayString, cachedProfile.picture)
                } else {
                    feedSub._initLoadingState.value = InitLoadingState.SearchingProfile
                }

                // Phase 1a: Connect + fetch self-data (profile, follow list, relay lists)
                // Use actual relay count to avoid waiting for impossible minCount
                relayPool.awaitAnyConnected(minCount = minOf(3, relayCount), timeoutMs = 5_000)
                subscribeSelfData()
                awaitEmojiListThenFetchSets()

                // Show profile if we didn't have it cached but now have it from self-data
                if (cachedProfile == null) {
                    val profile = myPubkey?.let { profileRepo.get(it) }
                    if (profile != null) {
                        feedSub._initLoadingState.value = InitLoadingState.FoundProfile(profile.displayString, profile.picture)
                        delay(400)
                    }
                }

                // Phase 1b: Relay list fetch + compute routing
                follows = contactRepo.getFollowList().map { it.pubkey }

                if (relayScoreBoard.needsRecompute() && follows.isNotEmpty()) {
                    feedSub._initLoadingState.value = InitLoadingState.FindingFriends(0, follows.size)

                    val subscriptionSent = fetchRelayListsForFollows(includeProfiles = true)
                    if (subscriptionSent) {
                        val target = (follows.size * 0.8).toInt()
                        val deadline = System.currentTimeMillis() + 7_000
                        while (System.currentTimeMillis() < deadline) {
                            val covered = follows.size - relayListRepo.getMissingPubkeys(follows).size
                            feedSub._initLoadingState.value = InitLoadingState.FindingFriends(covered, follows.size)
                            if (covered >= target) break
                            delay(200)
                        }
                        subManager.closeSubscription("relay-lists")
                        relayListRepo.markSyncComplete()
                    }

                    recomputeAndMergeRelays()
                    val newRelayCount = relayPool.getRelayUrls().size
                    relayPool.awaitAnyConnected(minCount = minOf(3, newRelayCount), timeoutMs = 5_000)
                } else {
                    val scored = relayScoreBoard.getScoredRelays()
                    Log.d("StartupCoord", "init: scoreboard cache valid (${scored.size} relays, ${follows.size} follows), skipping recompute")
                }
            } else {
                // Warm start: connect + background self-data refresh, no discovery UI
                Log.d("StartupCoord", "init: warm start (${cachedFollows.size} cached follows, scoreboard valid)")
                feedSub._initLoadingState.value = InitLoadingState.WarmLoading

                relayPool.awaitAnyConnected(minCount = minOf(3, relayCount), timeoutMs = 5_000)
                // Fire-and-forget self-data refresh — catch exceptions so a self-data failure
                // does not prevent DM and notification subscriptions from being set up.
                launch {
                    try {
                        subscribeSelfData()
                    } catch (e: Exception) {
                        Log.e("StartupCoord", "subscribeSelfData failed on warm start, ensuring DMs/notifs are subscribed", e)
                        val pk = getUserPubkey()
                        if (pk != null) subscribeDmsAndNotifications(pk)
                    }
                    awaitEmojiListThenFetchSets()
                }

                follows = cachedFollows.map { it.pubkey }
            }

            // Subscribe feed with current follow list and relay routing.
            Log.d("StartupCoord", "Subscribing feed, feedType=${feedSub.feedType.value}")
            feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)

            // Seed eventCache from ObjectBox so the feed is populated immediately on warm
            // starts, before the relay response arrives. The relay subscription only needs
            // to top up with events newer than the saved since timestamp.
            eventPersistence?.let { persistence ->
                val cached = withContext(processingContext) { persistence.seedCache(limit = 2000) }
                if (cached.isNotEmpty()) {
                    eventRepo.seedFromObjectBox(cached)
                    eventRepo.rebuildFeedFromCache()
                    Log.d("StartupCoord", "Seeded ${cached.size} events from ObjectBox into feed cache")
                }
            }

            feedSub._initLoadingState.value = InitLoadingState.Subscribing
            feedSub.subscribeFeed()
            Log.d("StartupCoord", "subscribeFeed called")

            // Safety retry: if the follow list arrived late (after the initial subscribeFeed
            // call which early-returns on empty authors), wait and retry once.
            if (follows.isEmpty()) {
                launch {
                    val arrived = withTimeoutOrNull(8_000) {
                        contactRepo.followList.first { it.isNotEmpty() }
                    }
                    if (arrived != null) {
                        Log.d("StartupCoord", "Follow list arrived late (${arrived.size} follows), resubscribing feed")
                        feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)
                        feedSub.resubscribeFeed()
                    }
                }
            } else {
                // Safety retry for fresh signup / incomplete routing: when the initial
                // subscribe fires, author kind 10002 relay lists may not have fully
                // propagated, so outbox routing falls back to pinned relays that
                // may not carry the followed authors' posts. If the feed is still
                // empty after a short wait, resubscribe once — by then relay-list
                // data has typically arrived and routing targets the correct relays.
                launch {
                    delay(5_000)
                    if (eventRepo.getNewestFeedEventTimestamp() == null) {
                        Log.d("StartupCoord", "Feed empty after 5s with ${follows.size} follows, resubscribing with updated routing")
                        feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)
                        feedSub.resubscribeFeed()
                    }
                }
            }

            // Background: fetch relay lists for any new follows (non-blocking)
            if (!relayScoreBoard.needsRecompute()) {
                fetchRelayListsForFollows()
            }
        }
    }

    /**
     * Fetches self-data (follow list, relay lists, mutes, etc.) and **awaits** EOSE
     * so the caller has fresh data before proceeding to build the feed.
     * DM and notification subscriptions are fire-and-forget (not feed-blocking).
     */
    /**
     * Wait for the emoji list (kind 10030) to arrive from EventRouter before
     * fetching referenced emoji sets. EOSE signals the relay finished sending but
     * events may still be buffered in the SharedFlow waiting for processing.
     */
    private suspend fun awaitEmojiListThenFetchSets() {
        if (customEmojiRepo.userEmojiList.value != null) {
            fetchEmojiSets()
            return
        }
        // Wait for EventRouter to process the kind 10030 event after EOSE
        withTimeoutOrNull(3_000) {
            customEmojiRepo.userEmojiList.first { it != null }
        }
        fetchEmojiSets()
    }

    /**
     * Fetches self-data (follow list, relay lists, mutes, etc.).
     * Instead of waiting for full EOSE (up to 15s), we proceed as soon as the
     * follow list (kind 3) arrives since that's the gate for the next phase.
     * Remaining self-data continues streaming in the background.
     */
    private suspend fun subscribeSelfData() {
        val myPubkey = getUserPubkey() ?: return

        // Consolidate into 2 filters to stay within relay filter-count limits.
        // Replaceable events (one per kind per author):
        val replaceableKinds = listOf(
            0, 3, 10002, 10050, 10007, 10006,
            Nip51.KIND_FAVORITE_RELAYS, Nip51.KIND_MUTE_LIST, Nip51.KIND_PIN_LIST,
            Nip51.KIND_BOOKMARK_LIST, Blossom.KIND_SERVER_LIST,
            Nip51.KIND_SIMPLE_GROUPS, Nip30.KIND_USER_EMOJI_LIST
        )
        // Parameterized replaceable events (many per kind per author):
        val addressableKinds = listOf(
            Nip51.KIND_FOLLOW_SET, Nip51.KIND_BOOKMARK_SET, Nip51.KIND_RELAY_SET,
            Nip51.KIND_INTEREST_SET, Nip30.KIND_EMOJI_SET,
            30315 // NIP-38: user status
        )
        val selfDataFilters = listOf(
            Filter(kinds = replaceableKinds, authors = listOf(myPubkey), limit = replaceableKinds.size),
            Filter(kinds = addressableKinds, authors = listOf(myPubkey), limit = 250),
            // NIP-09 deletion events the user has published. Pulled so that deletions
            // survive across devices and fresh installs even when relays keep re-serving
            // the deleted replaceable/addressable events.
            Filter(kinds = listOf(5), authors = listOf(myPubkey), limit = 500)
        )
        // Send to indexer relays (ephemeral if not already connected) AND the user's own
        // write relays — write relays are the authoritative source since the user published
        // directly there. Indexers may lag or be unreachable for some users.
        val indexerRelays = RelayConfig.DEFAULT_INDEXER_RELAYS
        val writeRelayUrls = relayPool.getWriteRelayUrls()
        val selfDataRelays = (indexerRelays + writeRelayUrls).distinct()
        val reqMsg = ClientMessage.req("self-data", selfDataFilters)
        for (url in selfDataRelays) {
            relayPool.sendToRelayOrEphemeral(url, reqMsg)
        }

        // Wait for relays to EOSE so we get the newest kind 3 (follow list).
        // contactRepo.updateFromEvent already keeps the newest by created_at, so collecting
        // from several relays ensures we don't proceed with a stale follow list from
        // a single fast relay.
        val eoseCount = subManager.awaitEoseCount("self-data", expectedCount = selfDataRelays.size, timeoutMs = 4_000)

        // EOSE and events travel on separate SharedFlows, so the kind 3 event may still
        // be buffered in relayEvents waiting for the processing loop on Dispatchers.Default.
        // Wait reactively for the follow list to arrive rather than polling — this handles
        // both slow event processing and cases where EOSE times out but events still arrive.
        if (contactRepo.getFollowList().isEmpty()) {
            withTimeoutOrNull(5_000) {
                contactRepo.followList.first { it.isNotEmpty() }
            }
        }

        val gotFollowList = contactRepo.getFollowList().isNotEmpty()
        Log.d("StartupCoord", "subscribeSelfData: eoseCount=$eoseCount")
        Log.d("StartupCoord", "subscribeSelfData: gotFollowList=$gotFollowList, follows=${contactRepo.getFollowList().size}")

        // Close subscription in background after a short grace period for remaining data
        scope.launch {
            delay(3_000)
            subManager.closeSubscription("self-data")
        }

        // Update account registry with display info for the drawer account picker
        val profile = profileRepo.get(myPubkey)
        if (profile != null) {
            keyRepo.updateAccountMetadata(myPubkey, profile.displayString, profile.picture)
        }

        // Cache the user's avatar locally for instant loading screen display.
        if (profile?.picture != null) {
            val localFile = profileRepo.getLocalAvatar(myPubkey)
            val urlFile = File(profileRepo.avatarDir, "${myPubkey}.url")
            val cachedUrl = if (urlFile.exists()) urlFile.readText() else null
            if (localFile == null || cachedUrl != profile.picture) {
                scope.launch {
                    profileRepo.cacheAvatar(myPubkey, profile.picture)
                    urlFile.writeText(profile.picture)
                }
            }
        }

        // Apply default DM relays if the user has none set (e.g. new account or never configured).
        // This must be a suspend call — kind 10050 events from the self-data subscription may
        // still be buffered in EventRouter's processing loop even after EOSE, so we wait
        // reactively for them to arrive before concluding the user has no DM relays.
        applyDefaultDmRelaysIfEmpty(myPubkey)

        // Seed NotificationRepository from ObjectBox before subscribing to relays —
        // kinds 1, 6, 7, 9735 are already persisted, so cached notifications appear
        // immediately without waiting for relay responses. addEvent handles all
        // p-tag / ownership filtering, so we can pass events through unfiltered.
        eventPersistence?.let { persistence ->
            notifSeedJob?.cancel()
            notifSeedJob = scope.launch(processingContext) {
                val cached = persistence.getRecentNotificationEvents(limit = 500)
                    .filter { event ->
                        // Only seed events that reference the current user via p-tag.
                        // Without this, kind 6 reposts of OTHER people's notes (stored in
                        // ObjectBox from the feed) would leak into notifications via the
                        // kind 6 p-tag bypass in addEvent.
                        event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == myPubkey }
                    }
                for (event in cached) {
                    if (getUserPubkey() != myPubkey) {
                        Log.d("StartupCoord", "Notif seeding aborted: pubkey changed mid-seed")
                        return@launch
                    }
                    notifRepo.addEvent(event, myPubkey)
                }
                Log.d("StartupCoord", "Seeded notifRepo with ${cached.size} cached events")
            }
        }

        // DMs and notifications are not feed-blocking — fire and forget
        subscribeDmsAndNotifications(myPubkey)
    }

    /**
     * If the user has no DM relays after fetching self-data, apply defaults and broadcast.
     *
     * The self-data subscription already requests kind 10050 from indexer relays, but events
     * are processed asynchronously by EventRouter. We must wait for that processing to
     * complete before concluding the user has no DM relays. Additionally, we query the
     * user's write relays (outbox) as a second source in case indexers don't have it.
     */
    private suspend fun applyDefaultDmRelaysIfEmpty(myPubkey: String) {
        // Fast path: already have DM relays from local storage (previous session) or
        // from EventRouter processing the self-data response.
        if (keyRepo.getDmRelays().isNotEmpty()) {
            Log.d("StartupCoord", "DM relays already set: ${keyRepo.getDmRelays()}")
            return
        }

        // The kind 10050 event may still be buffered in EventRouter's processing loop
        // even though EOSE arrived. Also query the user's write relays as a fallback
        // source — indexer relays may not have the latest kind 10050.
        val writeRelayUrls = relayPool.getWriteRelayUrls()
        if (writeRelayUrls.isNotEmpty()) {
            val dmRelayFilter = Filter(kinds = listOf(Nip51.KIND_DM_RELAYS), authors = listOf(myPubkey), limit = 1)
            val reqMsg = ClientMessage.req("dm-relay-check", listOf(dmRelayFilter))
            for (url in writeRelayUrls) {
                relayPool.sendToRelayOrEphemeral(url, reqMsg)
            }
        }

        // Wait reactively for DM relays to arrive from either source.
        val arrived = withTimeoutOrNull(8_000) {
            keyRepo.dmRelaysFlow.first { it.isNotEmpty() }
        }

        // Clean up the fallback subscription
        subManager.closeSubscription("dm-relay-check")

        if (arrived != null) {
            Log.d("StartupCoord", "DM relays arrived during wait: $arrived")
            return
        }

        // After thorough checking, user genuinely has no DM relays — apply defaults.
        // Double-check one more time in case of a late arrival.
        if (keyRepo.getDmRelays().isNotEmpty()) {
            Log.d("StartupCoord", "DM relays arrived just before default application: ${keyRepo.getDmRelays()}")
            return
        }

        val defaults = RelayConfig.DEFAULT_DM_RELAYS
        keyRepo.saveDmRelays(defaults)
        relayPool.updateDmRelays(defaults)
        Log.d("StartupCoord", "Applied default DM relays: $defaults")

        // Publish kind 10050 so other clients and relays know our DM relays
        val s = getSigner() ?: return
        scope.launch {
            val tags = Nip51.buildRelaySetTags(defaults)
            val event = s.signEvent(kind = Nip51.KIND_DM_RELAYS, content = "", tags = tags)
            val msg = ClientMessage.event(event)
            relayPool.sendToWriteRelays(msg)
            relayPool.sendToDmRelays(msg)
            for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
                relayPool.sendToRelayOrEphemeral(url, msg)
            }
            Log.d("StartupCoord", "Published default DM relay list (kind ${Nip51.KIND_DM_RELAYS})")
        }
    }

    /**
     * Subscribe to DMs and notifications. Extracted so it can be re-called on force reconnect
     * when all relay subscriptions have been torn down.
     */
    fun subscribeDmsAndNotifications(myPubkey: String) {
        notifRepo.soundEligibleAfter = System.currentTimeMillis() / 1000
        dmRepo.soundEligibleAfter = System.currentTimeMillis() / 1000
        // NIP-17 gift wraps use randomized timestamps (up to 2 days in the past), making
        // a `since` filter unreliable — it would silently drop DMs from older conversations
        // and suppress the unread badge. DMs are low-volume; fetch without restriction.
        val dmReqMsg = ClientMessage.req("dms", Filter(kinds = listOf(1059), pTags = listOf(myPubkey)))
        relayPool.sendToAll(dmReqMsg)
        relayPool.sendToDmRelays(dmReqMsg)
        scope.launch {
            subManager.awaitEoseWithTimeout("dms")
            Log.d("StartupCoord", "DM subscription (re)established")
        }

        val notifFilter = Filter(
            kinds = listOf(1, 6, 7, 9735),
            pTags = listOf(myPubkey),
            limit = 300
        )
        val notifReqMsg = ClientMessage.req("notif", notifFilter)
        relayPool.sendToReadRelays(notifReqMsg)

        // Also send to top scored relays for broader coverage
        val readUrls = relayPool.getReadRelayUrls().toSet()
        val topScored = relayScoreBoard.getScoredRelays()
            .take(5)
            .map { it.url }
            .filter { it !in readUrls }
        for (url in topScored) {
            relayPool.sendToRelay(url, notifReqMsg)
        }

        // Fetch private zap receipts from DM relays
        if (relayPool.hasDmRelays()) {
            val dmZapFilter = Filter(
                kinds = listOf(9735),
                pTags = listOf(myPubkey),
                limit = 100
            )
            relayPool.sendToDmRelays(ClientMessage.req("notif", dmZapFilter))
        }

        // Fetch the user's own recent notes upfront so notification referenced events
        // (reactions, zaps, reposts all point at our events) are in cache before
        // engagement subscriptions start.
        val selfNotesSince = eventRepo.getLatestEventTimestamp(myPubkey, 1)
        val selfNotesFilter = if (selfNotesSince != null) {
            Filter(kinds = listOf(1), authors = listOf(myPubkey), since = selfNotesSince)
        } else {
            Filter(kinds = listOf(1), authors = listOf(myPubkey), limit = 200)
        }
        val selfNotesMsg = ClientMessage.req("self-notes", selfNotesFilter)
        relayPool.sendToWriteRelays(selfNotesMsg)
        relayPool.sendToReadRelays(selfNotesMsg)

        scope.launch {
            subManager.awaitEoseWithTimeout("notif")
            // Wait for self-notes to arrive before engagement so referenced events are cached
            subManager.awaitEoseWithTimeout("self-notes", timeoutMs = 5_000)
            subManager.closeSubscription("self-notes")

            refreshNotifRepliesEtag(myPubkey)

            feedSub.subscribeNotifEngagement()
        }

        subscribeLiveStreams(myPubkey)
    }

    private fun subscribeLiveStreams(myPubkey: String) {
        // Bypass dedup for live chat subs — the discovery sub and per-stream sub both
        // receive kind 1311 events and must each reach EventRouter independently.
        // Without this, whichever sub wins the dedup race silently drops the other copy.
        relayPool.registerDedupBypass("live-chat-")

        // Subscription 1: discover live activities
        val activityFilter = Filter(kinds = listOf(30311), limit = 50)
        relayPool.sendToReadRelays(ClientMessage.req("live-activities", activityFilter))

        // Subscription 2: global chat discovery — find streams with recent chat activity
        val since = System.currentTimeMillis() / 1000 - 3600
        val chatFilter = Filter(kinds = listOf(1311), since = since, limit = 500)
        relayPool.sendToReadRelays(ClientMessage.req("live-chat-discovery", chatFilter))
    }

    /**
     * Subscribe for replies via e-tags on our own posts.
     * Catches replies where the replier's client omits the p-tag.
     * Extracted so it can be re-called after publishing a note without
     * re-running the full DM/notification setup.
     */
    fun refreshNotifRepliesEtag(myPubkey: String? = pubkeyHex) {
        val pk = myPubkey ?: return
        val myEventIds = eventRepo.getRecentEventIdsByAuthor(pk, limit = 100)
        if (myEventIds.isEmpty()) return
        // Publish the set so EventRouter and NotificationRepository can verify direct
        // reply targets and ownership of reactions/reposts whose referenced event has
        // been evicted from the LRU cache.
        val myEventIdSet = myEventIds.toSet()
        eventRouter.myOwnEventIds = myEventIdSet
        notifRepo.myOwnEventIds = myEventIdSet

        val since = notifRepo.getLatestNotifTimestamp()?.let { it - 5 * 60 }
        val filters = myEventIds.chunked(OutboxRouter.MAX_ETAGS_PER_FILTER).map { chunk ->
            Filter(kinds = listOf(1), eTags = chunk, limit = 200, since = since)
        }
        val replyReqMsg = if (filters.size == 1) ClientMessage.req("notif-replies-etag", filters[0])
        else ClientMessage.req("notif-replies-etag", filters)

        relayPool.sendToReadRelays(replyReqMsg)
        // Replies often land on the relay where the original note was posted
        val readUrls2 = relayPool.getReadRelayUrls().toSet()
        val writeNotInRead = relayPool.getWriteRelayUrls().filter { it !in readUrls2 }
        for (url in writeNotInRead) {
            relayPool.sendToRelay(url, replyReqMsg)
        }
        val topScored2 = relayScoreBoard.getScoredRelays()
            .take(5)
            .map { it.url }
            .filter { it !in readUrls2 && it !in writeNotInRead.toSet() }
        for (url in topScored2) {
            relayPool.sendToRelay(url, replyReqMsg)
        }
        // Also subscribe for quotes of our posts via #q tags
        val quoteFilters = myEventIds.chunked(OutboxRouter.MAX_ETAGS_PER_FILTER).map { chunk ->
            Filter(kinds = listOf(1), qTags = chunk, limit = 200, since = since)
        }
        val quoteReqMsg = if (quoteFilters.size == 1) ClientMessage.req("notif-quotes-qtag", quoteFilters[0])
        else ClientMessage.req("notif-quotes-qtag", quoteFilters)
        relayPool.sendToReadRelays(quoteReqMsg)
        for (url in writeNotInRead) relayPool.sendToRelay(url, quoteReqMsg)
        for (url in topScored2) relayPool.sendToRelay(url, quoteReqMsg)

        scope.launch {
            subManager.awaitEoseWithTimeout("notif-replies-etag", timeoutMs = 5_000)
            subManager.awaitEoseWithTimeout("notif-quotes-qtag", timeoutMs = 5_000)
        }
    }

    /**
     * Bootstrap follow data: fetch relay lists (kind 10002) AND profiles (kind 0)
     * for all follows in a single REQ.
     */
    fun fetchRelayListsForFollows(includeProfiles: Boolean = false): Boolean {
        val authors = contactRepo.getFollowList().map { it.pubkey }
        if (authors.isEmpty()) {
            Log.d("StartupCoord", "fetchRelayListsForFollows: follow list empty")
            return false
        }
        val sent = if (includeProfiles) {
            outboxRouter.requestRelayListsAndProfiles(authors, profileRepo) != null
        } else {
            outboxRouter.requestMissingRelayLists(authors) != null
        }
        Log.d("StartupCoord", "fetchRelayListsForFollows: ${authors.size} follows, includeProfiles=$includeProfiles, subscription sent=$sent")
        return sent
    }

    /** Blocked + bad relay URLs combined for outbox routing exclusion. */
    private fun getExcludedRelayUrls(): Set<String> =
        relayPool.getBlockedUrls() + healthTracker.getBadRelays()

    fun recomputeAndMergeRelays() {
        relayScoreBoard.recompute(excludeRelays = getExcludedRelayUrls())
        if (!relayScoreBoard.hasScoredRelays()) return
        rebuildRelayPool()
    }

    /**
     * Rebuild the persistent relay pool from pinned + scored + extended network relays.
     * Extended relays are always included so feed type switching is a cheap local filter.
     */
    fun rebuildRelayPool() {
        val pinnedRelays = keyRepo.getRelays()
        val pinnedUrls = pinnedRelays.map { it.url }.toSet()
        relayPool.setPinnedRelays(pinnedUrls)
        val scoredConfigs = relayScoreBoard.getScoredRelayConfigs()
            .filter { it.url !in pinnedUrls }
        val baseUrls = pinnedUrls + scoredConfigs.map { it.url }.toSet()
        val extendedConfigs = extendedNetworkRepo.getRelayConfigs()
            .filter { it.url !in baseUrls }

        relayPool.updateRelays(pinnedRelays + scoredConfigs + extendedConfigs)
    }

    /**
     * Integrate an already-computed extended network into the relay pool and feed.
     * Called after SocialGraphScreen finishes discovery.
     */
    fun integrateExtendedNetwork() {
        scope.launch {
            val extConfigs = extendedNetworkRepo.getRelayConfigs()
            if (extConfigs.isNotEmpty()) {
                rebuildRelayPool()
                val poolSize = relayPool.getRelayUrls().size
                relayPool.awaitAnyConnected(minCount = poolSize * 3 / 10, timeoutMs = 3_000)
                feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)
                feedSub.resubscribeFeed()
            }
        }
    }

    /** Called by Activity lifecycle — delegates to RelayLifecycleManager. */
    fun onAppPause() {
        Log.d("RLC", "[Startup] onAppPause — feedType=${feedSub.feedType.value} connectedCount=${relayPool.connectedCount.value}")
        notifRepo.appIsActive = false
        dmRepo.appIsActive = false
        lifecycleManager.onAppPause()
    }

    /** Called by Activity lifecycle — delegates to RelayLifecycleManager. */
    fun onAppResume(pausedMs: Long) {
        Log.d("RLC", "[Startup] onAppResume — paused ${pausedMs/1000}s, feedType=${feedSub.feedType.value} connectedCount=${relayPool.connectedCount.value}")
        notifRepo.appIsActive = true
        dmRepo.appIsActive = true
        lifecycleManager.onAppResume(pausedMs)
    }

    fun refreshDmsAndNotifications() {
        val pk = getUserPubkey() ?: return
        subscribeDmsAndNotifications(pk)
    }

    /**
     * Re-fetch self-data (NIP-51 lists, follow list, relay lists, etc.) in the background.
     * Called after a long pause (force reconnect) to ensure lists like bookmarks, hashtag
     * sets, and media servers are refreshed — they are not re-fetched by subscribeDmsAndNotifications.
     */
    fun refreshSelfData() {
        scope.launch {
            try {
                subscribeSelfData()
            } catch (e: Exception) {
                Log.e("StartupCoord", "refreshSelfData failed", e)
            }
        }
    }

    fun refreshRelays() {
        relayPool.updateBlockedUrls(keyRepo.getBlockedRelays())
        val relays = keyRepo.getRelays()
        relayPool.setPinnedRelays(relays.map { it.url }.toSet())
        relayPool.updateRelays(relays)
        val dmRelays = keyRepo.getDmRelays()
        relayPool.updateDmRelays(dmRelays)
        eventRepo.dmRelayUrls = dmRelays.toSet()
        feedSub.subscribeFeed()
    }
}
