package com.bitchat.android.ui

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.DarkBitchatColorScheme
import com.bitchat.android.ui.theme.DarkBitchatPalette
import com.bitchat.android.ui.theme.LightBitchatColorScheme
import com.bitchat.android.ui.theme.LightBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.PeerColorStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
 * Runs under Robolectric because URL detection in message bodies goes through
 * `android.util.Patterns.WEB_URL`, which is null on a bare JVM.
 */
@RunWith(RobolectricTestRunner::class)
class ChatUIUtilsTest {
    private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply {
        timeZone = java.util.TimeZone.getTimeZone("UTC")
    }

    private val palette = DarkBitchatPalette
    private val colorScheme = DarkBitchatColorScheme

    private fun message(
        content: String,
        sender: String = "alice",
        powDifficulty: Int? = null,
    ) = BitchatMessage(
        sender = sender,
        content = content,
        timestamp = Date(0),
        powDifficulty = powDifficulty,
    )

    // MARK: - Timestamp metadata

    @Test
    fun `text message metadata separates PoW badge with one space`() {
        assertEquals(
            "00:00 ⛨12b",
            formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text,
        )
    }

    @Test
    fun `text message metadata omits non-positive PoW difficulty`() {
        assertEquals(
            "00:00",
            formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text,
        )
    }

    // MARK: - Body with inline trailing timestamp

    @Test
    fun `body appends timestamp inline after the message text`() {
        val body = formatTextMessageBody(
            message = message("hello there"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
        )

        assertEquals("hello there  00:00", body.text)
        val timestamp = body.spanStyles.first {
            body.text.substring(it.start, it.end) == "  00:00"
        }.item
        assertEquals(10.sp, timestamp.fontSize)
        assertEquals(FontWeight.Normal, timestamp.fontWeight)
        assertEquals(palette.textTertiary, timestamp.color)
    }

    @Test
    fun `body appends PoW badge after the inline timestamp`() {
        val body = formatTextMessageBody(
            message = message("mined", powDifficulty = 8),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
        )

        assertEquals("mined  00:00 ⛨8b", body.text)
        val timestampAndPow = body.spanStyles.first {
            body.text.substring(it.start, it.end) == "  00:00 ⛨8b"
        }.item
        assertEquals(10.sp, timestampAndPow.fontSize)
        assertEquals(palette.textTertiary, timestampAndPow.color)
    }

    @Test
    fun `body can omit the timestamp for callers that render it separately`() {
        val body = formatTextMessageBody(
            message = message("hello there"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        assertEquals("hello there", body.text)
    }

    @Test
    fun `body renders plain text in the neutral palette color, not terminal green`() {
        val body = formatTextMessageBody(
            message = message("plain words"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        val textStyle = body.spanStyles.first { it.start == 0 }
        assertEquals(colorScheme.onSurface, textStyle.item.color)
    }

    @Test
    fun `chat text styles match the exported type scale`() {
        assertEquals(14.sp, MessageBodyTextStyle.fontSize)
        assertEquals(20.sp, MessageBodyTextStyle.lineHeight)
        assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight)
        assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily)
        assertEquals(14.sp, MessageSenderTextStyle.fontSize)
        assertEquals(16.sp, MessageSenderTextStyle.lineHeight)
        assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight)
    }

    @Test
    fun `body does not bold plain text for the sender's own messages`() {
        // Regression guard: the old renderer bolded the entire body when the message was yours,
        // and again when you were mentioned, which is what made busy channels unreadable.
        val body = formatTextMessageBody(
            message = message("my own words", sender = "bob"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        assertTrue(
            "no span should force a bold weight",
            body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true }
        )
    }

    // MARK: - Mention chips

    private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) =
        body.spanStyles.filter { it.item.background.isSpecified() }

    private fun androidx.compose.ui.graphics.Color.isSpecified() =
        this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f

    @Test
    fun `mention renders as a single contiguous background chip`() {
        val body = formatTextMessageBody(
            message = message("hey @carol#04af what's up"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        val chips = mentionChipSpans(body)
        assertEquals("expected exactly one chip", 1, chips.size)

        // The chip must cover "@carol#04af" as one run so it paints without seams.
        val chip = chips.single()
        assertEquals("@carol#04af", body.text.substring(chip.start, chip.end))
    }

    @Test
    fun `mention targeting the current user uses the orange accent`() {
        val body = formatTextMessageBody(
            message = message("ping @bob now"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        val chip = mentionChipSpans(body).single()
        assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background)
        assertEquals(0.2f, chip.item.background.alpha)

        val nameStyle: SpanStyle = body.spanStyles
            .first { it.start == chip.start && it.item.color == palette.accentOrange }
            .item
        assertEquals(palette.accentOrange, nameStyle.color)
    }

    @Test
    fun `mention of another user is tinted by that user's own peer color`() {
        val pubkey = "0123456789abcdef".repeat(4)
        val identity = PeerIdentity.nostr(pubkey)
        val mentionPeerIdentities = buildMentionPeerIdentityMap(
            messages = listOf(
                BitchatMessage(
                    sender = "carol#04af",
                    content = "hello",
                    timestamp = Date(0),
                    senderPeerID = "nostr:${pubkey.take(8)}",
                    senderNostrPubkey = pubkey,
                )
            )
        )
        val body = formatTextMessageBody(
            message = message("cc @carol#04af"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            mentionPeerIdentities = mentionPeerIdentities,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        val expected = colorForPeer(identity, palette)
        val chip = mentionChipSpans(body).single()
        assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
        assertTrue(body.spanStyles.any { it.item.color == expected })
    }

    @Test
    fun `composer colors nickname and hash suffix from the mentioned peer identity`() {
        val pubkey = "0123456789abcdef".repeat(4)
        val identity = PeerIdentity.nostr(pubkey)
        val token = "@carol#04af"
        val input = "ping $token now"
        val transformed = MentionVisualTransformation(
            mentionPeerIdentities = mapOf("carol#04af" to identity),
            palette = palette,
        ).filter(AnnotatedString(input)).text

        val expectedColor = colorForPeer(identity, palette)
        val tokenStart = input.indexOf(token)
        val suffixStart = input.indexOf("#04af")
        val tokenEnd = tokenStart + token.length

        assertEquals(input, transformed.text)
        assertTrue(transformed.spanStyles.any {
            it.start == tokenStart &&
                it.end == tokenEnd &&
                it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA)
        })
        assertTrue(transformed.spanStyles.any {
            it.start == tokenStart &&
                it.end == suffixStart &&
                it.item.color == expectedColor
        })
        assertTrue(transformed.spanStyles.any {
            it.start == suffixStart &&
                it.end == tokenEnd &&
                it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA)
        })
    }

    @Test
    fun `ambiguous base nickname is not assigned to the wrong peer`() {
        val firstIdentity = PeerIdentity.nostr("11111111".repeat(8))
        val secondIdentity = PeerIdentity.nostr("22222222".repeat(8))
        val identities = buildMentionPeerIdentityMap(
            messages = emptyList(),
            knownPeers = listOf(
                "alice#1111" to firstIdentity,
                "alice#2222" to secondIdentity,
            )
        )

        assertEquals(firstIdentity, identities["alice#1111"])
        assertEquals(secondIdentity, identities["alice#2222"])
        assertFalse(identities.containsKey("alice"))
        assertEquals(
            firstIdentity,
            resolveMentionPeerIdentity("@alice#1111", identities)
        )
        assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
    }

    @Test
    fun `message without mentions has no background chips`() {
        val body = formatTextMessageBody(
            message = message("no mentions in here"),
            currentUserNickname = "bob",
            palette = palette,
            contentColor = colorScheme.onSurface,
            linkColor = colorScheme.secondary,
            timeFormatter = timeFormatter,
            includeTimestamp = false,
        )

        assertTrue(mentionChipSpans(body).isEmpty())
    }

    // MARK: - System / action messages

    @Test
    fun `system message uses a double-slash prefix and no brackets`() {
        val text = formatSystemMessage(
            message = message("Tor started. Routing all chats via Tor", sender = "system"),
            contentColor = colorScheme.onSurface,
            timeFormatter = timeFormatter,
        ).text

        assertEquals("// Tor started. Routing all chats via Tor  00:00", text)
    }

    @Test
    fun `system message is not italic`() {
        // The old treatment was `* italic asterisks *`, which competed visually with real
        // messages despite being lower-priority narration.
        val annotated = formatSystemMessage(
            message = message("tor restarting", sender = "system"),
            contentColor = colorScheme.onSurface,
            timeFormatter = timeFormatter,
        )

        assertTrue(annotated.spanStyles.all { it.item.fontStyle == null })
    }

    @Test
    fun `system action and timestamp use their exported weights sizes and opacity`() {
        val annotated = formatSystemMessage(
            message = message("tor restarting", sender = "system"),
            contentColor = colorScheme.onSurface,
            timeFormatter = timeFormatter,
        )

        val action = annotated.spanStyles.first {
            annotated.text.substring(it.start, it.end) == "// tor restarting"
        }.item
        val time = annotated.spanStyles.first {
            annotated.text.substring(it.start, it.end) == "  00:00"
        }.item

        assertEquals(12.sp, action.fontSize)
        assertEquals(FontWeight.Medium, action.fontWeight)
        assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color)
        assertEquals(10.sp, time.fontSize)
        assertEquals(FontWeight.Normal, time.fontWeight)
        assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color)
    }

    // MARK: - Sender label

    @Test
    fun `sender label drops angle brackets and dims the hash suffix`() {
        val sender = formatTextMessageSender(
            message = message("hi", sender = "carol#04af"),
            currentUserNickname = "bob",
            myPeerID = "peer-me",
            palette = palette,
        )

        assertEquals("@carol#04af", sender.text)

        val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
        val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
        assertNotNull(suffixSpan.item.color)
        assertEquals(14.sp, nameSpan.item.fontSize)
        assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight)
        assertEquals(14.sp, suffixSpan.item.fontSize)
        assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight)
        assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha)
        assertTrue(
            "suffix must be dimmer than the name",
            suffixSpan.item.color.alpha < nameSpan.item.color.alpha
        )
    }

    @Test
    fun `sender label annotates the nickname for others but not for yourself`() {
        val other = formatTextMessageSender(
            message = message("hi", sender = "carol#04af"),
            currentUserNickname = "bob",
            myPeerID = "peer-me",
            palette = palette,
        )
        assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size)

        val mine = formatTextMessageSender(
            message = message("hi", sender = "bob"),
            currentUserNickname = "bob",
            myPeerID = "peer-me",
            palette = palette,
        )
        assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty())
    }

    // MARK: - Peer colors

    @Test
    fun `peer identity factories normalize stable IDs without resolving UI colors`() {
        assertEquals(
            PeerIdentity.mesh("abcdef"),
            PeerIdentity.mesh("ABCDEF")
        )
        assertEquals(
            PeerIdentity.nostr("abcdef"),
            PeerIdentity.nostr("ABCDEF")
        )
        assertEquals(
            PeerIdentity.nostr("abcdef"),
            PeerIdentity.nostr("nostr:nostr_ABCDEF")
        )
        assertEquals(
            "nostr:nostr:abcdef01",
            PeerIdentity.nostr("ABCDEF0123456789").stableKey
        )
        assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey)
    }

    @Test
    fun `peer color hue is stable across light and dark, only chroma differs`() {
        // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
        // theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
        val identity = PeerIdentity.mesh("abc")
        val dark = colorForPeer(identity, DarkBitchatPalette)
        val light = colorForPeer(identity, LightBitchatPalette)

        val darkHsv = FloatArray(3)
        val lightHsv = FloatArray(3)
        rgbToHsv(dark.red, dark.green, dark.blue, darkHsv)
        rgbToHsv(light.red, light.green, light.blue, lightHsv)

        assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
        assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
        assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
        assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
        assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
        // Dark theme: muted chroma, never dark (readable on near-black).
        assertTrue(darkHsv[1] < 0.75f)
        assertTrue(darkHsv[2] >= 0.75f)
        // Light theme: avoid neon / near-white peer labels.
        assertTrue(lightHsv[1] < 0.85f)
        assertTrue(lightHsv[2] <= 0.55f)
    }

    @Test
    fun `peer color avoids the orange hue reserved for self`() {
        // Sweep a range of seeds; none may land within the reserved orange band.
        repeat(500) { i ->
            val color = colorForPeer(
                PeerIdentity.mesh("seed$i"),
                DarkBitchatPalette
            )
            val hsv = FloatArray(3)
            rgbToHsv(color.red, color.green, color.blue, hsv)
            val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f)
            assertTrue(
                "seed$i resolved to ${hsv[0]}°, inside the reserved orange band",
                distanceFromOrange >= 17f || hsv[1] < 0.01f
            )
        }
    }

    @Test
    fun `material owns standard text while Bitchat palette owns peer chroma`() {
        assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
        assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
        assertTrue(
            LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
        )
    }

    @Test
    fun `geohash chat and people sheet resolve the same full Nostr identity`() {
        val pubkey = "ABCDEF0123456789".repeat(4)
        val peopleIdentity = PeerIdentity.nostr(pubkey)
        val chatIdentity = peerIdentityForMessage(
            BitchatMessage(
                sender = "alice#1234",
                content = "hello",
                timestamp = Date(0),
                senderPeerID = "nostr:${pubkey.take(8)}",
                senderNostrPubkey = pubkey,
            )
        )

        assertEquals(peopleIdentity, chatIdentity)
        assertEquals(
            colorForPeer(peopleIdentity, palette),
            colorForPeer(chatIdentity, palette)
        )
    }

    @Test
    fun `mesh chat and people sheet resolve the same peer identity`() {
        val peerID = "ABCDEF0123456789"
        val peopleIdentity = PeerIdentity.mesh(peerID)
        val chatIdentity = peerIdentityForMessage(
            BitchatMessage(
                sender = "alice#1234",
                content = "hello",
                timestamp = Date(0),
                senderPeerID = peerID,
            )
        )

        assertEquals(peopleIdentity, chatIdentity)
    }

    @Test
    fun `full Nostr identity wins over a truncated routing alias`() {
        val pubkey = "0123456789ABCDEF".repeat(4)
        val identity = peerIdentityForMessage(
            BitchatMessage(
                sender = "alice",
                content = "hello",
                timestamp = Date(0),
                senderPeerID = "nostr_${pubkey.take(16)}",
                senderNostrPubkey = pubkey,
            )
        )

        assertEquals(PeerIdentity.nostr(pubkey), identity)
    }

    private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
        val max = maxOf(r, g, b)
        val min = minOf(r, g, b)
        val delta = max - min
        out[0] = when {
            delta == 0f -> 0f
            max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f
            max == g -> 60f * (((b - r) / delta) + 2f)
            else -> 60f * (((r - g) / delta) + 4f)
        }
        out[1] = if (max == 0f) 0f else delta / max
        out[2] = max
    }
}
