package com.vitorpamplona.amethyst.model

import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter

@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
    override fun idNote() = address.toNAddr()
    override fun toNEvent() = address.toNAddr()
    override fun idDisplayNote() = idNote().toShortenHex()
    override fun address() = address
    override fun createdAt(): Long? {
        if (event == null) return null

        val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
        val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE

        return minOf(publishedAt, lastCreatedAt)
    }

    fun dTag(): String? {
        return (event as? AddressableEvent)?.dTag()
    }
}

@Stable
open class Note(val idHex: String) {
    // These fields are only available after the Text Note event is received.
    // They are immutable after that.
    var event: EventInterface? = null
    var author: User? = null
    var replyTo: List<Note>? = null

    // These fields are updated every time an event related to this note is received.
    var replies = listOf<Note>()
        private set
    var reactions = mapOf<String, List<Note>>()
        private set
    var boosts = listOf<Note>()
        private set
    var reports = mapOf<User, List<Note>>()
        private set
    var zaps = mapOf<Note, Note?>()
        private set
    var zapsAmount: BigDecimal = BigDecimal.ZERO

    var zapPayments = mapOf<Note, Note?>()
        private set

    var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
        private set

    var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()

    fun id() = Hex.decode(idHex)
    open fun idNote() = id().toNote()

    open fun toNEvent(): String {
        val myEvent = event
        return if (myEvent is WrappedEvent) {
            val host = myEvent.host
            if (host != null) {
                Nip19.createNEvent(
                    host.id,
                    host.pubKey,
                    host.kind(),
                    relays.firstOrNull()?.url
                )
            } else {
                Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
            }
        } else {
            Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
        }
    }

    fun toNostrUri(): String {
        return "nostr:${toNEvent()}"
    }

    open fun idDisplayNote() = idNote().toShortenHex()

    fun channelHex(): HexKey? {
        return if (event is ChannelMessageEvent ||
            event is ChannelMetadataEvent ||
            event is ChannelCreateEvent ||
            event is LiveActivitiesChatMessageEvent ||
            event is LiveActivitiesEvent
        ) {
            (event as? ChannelMessageEvent)?.channel()
                ?: (event as? ChannelMetadataEvent)?.channel()
                ?: (event as? ChannelCreateEvent)?.id
                ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
                ?: (event as? LiveActivitiesEvent)?.address()?.toTag()
        } else {
            null
        }
    }

    open fun address(): ATag? = null

    open fun createdAt() = event?.createdAt()

    fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
        if (this.event?.id() != event.id()) {
            this.event = event
            this.author = author
            this.replyTo = replyTo

            liveSet?.innerMetadata?.invalidateData()
            flowSet?.metadata?.invalidateData()
        }
    }

    fun formattedDateTime(timestamp: Long): String {
        return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
            .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
    }

    data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)

    /**
     * This method caches signatures during each execution to avoid recalculation in longer threads
     */
    fun replyLevelSignature(
        eventsToConsider: Set<HexKey>,
        cachedSignatures: MutableMap<Note, LevelSignature>,
        account: User,
        accountFollowingSet: Set<String>,
        now: Long
    ): LevelSignature {
        val replyTo = replyTo
        if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
            return LevelSignature(
                signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
                createdAt = createdAt(),
                author = author
            )
        }

        val parent = (
            replyTo
                .filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
                .map {
                    cachedSignatures[it] ?: it.replyLevelSignature(
                        eventsToConsider,
                        cachedSignatures,
                        account,
                        accountFollowingSet,
                        now
                    ).apply { cachedSignatures.put(it, this) }
                }
                .maxByOrNull { it.signature.length }
            )

        val parentSignature = parent?.signature?.removeSuffix(";") ?: ""

        val threadOrder = if (parent?.author == author && createdAt() != null) {
            // author of the thread first, in **ascending** order
            "9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8)
        } else if (author?.pubkeyHex == account.pubkeyHex) {
            "8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
        } else if (author?.pubkeyHex in accountFollowingSet) {
            "7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
        } else {
            "0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
        }

        val mySignature = LevelSignature(
            signature = parentSignature + "/" + threadOrder + ";",
            createdAt = createdAt(),
            author = author
        )

        cachedSignatures[this] = mySignature
        return mySignature
    }

    fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
        val replyTo = replyTo
        if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
            return 0
        }

        return replyTo.maxOf {
            cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
        } + 1
    }

    fun addReply(note: Note) {
        if (note !in replies) {
            replies = replies + note
            liveSet?.innerReplies?.invalidateData()
        }
    }

    fun removeReply(note: Note) {
        if (note in replies) {
            replies = replies - note
            liveSet?.innerReplies?.invalidateData()
        }
    }

    fun removeBoost(note: Note) {
        if (note in boosts) {
            boosts = boosts - note
            liveSet?.innerBoosts?.invalidateData()
        }
    }

    fun removeAllChildNotes(): List<Note> {
        val toBeRemoved = replies +
            reactions.values.flatten() +
            boosts +
            reports.values.flatten() +
            zaps.keys +
            zaps.values.filterNotNull() +
            zapPayments.keys +
            zapPayments.values.filterNotNull()

        replies = listOf<Note>()
        reactions = mapOf<String, List<Note>>()
        boosts = listOf<Note>()
        reports = mapOf<User, List<Note>>()
        zaps = mapOf<Note, Note?>()
        zapPayments = mapOf<Note, Note?>()
        zapsAmount = BigDecimal.ZERO
        relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
        lastReactionsDownloadTime = emptyMap()

        liveSet?.innerReplies?.invalidateData()
        liveSet?.innerReactions?.invalidateData()
        liveSet?.innerBoosts?.invalidateData()
        liveSet?.innerReports?.invalidateData()
        liveSet?.innerZaps?.invalidateData()

        return toBeRemoved
    }

    fun removeReaction(note: Note) {
        val tags = note.event?.tags() ?: emptyArray()
        val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"

        if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
            reactions[reaction]?.let {
                if (note in it) {
                    val newList = it.minus(note)
                    if (newList.isEmpty()) {
                        reactions = reactions.minus(reaction)
                    } else {
                        reactions = reactions + Pair(reaction, newList)
                    }

                    liveSet?.innerReactions?.invalidateData()
                }
            }
        }
    }

    fun removeReport(deleteNote: Note) {
        val author = deleteNote.author ?: return

        if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
            reports[author]?.let {
                reports = reports + Pair(author, it.minus(deleteNote))
                liveSet?.innerReports?.invalidateData()
            }
        }
    }

    fun removeZap(note: Note) {
        if (zaps[note] != null) {
            zaps = zaps.minus(note)
            updateZapTotal()
            liveSet?.innerZaps?.invalidateData()
        } else if (zaps.containsValue(note)) {
            zaps = zaps.filterValues { it != note }
            updateZapTotal()
            liveSet?.innerZaps?.invalidateData()
        }
    }

    fun removeZapPayment(note: Note) {
        if (zapPayments[note] != null) {
            zapPayments = zapPayments.minus(note)
            liveSet?.innerZaps?.invalidateData()
        } else if (zapPayments.containsValue(note)) {
            val toRemove = zapPayments.filterValues { it == note }
            zapPayments = zapPayments.minus(toRemove.keys)
            liveSet?.innerZaps?.invalidateData()
        }
    }

    fun addBoost(note: Note) {
        if (note !in boosts) {
            boosts = boosts + note
            liveSet?.innerBoosts?.invalidateData()
        }
    }

    @Synchronized
    private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
        if (zapRequest !in zaps.keys) {
            zaps = zaps + Pair(zapRequest, zap)
            return true
        } else if (zaps[zapRequest] == null) {
            zaps = zaps + Pair(zapRequest, zap)
            return true
        }

        return false
    }

    fun addZap(zapRequest: Note, zap: Note?) {
        checkNotInMainThread()
        if (zapRequest !in zaps.keys) {
            val inserted = innerAddZap(zapRequest, zap)
            if (inserted) {
                updateZapTotal()
                liveSet?.innerZaps?.invalidateData()
            }
        } else if (zaps[zapRequest] == null) {
            val inserted = innerAddZap(zapRequest, zap)
            if (inserted) {
                updateZapTotal()
                liveSet?.innerZaps?.invalidateData()
            }
        }
    }

    @Synchronized
    private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
        if (zapPaymentRequest !in zapPayments.keys) {
            zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
            return true
        } else if (zapPayments[zapPaymentRequest] == null) {
            zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
            return true
        }

        return false
    }

    fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
        checkNotInMainThread()
        if (zapPaymentRequest !in zapPayments.keys) {
            val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
            if (inserted) {
                liveSet?.innerZaps?.invalidateData()
            }
        } else if (zapPayments[zapPaymentRequest] == null) {
            val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
            if (inserted) {
                liveSet?.innerZaps?.invalidateData()
            }
        }
    }

    fun addReaction(note: Note) {
        val tags = note.event?.tags() ?: emptyArray()
        val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"

        if (reaction !in reactions.keys) {
            reactions = reactions + Pair(reaction, listOf(note))
            liveSet?.innerReactions?.invalidateData()
        } else if (reactions[reaction]?.contains(note) == false) {
            reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
            liveSet?.innerReactions?.invalidateData()
        }
    }

    fun addReport(note: Note) {
        val author = note.author ?: return

        if (author !in reports.keys) {
            reports = reports + Pair(author, listOf(note))
            liveSet?.innerReports?.invalidateData()
        } else if (reports[author]?.contains(note) == false) {
            reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
            liveSet?.innerReports?.invalidateData()
        }
    }

    @Synchronized
    fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
        if (briefInfo !in relays) {
            relays = relays + briefInfo
        }
    }

    fun addRelay(relay: Relay) {
        if (relay.brief !in relays) {
            addRelaySync(relay.brief)
            liveSet?.innerRelays?.invalidateData()
        }
    }

    private fun recursiveIsPaidByCalculation(
        account: Account,
        remainingZapPayments: List<Pair<Note, Note?>>,
        onWasZappedByAuthor: () -> Unit
    ) {
        if (remainingZapPayments.isEmpty()) {
            return
        }

        val next = remainingZapPayments.first()

        val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
        if (zapResponseEvent != null) {
            account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
                if (response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent.requestAuthor())) {
                    onWasZappedByAuthor()
                } else {
                    recursiveIsPaidByCalculation(
                        account,
                        remainingZapPayments.minus(next),
                        onWasZappedByAuthor
                    )
                }
            }
        }
    }

    private fun recursiveIsZappedByCalculation(
        option: Int?,
        user: User,
        account: Account,
        remainingZapEvents: List<Pair<Note, Note?>>,
        onWasZappedByAuthor: () -> Unit
    ) {
        if (remainingZapEvents.isEmpty()) {
            return
        }

        val next = remainingZapEvents.first()

        if (next.first.author?.pubkeyHex == user.pubkeyHex) {
            onWasZappedByAuthor()
        } else {
            account.decryptZapContentAuthor(next.first) {
                if (it.pubKey == user.pubkeyHex && (option == null || option == (it as? LnZapEvent)?.zappedPollOption())) {
                    onWasZappedByAuthor()
                } else {
                    recursiveIsZappedByCalculation(option, user, account, remainingZapEvents.minus(next), onWasZappedByAuthor)
                }
            }
        }
    }

    fun isZappedBy(user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
        recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
        if (account.userProfile() == user) {
            recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
        }
    }

    fun isZappedBy(option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
        recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
    }

    fun getReactionBy(user: User): String? {
        return reactions.firstNotNullOfOrNull {
            if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
                it.key
            } else {
                null
            }
        }
    }

    fun isBoostedBy(user: User): Boolean {
        return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
    }

    fun hasReportsBy(user: User): Boolean {
        return reports[user]?.isNotEmpty() ?: false
    }

    fun reportAuthorsBy(users: Set<HexKey>): List<User> {
        return reports.keys.filter { it.pubkeyHex in users }
    }

    fun countReportAuthorsBy(users: Set<HexKey>): Int {
        return reports.keys.count { it.pubkeyHex in users }
    }

    fun reportsBy(users: Set<HexKey>): List<Note> {
        return reportAuthorsBy(users).mapNotNull {
            reports[it]
        }.flatten()
    }

    private fun updateZapTotal() {
        var sumOfAmounts = BigDecimal.ZERO

        // Regular Zap Receipts
        zaps.values.forEach {
            val noteEvent = it?.event
            if (noteEvent is LnZapEvent) {
                sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
            }
        }

        zapsAmount = sumOfAmounts
    }

    private fun recursiveZappedAmountCalculation(
        invoiceSet: LinkedHashSet<String>,
        remainingZapPayments: List<Pair<Note, Note?>>,
        signer: NostrSigner,
        output: BigDecimal,
        onReady: (BigDecimal) -> Unit
    ) {
        if (remainingZapPayments.isEmpty()) {
            onReady(output)
            return
        }

        val next = remainingZapPayments.first()

        (next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
            if (noteEvent is PayInvoiceSuccessResponse) {
                (next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
                    val amount = try {
                        LnInvoiceUtil.getAmountInSats(invoice)
                    } catch (e: java.lang.Exception) {
                        null
                    }

                    var newAmount = output

                    if (amount != null && !invoiceSet.contains(invoice)) {
                        invoiceSet.add(invoice)
                        newAmount += amount
                    }

                    recursiveZappedAmountCalculation(
                        invoiceSet,
                        remainingZapPayments.minus(next),
                        signer,
                        newAmount,
                        onReady
                    )
                }
            }
        }
    }

    fun zappedAmountWithNWCPayments(signer: NostrSigner, onReady: (BigDecimal) -> Unit) {
        if (zapPayments.isEmpty()) {
            onReady(zapsAmount)
        }

        val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
        zaps.forEach {
            (it.value?.event as? LnZapEvent)?.lnInvoice()?.let {
                invoiceSet.add(it)
            }
        }

        recursiveZappedAmountCalculation(
            invoiceSet,
            zapPayments.toList(),
            signer,
            zapsAmount,
            onReady
        )
    }

    fun hasPledgeBy(user: User): Boolean {
        return replies
            .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
            .any {
                val pledgeValue = try {
                    BigDecimal(it.event?.content())
                } catch (e: Exception) {
                    null
                    // do nothing if it can't convert to bigdecimal
                }

                pledgeValue != null && it.author == user
            }
    }

    fun pledgedAmountByOthers(): BigDecimal {
        return replies
            .filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
            .mapNotNull {
                try {
                    BigDecimal(it.event?.content())
                } catch (e: Exception) {
                    null
                    // do nothing if it can't convert to bigdecimal
                }
            }
            .sumOf { it }
    }

    fun hasAnyReports(): Boolean {
        val dayAgo = TimeUtils.oneDayAgo()
        return reports.isNotEmpty() ||
            (
                author?.reports?.values?.any {
                    it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
                } ?: false
                )
    }

    fun isNewThread(): Boolean {
        return (
            event is RepostEvent ||
                event is GenericRepostEvent ||
                replyTo == null ||
                replyTo?.size == 0
            ) &&
            event !is ChannelMessageEvent &&
            event !is LiveActivitiesChatMessageEvent
    }

    fun hasZapped(loggedIn: User): Boolean {
        return zaps.any { it.key.author == loggedIn }
    }

    fun hasReacted(loggedIn: User, content: String): Boolean {
        return reactedBy(loggedIn, content).isNotEmpty()
    }

    fun reactedBy(loggedIn: User, content: String): List<Note> {
        return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
    }

    fun reactedBy(loggedIn: User): List<String> {
        return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
    }

    fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
        return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
    }

    fun boostedBy(loggedIn: User): List<Note> {
        return boosts.filter { it.author == loggedIn }
    }

    fun moveAllReferencesTo(note: AddressableNote) {
        // migrates these comments to a new version
        replies.forEach {
            note.addReply(it)
            it.replyTo = it.replyTo?.updated(this, note)
        }
        reactions.forEach {
            it.value.forEach {
                note.addReaction(it)
                it.replyTo = it.replyTo?.updated(this, note)
            }
        }
        boosts.forEach {
            note.addBoost(it)
            it.replyTo = it.replyTo?.updated(this, note)
        }
        reports.forEach {
            it.value.forEach {
                note.addReport(it)
                it.replyTo = it.replyTo?.updated(this, note)
            }
        }
        zaps.forEach {
            note.addZap(it.key, it.value)
            it.key.replyTo = it.key.replyTo?.updated(this, note)
            it.value?.replyTo = it.value?.replyTo?.updated(this, note)
        }

        replyTo = null
        replies = emptyList()
        reactions = emptyMap()
        boosts = emptyList()
        reports = emptyMap()
        zaps = emptyMap()
        zapsAmount = BigDecimal.ZERO
    }

    fun clearEOSE() {
        lastReactionsDownloadTime = emptyMap()
    }

    fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
        val thisEvent = event ?: return false

        val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) {
            replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
        } else {
            false
        }

        val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) {
            accountChoices.hiddenWords.any {
                thisEvent.content.contains(it, true)
            }
        } else {
            false
        }

        val isSensitive = thisEvent.isSensitive()
        return isBoostedNoteHidden || isHiddenByWord ||
            accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
            accountChoices.spammers.contains(author?.pubkeyHex) ||
            (isSensitive && accountChoices.showSensitiveContent == false)
    }

    var liveSet: NoteLiveSet? = null
    var flowSet: NoteFlowSet? = null

    @Synchronized
    fun createOrDestroyLiveSync(create: Boolean) {
        if (create) {
            if (liveSet == null) {
                liveSet = NoteLiveSet(this)
            }
        } else {
            if (liveSet != null && liveSet?.isInUse() == false) {
                liveSet?.destroy()
                liveSet = null
            }
        }
    }

    fun live(): NoteLiveSet {
        if (liveSet == null) {
            createOrDestroyLiveSync(true)
        }
        return liveSet!!
    }

    fun clearLive() {
        if (liveSet != null && liveSet?.isInUse() == false) {
            createOrDestroyLiveSync(false)
        }
    }

    @Synchronized
    fun createOrDestroyFlowSync(create: Boolean) {
        if (create) {
            if (flowSet == null) {
                flowSet = NoteFlowSet(this)
            }
        } else {
            if (flowSet != null && flowSet?.isInUse() == false) {
                flowSet?.destroy()
                flowSet = null
            }
        }
    }

    fun flow(): NoteFlowSet {
        if (flowSet == null) {
            createOrDestroyFlowSync(true)
        }
        return flowSet!!
    }

    fun clearFlow() {
        if (flowSet != null && flowSet?.isInUse() == false) {
            createOrDestroyFlowSync(false)
        }
    }
}

@Stable
class NoteFlowSet(u: Note) {
    // Observers line up here.
    val metadata = NoteBundledRefresherFlow(u)

    fun isInUse(): Boolean {
        return metadata.stateFlow.subscriptionCount.value > 0
    }

    fun destroy() {
        metadata.destroy()
    }
}

@Stable
class NoteLiveSet(u: Note) {
    // Observers line up here.
    val innerMetadata = NoteBundledRefresherLiveData(u)
    val innerReactions = NoteBundledRefresherLiveData(u)
    val innerBoosts = NoteBundledRefresherLiveData(u)
    val innerReplies = NoteBundledRefresherLiveData(u)
    val innerReports = NoteBundledRefresherLiveData(u)
    val innerRelays = NoteBundledRefresherLiveData(u)
    val innerZaps = NoteBundledRefresherLiveData(u)

    val metadata = innerMetadata.map { it }
    val reactions = innerReactions.map { it }
    val boosts = innerBoosts.map { it }
    val replies = innerReplies.map { it }
    val reports = innerReports.map { it }
    val relays = innerRelays.map { it }
    val zaps = innerZaps.map { it }

    val authorChanges = innerMetadata.map {
        it.note.author
    }.distinctUntilChanged()

    val hasEvent = innerMetadata.map {
        it.note.event != null
    }.distinctUntilChanged()

    val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
        zapState?.note?.zaps?.isNotEmpty() ?: false ||
            boostState?.note?.boosts?.isNotEmpty() ?: false ||
            reactionState?.note?.reactions?.isNotEmpty() ?: false
    }.distinctUntilChanged()

    val replyCount = innerReplies.map {
        it.note.replies.size
    }.distinctUntilChanged()

    val reactionCount = innerReactions.map {
        it.note.reactions.values.sumOf { it.size }
    }.distinctUntilChanged()

    val boostCount = innerBoosts.map {
        it.note.boosts.size
    }.distinctUntilChanged()

    val relayInfo = innerRelays.map {
        it.note.relays
    }

    val content = innerMetadata.map {
        it.note.event?.content() ?: ""
    }

    fun isInUse(): Boolean {
        return metadata.hasObservers() ||
            reactions.hasObservers() ||
            boosts.hasObservers() ||
            replies.hasObservers() ||
            reports.hasObservers() ||
            relays.hasObservers() ||
            zaps.hasObservers() ||
            authorChanges.hasObservers() ||
            hasEvent.hasObservers() ||
            hasReactions.hasObservers() ||
            replyCount.hasObservers() ||
            reactionCount.hasObservers() ||
            boostCount.hasObservers()
    }

    fun destroy() {
        innerMetadata.destroy()
        innerReactions.destroy()
        innerBoosts.destroy()
        innerReplies.destroy()
        innerReports.destroy()
        innerRelays.destroy()
        innerZaps.destroy()
    }
}

@Stable
class NoteBundledRefresherFlow(val note: Note) {
    // Refreshes observers in batches.
    private val bundler = BundledUpdate(500, Dispatchers.IO)
    val stateFlow = MutableStateFlow(NoteState(note))

    fun destroy() {
        bundler.cancel()
    }

    fun invalidateData() {
        checkNotInMainThread()

        bundler.invalidate() {
            checkNotInMainThread()

            stateFlow.emit(NoteState(note))
        }
    }
}

@Stable
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
    // Refreshes observers in batches.
    private val bundler = BundledUpdate(500, Dispatchers.IO)

    fun destroy() {
        bundler.cancel()
    }

    fun invalidateData() {
        checkNotInMainThread()

        bundler.invalidate() {
            checkNotInMainThread()

            postValue(NoteState(note))
        }
    }

    fun <Y> map(
        transform: (NoteState) -> Y
    ): NoteLoadingLiveData<Y> {
        val initialValue = this.value?.let { transform(it) }
        val result = NoteLoadingLiveData(note, initialValue)
        result.addSource(this) { x -> result.value = transform(x) }
        return result
    }
}

@Stable
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
    override fun onActive() {
        super.onActive()
        if (note is AddressableNote) {
            NostrSingleEventDataSource.addAddress(note)
        } else {
            NostrSingleEventDataSource.add(note)
        }
    }

    override fun onInactive() {
        super.onInactive()
        if (note is AddressableNote) {
            NostrSingleEventDataSource.removeAddress(note)
        } else {
            NostrSingleEventDataSource.remove(note)
        }
    }
}

@Immutable
class NoteState(val note: Note)

object RelayBriefInfoCache {
    val cache = LruCache<String, RelayBriefInfo?>(50)

    @Immutable
    data class RelayBriefInfo(
        val url: String,
        val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
        val favIcon: String = "https://$displayUrl/favicon.ico".intern()
    )

    fun get(url: String): RelayBriefInfo {
        val info = cache[url]
        if (info != null) return info

        val newInfo = RelayBriefInfo(url)
        cache.put(url, newInfo)
        return newInfo
    }
}
