package com.rosetta.messenger.ui.chats.components

import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.util.Base64
import android.util.LruCache
import android.webkit.MimeTypeMap
import android.widget.VideoView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min
import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.core.content.FileProvider

private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val VOICE_WAVE_DEBUG_LOG = BuildConfig.DEBUG
private val whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }

private fun rosettaDev1AttachmentLog(context: Context, tag: String, message: String) {
    if (!VOICE_WAVE_DEBUG_LOG) return
    runCatching {
        val dir = java.io.File(context.filesDir, "crash_reports")
        if (!dir.exists()) dir.mkdirs()
        val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
        java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
    }.onFailure { err ->
        android.util.Log.e(TAG, "rosettadev1 write failed: ${err.message}")
    }
}

private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")

private fun canonicalGroupDialogKey(value: String): String {
    val trimmed = value.trim()
    if (trimmed.isBlank()) return ""
    val groupId =
            when {
                trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
                trimmed.startsWith("group:", ignoreCase = true) ->
                        trimmed.substringAfter(':').trim()
                else -> return trimmed
            }
    return if (groupId.isBlank()) "" else "#group:$groupId"
}

private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
    if (!isGroupStoredKey(storedKey)) return null
    val encoded = storedKey.removePrefix("group:")
    if (encoded.isBlank()) return null
    return CryptoManager.decryptWithPassword(encoded, privateKey)
}

private fun decodeBase64Payload(data: String): ByteArray? {
    val raw = data.trim()
    if (raw.isBlank()) return null
    val payload =
            if (raw.startsWith("data:") && raw.contains(",")) {
                raw.substringAfter(",")
            } else {
                raw
            }
    return try {
        Base64.decode(payload, Base64.DEFAULT)
    } catch (_: Exception) {
        null
    }
}

private fun decodeHexPayload(data: String): ByteArray? {
    val raw = data.trim().removePrefix("0x")
    if (raw.isBlank() || raw.length % 2 != 0) return null
    fun nibble(ch: Char): Int =
        when (ch) {
            in '0'..'9' -> ch.code - '0'.code
            in 'a'..'f' -> ch.code - 'a'.code + 10
            in 'A'..'F' -> ch.code - 'A'.code + 10
            else -> -1
        }
    val out = ByteArray(raw.length / 2)
    var outIndex = 0
    var index = 0
    while (index < raw.length) {
        val hi = nibble(raw[index])
        val lo = nibble(raw[index + 1])
        if (hi < 0 || lo < 0) return null
        out[outIndex++] = ((hi shl 4) or lo).toByte()
        index += 2
    }
    return out
}

private fun decodeVoicePayload(data: String): ByteArray? {
    return decodeHexPayload(data) ?: decodeBase64Payload(data)
}

data class VoiceQueueEntry(
    val attachmentId: String,
    val dialogKey: String,
    val timestampMs: Long,
    val orderInMessage: Int,
    val senderLabel: String,
    val playedAtLabel: String,
    val audioFilePath: String?,
    val payload: String,
    val downloadTag: String,
    val transportServer: String,
    val chachaKey: String,
    val chachaKeyPlainHex: String,
    val privateKey: String
)

object VoicePlaybackCoordinator {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    private val speedSteps = listOf(1f, 1.5f, 2f)
    private const val SEEK_STALE_SAMPLE_WINDOW_MS = 900L
    private const val SEEK_SETTLE_TOLERANCE_MS = 180
    private var player: MediaPlayer? = null
    private var currentAttachmentId: String? = null
    private var appContext: Context? = null
    private var progressJob: Job? = null
    private var pendingSeekTargetMs: Int? = null
    private var pendingSeekExpiresAtMs: Long = 0L
    private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
    private val _playingAttachmentId = MutableStateFlow<String?>(null)
    val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
    private val _playingDialogKey = MutableStateFlow<String?>(null)
    val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
    private val _positionMs = MutableStateFlow(0)
    val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
    private val _durationMs = MutableStateFlow(0)
    val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
    private val _isPlaying = MutableStateFlow(false)
    val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
    private val _playbackSpeed = MutableStateFlow(1f)
    val playbackSpeed: StateFlow<Float> = _playbackSpeed.asStateFlow()
    private val _playingSenderLabel = MutableStateFlow("")
    val playingSenderLabel: StateFlow<String> = _playingSenderLabel.asStateFlow()
    private val _playingTimeLabel = MutableStateFlow("")
    val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()

    fun upsertQueueEntry(context: Context, entry: VoiceQueueEntry) {
        appContext = context.applicationContext
        queueEntries[entry.attachmentId] = entry
    }

    fun removeQueueEntry(attachmentId: String) {
        queueEntries.remove(attachmentId)
    }

    private fun setPendingSeek(targetMs: Int) {
        pendingSeekTargetMs = targetMs.coerceAtLeast(0)
        pendingSeekExpiresAtMs = SystemClock.elapsedRealtime() + SEEK_STALE_SAMPLE_WINDOW_MS
    }

    private fun clearPendingSeek() {
        pendingSeekTargetMs = null
        pendingSeekExpiresAtMs = 0L
    }

    private fun resolvePositionForUi(sampledPositionMs: Int): Int {
        val pendingTarget = pendingSeekTargetMs ?: return sampledPositionMs
        val now = SystemClock.elapsedRealtime()
        val settled = kotlin.math.abs(sampledPositionMs - pendingTarget) <= SEEK_SETTLE_TOLERANCE_MS
        val expired = now >= pendingSeekExpiresAtMs
        return if (settled || expired) {
            clearPendingSeek()
            sampledPositionMs
        } else {
            pendingTarget
        }
    }

    fun toggle(
            attachmentId: String,
            sourceFile: File,
            dialogKey: String = "",
            senderLabel: String = "",
            playedAtLabel: String = "",
            onError: (String) -> Unit = {}
    ) {
        if (!sourceFile.exists()) {
            onError("Voice file is missing")
            return
        }

        if (currentAttachmentId == attachmentId && player != null) {
            if (_isPlaying.value) {
                pause()
            } else {
                resume(onError = onError)
            }
            return
        }

        stop()
        clearPendingSeek()
        val mediaPlayer = MediaPlayer()
        try {
            mediaPlayer.setAudioAttributes(
                AudioAttributes.Builder()
                    .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .build()
            )
            mediaPlayer.setDataSource(sourceFile.absolutePath)
            mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) }
            mediaPlayer.prepare()
            applyPlaybackSpeed(mediaPlayer)
            mediaPlayer.start()
            player = mediaPlayer
            currentAttachmentId = attachmentId
            _playingAttachmentId.value = attachmentId
            _playingDialogKey.value = dialogKey.trim().ifBlank { null }
            _playingSenderLabel.value = senderLabel.trim()
            _playingTimeLabel.value = playedAtLabel.trim()
            _durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
            _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
            _isPlaying.value = true
            startProgressUpdates(attachmentId)
        } catch (e: Exception) {
            runCatching { mediaPlayer.release() }
            stop()
            onError(e.message ?: "Playback failed")
        }
    }

    fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
        if (player == null || currentAttachmentId.isNullOrBlank()) return
        if (_isPlaying.value) {
            pause()
        } else {
            resume(onError = onError)
        }
    }

    fun prepareForScrub(
            attachmentId: String,
            sourceFile: File,
            dialogKey: String = "",
            senderLabel: String = "",
            playedAtLabel: String = "",
            onError: (String) -> Unit = {}
    ) {
        if (!sourceFile.exists()) {
            onError("Voice file is missing")
            return
        }

        if (currentAttachmentId == attachmentId && player != null) {
            clearPendingSeek()
            val active = player ?: return
            _playingAttachmentId.value = attachmentId
            _playingDialogKey.value = dialogKey.trim().ifBlank { null }
            _playingSenderLabel.value = senderLabel.trim()
            _playingTimeLabel.value = playedAtLabel.trim()
            _durationMs.value = runCatching { active.duration }.getOrDefault(0).coerceAtLeast(0)
            _positionMs.value = runCatching { active.currentPosition }.getOrDefault(0).coerceAtLeast(0)
            return
        }

        stop()
        clearPendingSeek()
        val mediaPlayer = MediaPlayer()
        try {
            mediaPlayer.setAudioAttributes(
                AudioAttributes.Builder()
                    .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .build()
            )
            mediaPlayer.setDataSource(sourceFile.absolutePath)
            mediaPlayer.setOnCompletionListener { handleTrackCompleted(attachmentId) }
            mediaPlayer.prepare()
            applyPlaybackSpeed(mediaPlayer)
            player = mediaPlayer
            currentAttachmentId = attachmentId
            _playingAttachmentId.value = attachmentId
            _playingDialogKey.value = dialogKey.trim().ifBlank { null }
            _playingSenderLabel.value = senderLabel.trim()
            _playingTimeLabel.value = playedAtLabel.trim()
            _durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
            _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
            _isPlaying.value = false
            progressJob?.cancel()
            progressJob = null
        } catch (e: Exception) {
            runCatching { mediaPlayer.release() }
            stop()
            onError(e.message ?: "Playback failed")
        }
    }

    fun pause() {
        clearPendingSeek()
        val active = player ?: return
        runCatching {
            if (active.isPlaying) active.pause()
        }
        _isPlaying.value = false
        progressJob?.cancel()
        progressJob = null
        _positionMs.value = active.currentPosition.coerceAtLeast(0)
    }

    fun resume(onError: (String) -> Unit = {}) {
        val active = player ?: return
        val attachmentId = currentAttachmentId
        if (attachmentId.isNullOrBlank()) return
        try {
            applyPlaybackSpeed(active)
            active.start()
            _durationMs.value = active.duration.coerceAtLeast(0)
            _positionMs.value = active.currentPosition.coerceAtLeast(0)
            _isPlaying.value = true
            startProgressUpdates(attachmentId)
        } catch (e: Exception) {
            stop()
            onError(e.message ?: "Playback failed")
        }
    }

    fun cycleSpeed() {
        val current = _playbackSpeed.value
        val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
        val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
        setPlaybackSpeed(next)
    }

    fun seekTo(positionMs: Int, keepPaused: Boolean = false) {
        val active = player ?: return
        val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
        if (duration <= 0) return
        val clampedPosition = positionMs.coerceIn(0, duration)
        val seekResult = runCatching {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC)
            } else {
                @Suppress("DEPRECATION")
                active.seekTo(clampedPosition)
            }
        }
        if (seekResult.isSuccess) {
            setPendingSeek(clampedPosition)
        } else {
            clearPendingSeek()
        }
        if (keepPaused) {
            // Some devices auto-resume after seek; for paused scrub we force pause every time.
            runCatching {
                if (active.isPlaying) active.pause()
            }
            _isPlaying.value = false
            progressJob?.cancel()
            progressJob = null
        } else {
            _isPlaying.value = runCatching { active.isPlaying }.getOrDefault(false)
        }
        _positionMs.value = clampedPosition
        _durationMs.value = duration
    }

    fun seekToProgress(progress: Float, keepPaused: Boolean = false) {
        val active = player ?: return
        val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
        if (duration <= 0) return
        val clampedProgress = progress.coerceIn(0f, 1f)
        val targetMs = (duration.toFloat() * clampedProgress).toInt().coerceIn(0, duration)
        seekTo(targetMs, keepPaused = keepPaused)
    }

    private fun setPlaybackSpeed(speed: Float) {
        val normalized =
                speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
        _playbackSpeed.value = normalized
        // Only apply to the player if it's currently playing — otherwise setting
        // playbackParams auto-resumes a paused MediaPlayer (Android quirk).
        // The new speed will be applied on the next resume() call.
        if (_isPlaying.value) {
            player?.let { applyPlaybackSpeed(it) }
        }
    }

    private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
        runCatching {
            val current = mediaPlayer.playbackParams
            mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
        }
    }

    private fun startProgressUpdates(attachmentId: String) {
        progressJob?.cancel()
        progressJob =
                scope.launch {
                    while (isActive && currentAttachmentId == attachmentId) {
                        val active = player ?: break
                        val sampledPosition = active.currentPosition.coerceAtLeast(0)
                        _positionMs.value = resolvePositionForUi(sampledPosition)
                        _durationMs.value = active.duration.coerceAtLeast(0)
                        if (!active.isPlaying) break
                        delay(120)
                    }
                    _isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
                }
    }

    fun stop() {
        clearPendingSeek()
        val active = player
        player = null
        currentAttachmentId = null
        progressJob?.cancel()
        progressJob = null
        _playingAttachmentId.value = null
        _playingDialogKey.value = null
        _playingSenderLabel.value = ""
        _playingTimeLabel.value = ""
        _positionMs.value = 0
        _durationMs.value = 0
        _isPlaying.value = false
        if (active != null) {
            runCatching {
                if (active.isPlaying) active.stop()
            }
            runCatching { active.release() }
        }
    }

    private fun normalizedDialogKey(value: String): String {
        return value.trim()
    }

    private fun orderedEntriesForDialog(dialogKey: String): List<VoiceQueueEntry> {
        val normalized = normalizedDialogKey(dialogKey)
        return queueEntries.values
            .asSequence()
            .filter { normalizedDialogKey(it.dialogKey) == normalized }
            .sortedWith(
                // Telegram-like "next voice": move forward in timeline (old -> new).
                compareBy<VoiceQueueEntry> { it.timestampMs }
                    .thenBy { it.orderInMessage }
                    .thenBy { it.attachmentId }
            )
            .toList()
    }

    private suspend fun prepareFileForEntry(entry: VoiceQueueEntry): File? {
        entry.audioFilePath?.trim()?.takeIf { it.isNotBlank() }?.let { path ->
            val file = File(path)
            if (file.exists() && file.length() > 0L) return file
        }

        val context = appContext ?: return null

        if (entry.payload.isNotBlank()) {
            val fromPayload =
                withContext(Dispatchers.IO) {
                    ensureVoiceAudioFile(context, entry.attachmentId, entry.payload)
                }
            if (fromPayload != null) {
                queueEntries[entry.attachmentId] =
                    entry.copy(audioFilePath = fromPayload.absolutePath)
                return fromPayload
            }
        }

        if (entry.downloadTag.isBlank()) return null

        val decrypted =
            downloadAndDecryptVoicePayload(
                attachmentId = entry.attachmentId,
                downloadTag = entry.downloadTag,
                chachaKey = entry.chachaKey,
                privateKey = entry.privateKey,
                transportServer = entry.transportServer,
                chachaKeyPlainHex = entry.chachaKeyPlainHex
            ) ?: return null

        val prepared =
            withContext(Dispatchers.IO) {
                ensureVoiceAudioFile(context, entry.attachmentId, decrypted)
            } ?: return null

        queueEntries[entry.attachmentId] =
            entry.copy(payload = decrypted, audioFilePath = prepared.absolutePath)
        return prepared
    }

    private fun handleTrackCompleted(completedAttachmentId: String) {
        val completedDialog = _playingDialogKey.value.orEmpty()
        val ordered = orderedEntriesForDialog(completedDialog)
        val currentIndex = ordered.indexOfFirst { it.attachmentId == completedAttachmentId }

        stop()
        if (currentIndex < 0) return

        val candidates = ordered.drop(currentIndex + 1)
        if (candidates.isEmpty()) return

        scope.launch {
            for (entry in candidates) {
                val file = prepareFileForEntry(entry) ?: continue
                toggle(
                    attachmentId = entry.attachmentId,
                    sourceFile = file,
                    dialogKey = entry.dialogKey,
                    senderLabel = entry.senderLabel,
                    playedAtLabel = entry.playedAtLabel
                )
                return@launch
            }
        }
    }
}

private fun shortDebugId(value: String): String {
    if (value.isBlank()) return "empty"
    val clean = value.trim()
    return if (clean.length <= 8) clean else "${clean.take(8)}..."
}

private fun logPhotoDebug(message: String) {
    AttachmentDownloadDebugLogger.log(message)
}

private fun shortDebugHash(bytes: ByteArray): String {
    return try {
        val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
        digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) }
    } catch (_: Exception) {
        "shaerr"
    }
}

private const val LEGACY_ATTACHMENT_ERROR_TEXT =
        "This attachment is no longer available because it was sent for a previous version of the app."

@Composable
private fun LegacyAttachmentErrorCard(
        isDarkTheme: Boolean,
        modifier: Modifier = Modifier
) {
    val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
    val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
    val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)

    Row(
            modifier =
                    modifier.fillMaxWidth()
                            .border(1.dp, borderColor, RoundedCornerShape(8.dp))
                            .clip(RoundedCornerShape(8.dp))
                            .background(backgroundColor)
                            .padding(horizontal = 12.dp, vertical = 10.dp),
            verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(
                imageVector = Icons.Default.Close,
                contentDescription = null,
                tint = Color(0xFFE55A5A),
                modifier = Modifier.size(28.dp)
        )
        Spacer(modifier = Modifier.width(10.dp))
        Text(
                text = LEGACY_ATTACHMENT_ERROR_TEXT,
                color = textColor,
                fontSize = 12.sp
        )
    }
}

/**
 * Анимированный текст с волнообразными точками.
 * Три точки плавно подпрыгивают каскадом с изменением прозрачности.
 */
@Composable
fun AnimatedDotsText(
    baseText: String,
    color: Color,
    fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
    fontWeight: FontWeight = FontWeight.Normal
) {
    val infiniteTransition = rememberInfiniteTransition(label = "dots")

    val dot0 by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 1200
                0f at 0
                1f at 300
                0f at 600
                0f at 1200
            },
            repeatMode = RepeatMode.Restart,
            initialStartOffset = StartOffset(0)
        ),
        label = "dot0"
    )
    val dot1 by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 1200
                0f at 0
                1f at 300
                0f at 600
                0f at 1200
            },
            repeatMode = RepeatMode.Restart,
            initialStartOffset = StartOffset(200)
        ),
        label = "dot1"
    )
    val dot2 by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 1200
                0f at 0
                1f at 300
                0f at 600
                0f at 1200
            },
            repeatMode = RepeatMode.Restart,
            initialStartOffset = StartOffset(400)
        ),
        label = "dot2"
    )

    val dotValues = listOf(dot0, dot1, dot2)
    val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f }

    Row(verticalAlignment = Alignment.CenterVertically) {
        Text(
            text = baseText,
            fontSize = fontSize,
            fontWeight = fontWeight,
            color = color
        )
        dotValues.forEach { progress ->
            Text(
                text = ".",
                fontSize = fontSize,
                fontWeight = fontWeight,
                color = color.copy(alpha = 0.4f + 0.6f * progress),
                modifier = Modifier.graphicsLayer {
                    translationY = -bounceHeight * progress
                }
            )
        }
    }
}

/**
 * 🖼️ Глобальный LRU кэш для bitmap'ов изображений
 * Это предотвращает мигание при перезаходе в диалог,
 * так как изображения остаются в памяти между recomposition
 */
object ImageBitmapCache {
    // Размер кэша = 1/8 доступной памяти (стандартная практика Android)
    private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
    private val cacheSize = maxMemory / 8

    private val cache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            // Размер в килобайтах
            return bitmap.byteCount / 1024
        }
    }

    // Flow для уведомления о новых записях (заменяет polling retry loops)
    private val _updates = kotlinx.coroutines.flow.MutableSharedFlow<String>(extraBufferCapacity = 64)
    val updates: kotlinx.coroutines.flow.SharedFlow<String> = _updates

    fun get(key: String): Bitmap? = cache.get(key)

    fun put(key: String, bitmap: Bitmap) {
        if (cache.get(key) == null) {
            cache.put(key, bitmap)
            _updates.tryEmit(key)
        }
    }

    fun remove(key: String) {
        cache.remove(key)
    }

    fun clear() {
        cache.evictAll()
    }

    /**
     * Ждёт появления bitmap в кэше по ключу (вместо polling retry loop).
     * Возвращает null при таймауте.
     */
    suspend fun awaitCached(key: String, timeoutMs: Long = 3000): Bitmap? {
        // Может уже быть в кэше
        get(key)?.let { return it }
        return kotlinx.coroutines.withTimeoutOrNull(timeoutMs) {
            updates.first { it == key }
            get(key)
        }
    }
}

/**
 * 🔒 Global semaphore to limit concurrent image decode/download operations
 * This prevents lag when opening collages with many photos
 */
object ImageLoadSemaphore {
    val semaphore = kotlinx.coroutines.sync.Semaphore(3)
}

/**
 * 📐 Telegram Bubble Specification
 * Все константы взяты из ChatMessageCell.java и Theme.java
 */
object TelegramBubbleSpec {
    // === BUBBLE RADIUS ===
    val bubbleRadius = 17.dp           // chat_msgTextPaintOneEmoji
    val nearRadius = 6.dp              // smallRad для хвостика
    val minRadius = 6.dp               // Минимальный радиус
    val photoRadius = 15.dp            // bubbleRadius - 2 для медиа
    
    // === PHOTO SIZING ===
    // maxWidth = min(AndroidUtilities.displaySize.x, AndroidUtilities.displaySize.y) * 0.5f
    // Telegram использует ~50-65% ширины экрана
    fun maxPhotoWidth(screenWidth: Int): Int = (screenWidth * 0.65f).toInt()
    // Multi-photo collage should be slightly larger than a single media bubble.
    fun maxCollageWidth(screenWidth: Int): Int = (screenWidth * 0.72f).toInt()
    val maxPhotoHeight = 360           // dp, примерно maxWidth + 100
    val minPhotoWidth = 150            // dp
    val minPhotoHeight = 120           // dp
    
    // === CAPTION PADDING ===
    val captionPaddingTop = 9.dp       // Отступ от фото до caption
    val captionPaddingBottom = 6.dp
    val captionPaddingStartOutgoing = 11.dp  // Исходящие
    val captionPaddingStartIncoming = 13.dp  // Входящие
    val captionPaddingEnd = 10.dp
    
    // === TIME OVERLAY (на медиа) ===
    val timeOverlayPadding = 6.dp
    val timeOverlayRadius = 10.dp
    val timeOverlayAlpha = 0.5f
    val timeTextSize = 11.sp
    
    // === COLLAGE ===
    val collageSpacing = 1.dp          // Тонкий разделитель как в Telegram
    
    // === TEXT ===
    val messageTextSize = 16.sp
    val captionTextSize = 16.sp
}

/** Статус скачивания attachment (как в desktop) */
enum class DownloadStatus {
    DOWNLOADED,
    NOT_DOWNLOADED,
    PENDING,
    DECRYPTING,
    DOWNLOADING,
    ERROR
}

private enum class TelegramFileActionState {
    FILE,
    DOWNLOAD,
    CANCEL,
    PAUSE,
    ERROR
}

@Composable
private fun TelegramFileActionButton(
    state: TelegramFileActionState,
    progress: Float?,
    indeterminate: Boolean,
    modifier: Modifier = Modifier
) {
    val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
    val spin by spinTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1200, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "file_action_spin_progress"
    )

    val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
    val iconPainter = when (state) {
        TelegramFileActionState.FILE -> TelegramIcons.File
        TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
        TelegramFileActionState.CANCEL -> TelegramIcons.Close
        TelegramFileActionState.ERROR -> TelegramIcons.Close
        TelegramFileActionState.PAUSE -> null
    }
    val iconSize =
        when (state) {
            TelegramFileActionState.ERROR -> 18.dp
            TelegramFileActionState.PAUSE -> 18.dp
            else -> 20.dp
        }

    val showProgressRing =
        (state == TelegramFileActionState.PAUSE ||
            state == TelegramFileActionState.DOWNLOAD ||
            state == TelegramFileActionState.CANCEL) &&
            (indeterminate || progress != null)
    val sweep = when {
        indeterminate -> 104f
        progress != null -> (progress.coerceIn(0f, 1f) * 360f)
        else -> 0f
    }
    val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f

    Box(
        modifier = modifier.size(40.dp),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .clip(CircleShape)
                .background(backgroundColor),
            contentAlignment = Alignment.Center
        ) {
            if (showProgressRing && sweep > 0f) {
                Canvas(modifier = Modifier.fillMaxSize()) {
                    val strokeWidth = 2.dp.toPx()
                    val inset = 3.dp.toPx()
                    drawArc(
                        color = Color.White,
                        startAngle = startAngle,
                        sweepAngle = sweep,
                        useCenter = false,
                        topLeft = Offset(inset, inset),
                        size = Size(size.width - inset * 2f, size.height - inset * 2f),
                        style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
                    )
                }
            }

            if (state == TelegramFileActionState.PAUSE) {
                Icon(
                    imageVector = Icons.Default.Pause,
                    contentDescription = null,
                    tint = Color.White,
                    modifier = Modifier.size(iconSize)
                )
            } else {
                Icon(
                    painter = iconPainter ?: TelegramIcons.File,
                    contentDescription = null,
                    tint = Color.White,
                    modifier = Modifier.size(iconSize)
                )
            }
        }
    }
}

/**
 * Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
 * коллаж (как в Telegram)
 */
@Composable
fun MessageAttachments(
        attachments: List<MessageAttachment>,
        chachaKey: String,
        chachaKeyPlainHex: String = "",
        privateKey: String,
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        senderPublicKey: String,
        senderDisplayName: String = "",
        dialogPublicKey: String = "",
        isGroupChat: Boolean = false,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ,
        avatarRepository: AvatarRepository? = null,
        currentUserPublicKey: String = "",
        hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
        showTail: Boolean = true, // Показывать хвостик пузырька
        isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
        onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
        onCancelUpload: (attachmentId: String) -> Unit = {},
        onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
        onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {},
        modifier: Modifier = Modifier
) {
    if (attachments.isEmpty()) return

    // Разделяем attachments по типам
    val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE }
    val otherAttachments =
            attachments.filter {
                it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES
            }

    Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
        // 🖼️ Коллаж для изображений (если больше 1)
        if (imageAttachments.isNotEmpty()) {
            CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
                ImageCollage(
                        attachments = imageAttachments,
                        chachaKey = chachaKey,
                        privateKey = privateKey,
                        senderPublicKey = senderPublicKey,
                        isOutgoing = isOutgoing,
                        isDarkTheme = isDarkTheme,
                        timestamp = timestamp,
                        messageStatus = messageStatus,
                        hasCaption = hasCaption,
                        showTail = showTail,
                        isSelectionMode = isSelectionMode,
                        onLongClick = onLongClick,
                        onImageClick = onImageClick
                )
            }
        }

        // Остальные attachments по отдельности
        var voiceOrderInMessage = 0
        otherAttachments.forEach { attachment ->
            when (attachment.type) {
                AttachmentType.FILE -> {
                    FileAttachment(
                            attachment = attachment,
                            chachaKey = chachaKey,
                            privateKey = privateKey,
                            currentUserPublicKey = currentUserPublicKey,
                            isOutgoing = isOutgoing,
                            isDarkTheme = isDarkTheme,
                            timestamp = timestamp,
                            messageStatus = messageStatus
                    )
                }
                AttachmentType.AVATAR -> {
                    AvatarAttachment(
                            attachment = attachment,
                            chachaKey = chachaKey,
                            privateKey = privateKey,
                            senderPublicKey = senderPublicKey,
                            dialogPublicKey = dialogPublicKey,
                            isGroupChat = isGroupChat,
                            avatarRepository = avatarRepository,
                            currentUserPublicKey = currentUserPublicKey,
                            isOutgoing = isOutgoing,
                            isDarkTheme = isDarkTheme,
                            timestamp = timestamp,
                            messageStatus = messageStatus
                    )
                }
                AttachmentType.CALL -> {
                    CallAttachment(
                            attachment = attachment,
                            isOutgoing = isOutgoing,
                            isDarkTheme = isDarkTheme
                    )
                }
                AttachmentType.VOICE -> {
                    val currentVoiceOrder = voiceOrderInMessage++
                    VoiceAttachment(
                            attachment = attachment,
                            chachaKey = chachaKey,
                            chachaKeyPlainHex = chachaKeyPlainHex,
                            privateKey = privateKey,
                            senderPublicKey = senderPublicKey,
                            senderDisplayName = senderDisplayName,
                            dialogPublicKey = dialogPublicKey,
                            isOutgoing = isOutgoing,
                            isDarkTheme = isDarkTheme,
                            timestamp = timestamp,
                            messageStatus = messageStatus,
                            attachmentOrderInMessage = currentVoiceOrder,
                            onVoiceWaveGestureActiveChanged = onVoiceWaveGestureActiveChanged
                    )
                }
                AttachmentType.VIDEO_CIRCLE -> {
                    VideoCircleAttachment(
                            attachment = attachment,
                            chachaKey = chachaKey,
                            chachaKeyPlainHex = chachaKeyPlainHex,
                            privateKey = privateKey,
                            senderPublicKey = senderPublicKey,
                            isOutgoing = isOutgoing,
                            isDarkTheme = isDarkTheme,
                            timestamp = timestamp,
                            messageStatus = messageStatus
                    )
                }
                else -> {
                    // Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
                    LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
                }
            }
        }
    }
}

/**
 * 🖼️ Коллаж изображений в стиле Telegram Разные layout'ы в зависимости от количества фото:
 * - 1 фото: полная ширина
 * - 2 фото: 2 колонки
 * - 3 фото: 1 большое слева + 2 маленьких справа
 * - 4 фото: 2x2 сетка
 * - 5+ фото: 2 сверху + 3 снизу (или больше рядов)
 */
@Composable
fun ImageCollage(
        attachments: List<MessageAttachment>,
        chachaKey: String,
        privateKey: String,
        senderPublicKey: String,
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ,
        hasCaption: Boolean = false, // Если есть caption - время показывается под фото
        showTail: Boolean = true, // Показывать хвостик пузырька
        isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
        onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
        onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
        modifier: Modifier = Modifier
) {
    val count = attachments.size
    val spacing = TelegramBubbleSpec.collageSpacing
    val collageDividerColor =
            if (isOutgoing) {
                Color.White.copy(alpha = if (isDarkTheme) 0.18f else 0.22f)
            } else {
                if (isDarkTheme) Color(0xFF2D3036) else Color(0xFFD8DEE8)
            }

    // Показываем время и статус только если нет caption
    val showOverlayOnLast = !hasCaption

    // Telegram: bubbleRadius=17dp, photoRadius=15dp (bubbleRadius-2), minRadius=6dp
    val collageShape =
            if (hasCaption) {
                // Фото над caption: верх=15dp, низ=0dp (плавный переход к caption)
                RoundedCornerShape(
                        topStart = TelegramBubbleSpec.photoRadius,
                        topEnd = TelegramBubbleSpec.photoRadius,
                        bottomStart = 0.dp,
                        bottomEnd = 0.dp
                )
            } else {
                // Фото без caption - простое закругление
                RoundedCornerShape(TelegramBubbleSpec.photoRadius)
            }

    Box(modifier = modifier.clip(collageShape).background(collageDividerColor)) {
        when (count) {
            1 -> {
                // Одно фото - размер определяется самим фото (Telegram style)
                ImageAttachment(
                        attachment = attachments[0],
                        chachaKey = chachaKey,
                        privateKey = privateKey,
                        senderPublicKey = senderPublicKey,
                        isOutgoing = isOutgoing,
                        isDarkTheme = isDarkTheme,
                        timestamp = timestamp,
                        messageStatus = messageStatus,
                        showTimeOverlay = showOverlayOnLast,
                        hasCaption = hasCaption,
                        isSelectionMode = isSelectionMode,
                        onLongClick = onLongClick,
                        onImageClick = onImageClick
                )
            }
            2 -> {
                // Два фото - горизонтально
                Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(spacing)
                ) {
                    attachments.forEachIndexed { index, attachment ->
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachment,
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = showOverlayOnLast && index == count - 1,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                    }
                }
            }
            3 -> {
                // Три фото: 1 большое слева + 2 маленьких справа
                Row(
                        modifier = Modifier.fillMaxWidth().height(200.dp),
                        horizontalArrangement = Arrangement.spacedBy(spacing)
                ) {
                    // Большое фото слева
                    Box(modifier = Modifier.weight(1.5f).fillMaxHeight()) {
                        ImageAttachment(
                                attachment = attachments[0],
                                chachaKey = chachaKey,
                                privateKey = privateKey,
                                senderPublicKey = senderPublicKey,
                                isOutgoing = isOutgoing,
                                isDarkTheme = isDarkTheme,
                                timestamp = timestamp,
                                messageStatus = messageStatus,
                                showTimeOverlay = false,
                                fillMaxSize = true,
                                isSelectionMode = isSelectionMode,
                                onLongClick = onLongClick,
                                onImageClick = onImageClick
                        )
                    }
                    // Два маленьких справа
                    Column(
                            modifier = Modifier.weight(1f).fillMaxHeight(),
                            verticalArrangement = Arrangement.spacedBy(spacing)
                    ) {
                        Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
                            ImageAttachment(
                                    attachment = attachments[1],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                        Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
                            ImageAttachment(
                                    attachment = attachments[2],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = showOverlayOnLast,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                    }
                }
            }
            4 -> {
                // Четыре фото: 2x2 сетка
                Column(
                        modifier = Modifier.fillMaxWidth(),
                        verticalArrangement = Arrangement.spacedBy(spacing)
                ) {
                    Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(spacing)
                    ) {
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[0],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[1],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                    }
                    Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(spacing)
                    ) {
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[2],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[3],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = showOverlayOnLast,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                    }
                }
            }
            else -> {
                // 5+ фото: 2 сверху + остальные снизу по 3 в ряд
                Column(
                        modifier = Modifier.fillMaxWidth(),
                        verticalArrangement = Arrangement.spacedBy(spacing)
                ) {
                    // Первые 2 фото сверху
                    Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(spacing)
                    ) {
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[0],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                        Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                            ImageAttachment(
                                    attachment = attachments[1],
                                    chachaKey = chachaKey,
                                    privateKey = privateKey,
                                    senderPublicKey = senderPublicKey,
                                    isOutgoing = isOutgoing,
                                    isDarkTheme = isDarkTheme,
                                    timestamp = timestamp,
                                    messageStatus = messageStatus,
                                    showTimeOverlay = false,
                                    fillMaxSize = true,
                                    isSelectionMode = isSelectionMode,
                                    onLongClick = onLongClick,
                                    onImageClick = onImageClick
                            )
                        }
                    }
                    // Остальные по 3 в ряд
                    val remaining = attachments.drop(2)
                    val rows = remaining.chunked(3)
                    val totalRows = rows.size
                    rows.forEachIndexed { rowIndex, rowItems ->
                        val isLastRow = rowIndex == totalRows - 1
                        Row(
                                modifier = Modifier.fillMaxWidth(),
                                horizontalArrangement = Arrangement.spacedBy(spacing)
                        ) {
                            rowItems.forEachIndexed { index, attachment ->
                                val isLastItem = isLastRow && index == rowItems.size - 1
                                Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
                                    ImageAttachment(
                                            attachment = attachment,
                                            chachaKey = chachaKey,
                                            privateKey = privateKey,
                                            senderPublicKey = senderPublicKey,
                                            isOutgoing = isOutgoing,
                                            isDarkTheme = isDarkTheme,
                                            timestamp = timestamp,
                                            messageStatus = messageStatus,
                                            showTimeOverlay = showOverlayOnLast && isLastItem,
                                            fillMaxSize = true,
                                            isSelectionMode = isSelectionMode,
                                            onLongClick = onLongClick,
                                            onImageClick = onImageClick
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/**
 * Image attachment - Telegram style с blurhash placeholder
 *
 * Логика определения статуса (как в Desktop):
 * 1. Если preview НЕ содержит UUID → это локальный файл → DOWNLOADED
 * 2. Если есть UUID (download tag) → проверяем файловую систему через AttachmentFileManager
 * 3. Если файл есть локально → DOWNLOADED (загружаем из файла)
 * 4. Иначе → NOT_DOWNLOADED (нужно скачать с CDN)
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageAttachment(
        attachment: MessageAttachment,
        chachaKey: String,
        privateKey: String,
        senderPublicKey: String,
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ,
        showTimeOverlay: Boolean = true,
        aspectRatio: Float? = null,
        fillMaxSize: Boolean = false,
        hasCaption: Boolean = false,
        isSelectionMode: Boolean = false,
        onLongClick: () -> Unit = {},
        onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    // Bounds для shared element transition
    var imageBounds by remember { mutableStateOf<Rect?>(null) }

    // 🔥 Ключ для глобального кэша
    val cacheKey = "img_${attachment.id}"
    
    // 🔥 Сначала проверяем глобальный кэш
    var downloadStatus by remember(attachment.id) { 
        mutableStateOf(
            if (ImageBitmapCache.get(cacheKey) != null) DownloadStatus.DOWNLOADED 
            else DownloadStatus.PENDING
        ) 
    }
    var imageBitmap by remember(attachment.id) { 
        mutableStateOf(ImageBitmapCache.get(cacheKey)) 
    }
    var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
    var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
    var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
    val uploadingState by TransportManager.uploading.collectAsState()
    val uploadEntry = uploadingState.firstOrNull { it.id == attachment.id }
    val uploadProgress = uploadEntry?.progress ?: 0
    val isUploadInProgress = uploadEntry != null
    val onCancelImageUpload = LocalOnCancelImageUpload.current

    val preview = getPreview(attachment)
    val downloadTag = getDownloadTag(attachment)

    // Анимация прогресса
    val animatedProgress by
            animateFloatAsState(
                    targetValue = downloadProgress,
                    animationSpec = tween(durationMillis = 300),
                    label = "progress"
            )

    // Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus)
    LaunchedEffect(attachment.id, attachment.localUri) {
        // 🔥 Если изображение уже в глобальном кэше - используем его!
        val cachedBitmap = ImageBitmapCache.get(cacheKey)
        if (cachedBitmap != null) {
            imageBitmap = cachedBitmap
            downloadStatus = DownloadStatus.DOWNLOADED
            return@LaunchedEffect
        }
        
        // 🔥 Если изображение уже загружено локально - не перезагружаем!
        if (imageBitmap != null && downloadStatus == DownloadStatus.DOWNLOADED) {
            return@LaunchedEffect
        }
        
        // Определяем статус (логика из Desktop useAttachment.ts)
        withContext(Dispatchers.IO) {
            downloadStatus =
                    when {
                        // 🚀 0. Если есть localUri - это optimistic UI, показываем сразу
                        attachment.localUri.isNotEmpty() -> {
                            DownloadStatus.DOWNLOADED
                        }
                        // 1. Если blob уже есть в памяти → DOWNLOADED
                        attachment.blob.isNotEmpty() -> {
                            DownloadStatus.DOWNLOADED
                        }
                        // 2. Нет transport tag → это локальный файл → DOWNLOADED
                        downloadTag.isBlank() -> {
                            DownloadStatus.DOWNLOADED
                        }
                        // 3. Есть UUID (download tag) → проверяем файловую систему
                        else -> {
                            // Проверяем есть ли файл локально (как в Desktop:
                            // readFile(`m/${md5hash}`))
                            val hasLocal =
                                    AttachmentFileManager.hasAttachment(
                                            context,
                                            attachment.id,
                                            senderPublicKey
                                    )
                            if (hasLocal) {
                                DownloadStatus.DOWNLOADED
                            } else {
                                DownloadStatus.NOT_DOWNLOADED
                            }
                        }
                    }
        }

        // Декодируем blurhash для placeholder (если есть)
        if (preview.isNotEmpty()) {
            withContext(Dispatchers.IO) {
                try {
                    blurhashBitmap = BlurHash.decode(preview, 200, 200)
                    if (blurhashBitmap != null) {
                    } else {
                    }
                } catch (e: Exception) {
                }
            }
        } else {
        }

        // Загружаем изображение если статус DOWNLOADED
        if (downloadStatus == DownloadStatus.DOWNLOADED) {
            // 🔒 Ограничиваем параллельные загрузки через семафор
            ImageLoadSemaphore.semaphore.withPermit {
                withContext(Dispatchers.IO) {
                    // 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
                    if (imageBitmap == null && attachment.localUri.isNotEmpty()) {
                    try {
                        val uri = android.net.Uri.parse(attachment.localUri)
                        
                        // 📐 Читаем EXIF ориентацию
                        val orientation = try {
                            context.contentResolver.openInputStream(uri)?.use { inputStream ->
                                val exif = ExifInterface(inputStream)
                                exif.getAttributeInt(
                                    ExifInterface.TAG_ORIENTATION,
                                    ExifInterface.ORIENTATION_NORMAL
                                )
                            } ?: ExifInterface.ORIENTATION_NORMAL
                        } catch (e: Exception) {
                            ExifInterface.ORIENTATION_NORMAL
                        }
                        
                        // Загружаем bitmap
                        val inputStream = context.contentResolver.openInputStream(uri)
                        var bitmap = BitmapFactory.decodeStream(inputStream)
                        inputStream?.close()
                        
                        // 🔄 Применяем EXIF ориентацию
                        if (bitmap != null && orientation != ExifInterface.ORIENTATION_NORMAL) {
                            val matrix = Matrix()
                            when (orientation) {
                                ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
                                ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
                                ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
                                ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
                                ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
                                ExifInterface.ORIENTATION_TRANSPOSE -> {
                                    matrix.postRotate(90f)
                                    matrix.preScale(-1f, 1f)
                                }
                                ExifInterface.ORIENTATION_TRANSVERSE -> {
                                    matrix.postRotate(270f)
                                    matrix.preScale(-1f, 1f)
                                }
                            }
                            val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
                            if (rotated != bitmap) {
                                bitmap.recycle()
                                bitmap = rotated
                            }
                        }
                        
                        if (bitmap != null) {
                            imageBitmap = bitmap
                            // 🔥 Сохраняем в глобальный кэш
                            ImageBitmapCache.put(cacheKey, bitmap)
                        }
                    } catch (e: Exception) {
                        // Fallback to other methods
                    }
                }
                
                // 1. Если нет bitmap, пробуем blob из памяти
                if (imageBitmap == null && attachment.blob.isNotEmpty()) {
                    val bitmap = base64ToBitmap(attachment.blob)
                    if (bitmap != null) {
                        imageBitmap = bitmap
                        // 🔥 Сохраняем в глобальный кэш
                        ImageBitmapCache.put(cacheKey, bitmap)
                    }
                }
                
                // 2. Если всё ещё нет, читаем из файловой системы
                if (imageBitmap == null) {
                    val localBlob =
                            AttachmentFileManager.readAttachment(
                                    context,
                                    attachment.id,
                                    senderPublicKey,
                                    privateKey
                            )
                    if (localBlob != null) {
                        val bitmap = base64ToBitmap(localBlob)
                        if (bitmap != null) {
                            imageBitmap = bitmap
                            // 🔥 Сохраняем в глобальный кэш
                            ImageBitmapCache.put(cacheKey, bitmap)
                        } else {
                            downloadStatus = DownloadStatus.ERROR
                        }
                    } else if (attachment.localUri.isEmpty()) {
                        // Только если нет localUri - помечаем как NOT_DOWNLOADED
                        downloadStatus = DownloadStatus.NOT_DOWNLOADED
                    }
                }
                }
            }

            if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) {
                downloadStatus =
                        if (downloadTag.isNotEmpty()) {
                            DownloadStatus.NOT_DOWNLOADED
                        } else {
                            DownloadStatus.ERROR
                        }
            }
        }
    }

    val download: () -> Unit = {
        if (downloadTag.isNotEmpty()) {
            scope.launch {
                val idShort = shortDebugId(attachment.id)
                val tagShort = shortDebugId(downloadTag)
                val server = attachment.transportServer.ifBlank { TransportManager.getTransportServer() ?: "unset" }
                logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
                try {
                    downloadStatus = DownloadStatus.DOWNLOADING

                    // Скачиваем зашифрованный контент (с одним авто-ретраем)
                    val startTime = System.currentTimeMillis()
                    val encryptedContent: String
                    try {
                        encryptedContent =
                                TransportManager.downloadFile(
                                        attachment.id,
                                        downloadTag,
                                        attachment.transportServer
                                )
                    } catch (e: Exception) {
                        // Один авто-ретрай через 1с
                        logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
                        kotlinx.coroutines.delay(1000)
                        try {
                            val retryResult =
                                    TransportManager.downloadFile(
                                            attachment.id,
                                            downloadTag,
                                            attachment.transportServer
                                    )
                            @Suppress("NAME_SHADOWING")
                            val encryptedContent = retryResult
                            logPhotoDebug("CDN retry OK: id=$idShort")
                            // Продолжаем с retryResult
                            processDownloadedImage(
                                encryptedContent = encryptedContent,
                                chachaKey = chachaKey,
                                privateKey = privateKey,
                                attachment = attachment,
                                senderPublicKey = senderPublicKey,
                                context = context,
                                cacheKey = cacheKey,
                                idShort = idShort,
                                onProgress = { downloadProgress = it },
                                onStatus = { downloadStatus = it },
                                onBitmap = { imageBitmap = it },
                                onError = { label -> errorLabel = label }
                            )
                            return@launch
                        } catch (retryEx: Exception) {
                            // CDN ошибка (файл истёк или сервер недоступен)
                            val isExpired = retryEx.message?.contains("404") == true || retryEx.message?.contains("410") == true
                            errorLabel = if (isExpired) "Expired" else "Error"
                            downloadStatus = DownloadStatus.ERROR
                            logPhotoDebug("CDN retry also failed: id=$idShort, reason=${retryEx.message}")
                            return@launch
                        }
                    }
                    val downloadTime = System.currentTimeMillis() - startTime
                    logPhotoDebug(
                            "CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
                    )
                    
                    processDownloadedImage(
                        encryptedContent = encryptedContent,
                        chachaKey = chachaKey,
                        privateKey = privateKey,
                        attachment = attachment,
                        senderPublicKey = senderPublicKey,
                        context = context,
                        cacheKey = cacheKey,
                        idShort = idShort,
                        onProgress = { downloadProgress = it },
                        onStatus = { downloadStatus = it },
                        onBitmap = { imageBitmap = it },
                        onError = { label -> errorLabel = label }
                    )
                } catch (e: Exception) {
                    e.printStackTrace()
                    downloadStatus = DownloadStatus.ERROR
                    errorLabel = "Error"
                    logPhotoDebug(
                            "Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}, stack=${e.stackTraceToString().take(200)}"
                    )
                } catch (e: OutOfMemoryError) {
                    System.gc()
                    downloadStatus = DownloadStatus.ERROR
                    errorLabel = "Error"
                    logPhotoDebug("Image OOM: id=$idShort, availMem=${Runtime.getRuntime().freeMemory() / 1024}KB")
                }
            }
        } else {
            logPhotoDebug("Skip image download: empty tag for id=${shortDebugId(attachment.id)}")
        }
    }

    // Telegram-style image с blurhash placeholder и тонким бордером
    val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
    val borderColor =
            if (isOutgoing) {
                Color.White.copy(alpha = 0.15f)
            } else {
                if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
            }

    // 📐 TELEGRAM-STYLE sizing (из ChatMessageCell.java)
    // maxWidth = min(displaySize.x, displaySize.y) * 0.5f (~65% в портрете)
    // Сохраняем aspect ratio, ограничиваем max/min размеры
    val configuration = LocalConfiguration.current
    val screenWidthDp = configuration.screenWidthDp
    
    // For incoming media where server dimensions are often missing, keep stable bubble-based fallback size
    // instead of reflowing from decoded bitmap dimensions after download.
    val actualWidth = attachment.width.takeIf { it > 0 } ?: 0
    val actualHeight = attachment.height.takeIf { it > 0 } ?: 0

    val (imageWidth, imageHeight) =
            remember(actualWidth, actualHeight, fillMaxSize, aspectRatio, screenWidthDp) {
                if (fillMaxSize) {
                    null to null
                } else {
                    // Telegram: maxWidth = screenWidth * 0.65
                    val maxPhotoWidthPx = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp)

                    if (actualWidth > 0 && actualHeight > 0) {
                        val ar = actualWidth.toFloat() / actualHeight.toFloat()
                        
                        // Telegram: scale = imageW / photoWidth, пропорционально масштабируем
                        var w: Float
                        var h: Float
                        
                        if (ar >= 1f) {
                            // Горизонтальное фото - по ширине
                            w = maxPhotoWidthPx.toFloat()
                            h = w / ar
                        } else {
                            // Вертикальное фото - уменьшаем ширину
                            w = maxPhotoWidthPx * 0.75f
                            h = w / ar
                        }
                        
                        // Ограничение максимальной высоты
                        if (h > TelegramBubbleSpec.maxPhotoHeight) {
                            h = TelegramBubbleSpec.maxPhotoHeight.toFloat()
                            w = h * ar
                        }
                        
                        // Минимальная высота
                        if (h < TelegramBubbleSpec.minPhotoHeight) {
                            h = TelegramBubbleSpec.minPhotoHeight.toFloat()
                            w = h * ar
                        }
                        
                        // Ограничения по ширине
                        w = w.coerceIn(TelegramBubbleSpec.minPhotoWidth.toFloat(), maxPhotoWidthPx.toFloat())
                        h = h.coerceIn(TelegramBubbleSpec.minPhotoHeight.toFloat(), TelegramBubbleSpec.maxPhotoHeight.toFloat())
                        
                        w.dp to h.dp
                    } else {
                        // Match placeholder size to bubble width when media dimensions are still unknown.
                        val fallbackWidthPx = maxPhotoWidthPx.toFloat()
                        val fallbackHeightPx =
                                (fallbackWidthPx * 0.75f).coerceIn(
                                        TelegramBubbleSpec.minPhotoHeight.toFloat(),
                                        TelegramBubbleSpec.maxPhotoHeight.toFloat()
                                )
                        fallbackWidthPx.dp to fallbackHeightPx.dp
                    }
                }
            }

    // Модификатор размера
    val sizeModifier =
            when {
                fillMaxSize -> Modifier.fillMaxSize()
                aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio)
                imageWidth != null && imageHeight != null ->
                        Modifier.width(imageWidth).height(imageHeight)
                else -> Modifier.size(200.dp)
            }

    // Telegram: photoRadius = 15dp (bubbleRadius - 2)
    // Если есть caption - нижние углы прямые
    val photoRadius = TelegramBubbleSpec.photoRadius
    val imageShape = when {
        fillMaxSize -> RoundedCornerShape(0.dp)
        hasCaption -> RoundedCornerShape(
            topStart = photoRadius,
            topEnd = photoRadius,
            bottomStart = 0.dp,
            bottomEnd = 0.dp
        )
        else -> RoundedCornerShape(photoRadius)
    }
    val cornerRadius = if (fillMaxSize || hasCaption) 0f else 15f

    Box(
            modifier =
                    sizeModifier
                            .clip(imageShape)
                            .background(Color.Transparent)
                            .onGloballyPositioned { coordinates ->
                                // Capture bounds for shared element transition
                                imageBounds = coordinates.boundsInWindow()
                            }
                            .combinedClickable(
                                    indication = null,
                                    interactionSource = remember { MutableInteractionSource() },
                                    onLongClick = onLongClick,
                                    onClick = {
                                        if (!isSelectionMode) {
                                            when (downloadStatus) {
                                                DownloadStatus.NOT_DOWNLOADED -> download()
                                                DownloadStatus.DOWNLOADED -> {
                                                    // 📸 Open image viewer with bounds for animation
                                                    val bounds = imageBounds?.let {
                                                        ImageSourceBounds(
                                                            left = it.left,
                                                            top = it.top,
                                                            width = it.width,
                                                            height = it.height,
                                                            cornerRadius = cornerRadius,
                                                            thumbnailBitmap = imageBitmap
                                                        )
                                                    }
                                                    onImageClick(attachment.id, bounds)
                                                }
                                                DownloadStatus.ERROR -> download()
                                                else -> {}
                                            }
                                        } else {
                                            onLongClick()
                                        }
                                    }
                            ),
            contentAlignment = Alignment.Center
    ) {
        // Фоновый слой - blurhash или placeholder
        when {
            imageBitmap != null -> {
                // Показываем полное изображение
                Image(
                        bitmap = imageBitmap!!.asImageBitmap(),
                        contentDescription = "Image",
                        modifier = Modifier.fillMaxSize(),
                        contentScale = ContentScale.Crop
                )
            }
            blurhashBitmap != null -> {
                // Показываем blurhash placeholder
                Image(
                        bitmap = blurhashBitmap!!.asImageBitmap(),
                        contentDescription = "Preview",
                        modifier = Modifier.fillMaxSize(),
                        contentScale = ContentScale.Crop
                )
            }
            else -> {
                // Простой placeholder с приятным gradient фоном
                Box(
                        modifier =
                                Modifier.fillMaxSize()
                                        .background(
                                                Brush.verticalGradient(
                                                        colors =
                                                                if (isDarkTheme)
                                                                        listOf(
                                                                                Color(0xFF2A2A2A),
                                                                                Color(0xFF1A1A1A)
                                                                        )
                                                                else
                                                                        listOf(
                                                                                Color(0xFFE8E8E8),
                                                                                Color(0xFFD0D0D0)
                                                                        )
                                                )
                                        )
                )
            }
        }

        // Время в правом нижнем углу (показываем только если showTimeOverlay = true)
        if (showTimeOverlay) {
            Box(
                    modifier =
                            Modifier.align(Alignment.BottomEnd)
                                    .padding(TelegramBubbleSpec.timeOverlayPadding)
                                    .background(
                                            Color.Black.copy(alpha = TelegramBubbleSpec.timeOverlayAlpha),
                                            shape = RoundedCornerShape(TelegramBubbleSpec.timeOverlayRadius)
                                    )
                                    .padding(horizontal = 6.dp, vertical = 3.dp)
            ) {
                Row(
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.spacedBy(2.dp)
                ) {
                    Text(
                            text = timeFormat.format(timestamp),
                            color = Color.White,
                            fontSize = TelegramBubbleSpec.timeTextSize,
                            fontWeight = FontWeight.Medium
                    )
                    if (isOutgoing) {
                        // Статус доставки для исходящих
                        when (messageStatus) {
                            MessageStatus.SENDING -> {
                                Icon(
                                        painter = TelegramIcons.Clock,
                                        contentDescription = null,
                                        tint = Color.White.copy(alpha = 0.7f),
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                            MessageStatus.SENT -> {
                                Icon(
                                        painter = TelegramIcons.Done,
                                        contentDescription = null,
                                        tint =
                                                if (isDarkTheme) Color.White
                                                else Color.White.copy(alpha = 0.7f),
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                            MessageStatus.DELIVERED -> {
                                Icon(
                                        painter = TelegramIcons.Done,
                                        contentDescription = null,
                                        tint =
                                                if (isDarkTheme) Color.White
                                                else Color.White.copy(alpha = 0.7f),
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                            MessageStatus.READ -> {
                                Box(modifier = Modifier.height(14.dp)) {
                                    Icon(
                                            painter = TelegramIcons.Done,
                                            contentDescription = null,
                                            tint = Color.White,
                                            modifier = Modifier.size(14.dp)
                                    )
                                    Icon(
                                            painter = TelegramIcons.Done,
                                            contentDescription = null,
                                            tint = Color.White,
                                            modifier = Modifier.size(14.dp).offset(x = 4.dp)
                                    )
                                }
                            }
                            else -> {}
                        }
                    }
                }
            }
        }

        // Desktop-style upload state: Encrypting... (0%) / progress ring (>0%)
        if (isOutgoing &&
                messageStatus == MessageStatus.SENDING &&
                downloadStatus == DownloadStatus.DOWNLOADED &&
                (isUploadInProgress || attachment.localUri.isNotEmpty())
        ) {
            Box(
                    modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
                    contentAlignment = Alignment.Center
            ) {
                if (uploadProgress > 0) {
                    val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
                    Box(
                            modifier = Modifier
                                    .size(44.dp)
                                    .clip(CircleShape)
                                    .background(Color.Black.copy(alpha = 0.5f))
                                    .clickable(
                                            interactionSource = remember { MutableInteractionSource() },
                                            indication = null,
                                            enabled = !isSelectionMode
                                    ) {
                                        onCancelImageUpload(attachment.id)
                                    },
                            contentAlignment = Alignment.Center
                    ) {
                        CircularProgressIndicator(
                                progress = cappedProgress,
                                modifier = Modifier.size(36.dp),
                                color = Color.White,
                                strokeWidth = 2.5.dp,
                                trackColor = Color.White.copy(alpha = 0.25f),
                                strokeCap = StrokeCap.Round
                        )
                        Icon(
                                imageVector = Icons.Default.Close,
                                contentDescription = "Cancel upload",
                                tint = Color.White,
                                modifier = Modifier.size(14.dp)
                        )
                    }
                } else {
                    Row(
                            modifier =
                                    Modifier.clip(RoundedCornerShape(8.dp))
                                            .background(Color.Black.copy(alpha = 0.5f))
                                            .clickable(
                                                    interactionSource = remember { MutableInteractionSource() },
                                                    indication = null,
                                                    enabled = !isSelectionMode
                                            ) {
                                                onCancelImageUpload(attachment.id)
                                            }
                                            .padding(horizontal = 8.dp, vertical = 6.dp),
                            verticalAlignment = Alignment.CenterVertically
                    ) {
                        CircularProgressIndicator(
                                modifier = Modifier.size(14.dp),
                                color = Color.White,
                                strokeWidth = 2.dp
                        )
                        Spacer(modifier = Modifier.width(6.dp))
                        AnimatedDotsText(
                                baseText = "Encrypting",
                                color = Color.White,
                                fontSize = 12.sp,
                                fontWeight = FontWeight.Medium
                        )
                    }
                }
            }
        }

        // Оверлей для статуса скачивания
        if (downloadStatus != DownloadStatus.DOWNLOADED) {
            Box(
                    modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)),
                    contentAlignment = Alignment.Center
            ) {
                when (downloadStatus) {
                    DownloadStatus.NOT_DOWNLOADED -> {
                        // Кнопка скачивания
                        Box(
                                modifier =
                                        Modifier.size(48.dp)
                                                .clip(CircleShape)
                                                .background(Color.Black.copy(alpha = 0.5f)),
                                contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                    Icons.Default.ArrowDownward,
                                    contentDescription = "Download",
                                    tint = Color.White,
                                    modifier = Modifier.size(24.dp)
                            )
                        }
                    }
                    DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
                        // Индикатор загрузки с прогрессом
                        Box(
                                modifier =
                                        Modifier.size(48.dp)
                                                .clip(CircleShape)
                                                .background(Color.Black.copy(alpha = 0.5f)),
                                contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator(
                                    modifier = Modifier.size(36.dp),
                                    color = Color.White,
                                    strokeWidth = 3.dp
                            )
                        }
                    }
                    DownloadStatus.ERROR -> {
                        // Ошибка с кнопкой повтора
                        Column(horizontalAlignment = Alignment.CenterHorizontally) {
                            Box(
                                    modifier =
                                            Modifier.size(48.dp)
                                                    .clip(CircleShape)
                                                    .background(
                                                            Color(0xFFE53935).copy(alpha = 0.8f)
                                                    ),
                                    contentAlignment = Alignment.Center
                            ) {
                                Icon(
                                        Icons.Default.Refresh,
                                        contentDescription = "Retry",
                                        tint = Color.White,
                                        modifier = Modifier.size(24.dp)
                                )
                            }
                            Spacer(modifier = Modifier.height(8.dp))
                            Text(errorLabel, fontSize = 12.sp, color = Color.White)
                        }
                    }
                    else -> {}
                }
            }
        }
    }
}

private data class DesktopCallUi(
    val title: String,
    val subtitle: String,
    val isError: Boolean
)

private fun parseCallDurationSeconds(preview: String): Int {
    if (preview.isBlank()) return 0

    val tail = preview.substringAfterLast("::").trim()
    tail.toIntOrNull()?.let { return it.coerceAtLeast(0) }

    val durationRegex = Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
    durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
        return it.coerceAtLeast(0)
    }

    return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}

private fun parseVoicePreview(preview: String): Pair<Int, List<Float>> {
    if (preview.isBlank()) return 0 to emptyList()
    val durationPart = preview.substringBefore("::", preview).trim()
    val wavesPart = preview.substringAfter("::", "").trim()
    val duration = durationPart.toIntOrNull()?.coerceAtLeast(0) ?: 0
    val waves =
        if (wavesPart.isBlank()) {
            emptyList()
        } else {
            wavesPart.split(",")
                .mapNotNull { it.trim().toFloatOrNull() }
                .map { it.coerceIn(0f, 1f) }
    }
    return duration to waves
}

private data class VideoCirclePreviewMeta(
    val durationSec: Int,
    val mimeType: String
)

private fun parseVideoCirclePreview(preview: String): VideoCirclePreviewMeta {
    if (preview.isBlank()) {
        return VideoCirclePreviewMeta(durationSec = 1, mimeType = "video/mp4")
    }
    val durationPart = preview.substringBefore("::", preview).trim()
    val mimePart = preview.substringAfter("::", "").trim()
    val duration = durationPart.toIntOrNull()?.coerceAtLeast(1) ?: 1
    val mime =
        if (mimePart.contains("/")) {
            mimePart
        } else {
            "video/mp4"
        }
    return VideoCirclePreviewMeta(durationSec = duration, mimeType = mime)
}

private fun decodeVideoCirclePayload(data: String): ByteArray? {
    return decodeHexPayload(data) ?: decodeBase64Payload(data)
}

private fun ensureVideoCirclePlaybackUri(
    context: android.content.Context,
    attachmentId: String,
    payload: String,
    mimeType: String,
    localUri: String = ""
): Uri? {
    if (localUri.isNotBlank()) {
        return runCatching { Uri.parse(localUri) }.getOrNull()
    }
    val bytes = decodeVideoCirclePayload(payload) ?: return null
    val extension =
        MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.ifBlank { null } ?: "mp4"
    val directory = File(context.cacheDir, "video_circles").apply { mkdirs() }
    val file = File(directory, "$attachmentId.$extension")
    runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
    return Uri.fromFile(file)
}

private fun normalizeVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
    if (targetLength <= 0) return emptyList()
    if (source.isEmpty()) return List(targetLength) { 0f }
    if (source.size == targetLength) return source
    if (source.size > targetLength) {
        val bucket = source.size.toFloat() / targetLength.toFloat()
        return List(targetLength) { idx ->
            val start = kotlin.math.floor(idx * bucket).toInt()
            val end = kotlin.math.max(start + 1, kotlin.math.floor((idx + 1) * bucket).toInt())
            var maxValue = 0f
            var i = start
            while (i < end && i < source.size) {
                if (source[i] > maxValue) maxValue = source[i]
                i++
            }
            maxValue
        }
    }
    if (targetLength == 1) return listOf(source.first())
    val lastIndex = source.lastIndex.toFloat()
    return List(targetLength) { idx ->
        val pos = idx * lastIndex / (targetLength - 1).toFloat()
        val left = kotlin.math.floor(pos).toInt()
        val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
        if (left == right) source[left] else {
            val t = pos - left.toFloat()
            source[left] * (1f - t) + source[right] * t
        }
    }
}

private fun formatVoiceDuration(seconds: Int): String {
    val safe = seconds.coerceAtLeast(0)
    val minutes = (safe / 60).toString().padStart(2, '0')
    val rem = (safe % 60).toString().padStart(2, '0')
    return "$minutes:$rem"
}

private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
    val normalized = kotlin.math.round(speed * 10f) / 10f
    return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
        "${normalized.toInt()}x"
    } else {
        "${normalized}x"
    }
}

private const val VOICE_WAVEFORM_BAR_COUNT = 56

@Composable
private fun VoicePlaybackButtonBlob(
    level: Float,
    isOutgoing: Boolean,
    isDarkTheme: Boolean,
    modifier: Modifier = Modifier
) {
    val clampedLevel = level.coerceIn(0f, 1f)
    val animatedLevel by animateFloatAsState(
        targetValue = clampedLevel,
        animationSpec = tween(durationMillis = 140),
        label = "voice_blob_level"
    )
    val transition = rememberInfiniteTransition(label = "voice_blob_motion")
    val pulse by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "voice_blob_pulse"
    )

    val blobColor =
        if (isOutgoing) {
            Color.White
        } else if (isDarkTheme) {
            Color(0xFF5DB8FF)
        } else {
            PrimaryBlue
        }

    Canvas(modifier = modifier) {
        val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
        val buttonRadius = 20.dp.toPx() // Play button is 40dp.
        val amp = animatedLevel.coerceIn(0f, 1f)

        // Telegram-like: soft concentric glow, centered, no geometry distortion.
        val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
        val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
        val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()

        drawCircle(
            color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
            radius = r1,
            center = center
        )
        drawCircle(
            color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
            radius = r2,
            center = center
        )
        drawCircle(
            color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
            radius = r3,
            center = center
        )
    }
}

private fun formatDesktopCallDuration(durationSec: Int): String {
    val minutes = durationSec / 60
    val seconds = durationSec % 60
    return "$minutes:${seconds.toString().padStart(2, '0')}"
}

private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopCallUi {
    val durationSec = parseCallDurationSeconds(preview)
    val isError = durationSec == 0
    val title =
        if (isError) {
            if (isOutgoing) "Rejected call" else "Missed call"
        } else {
            if (isOutgoing) "Outgoing call" else "Incoming call"
        }
    val subtitle =
        if (isError) {
            "Call was not answered or was rejected"
        } else {
            formatDesktopCallDuration(durationSec)
        }
    return DesktopCallUi(title = title, subtitle = subtitle, isError = isError)
}

/** Call attachment bubble */
@Composable
fun CallAttachment(
        attachment: MessageAttachment,
        isOutgoing: Boolean,
        isDarkTheme: Boolean
) {
    val callUi = remember(attachment.preview, isOutgoing) {
        resolveDesktopCallUi(attachment.preview, isOutgoing)
    }
    val containerShape = RoundedCornerShape(17.dp)
    val containerBackground =
        if (isOutgoing) {
            PrimaryBlue
        } else {
            if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
        }
    val containerBorder = Color.Transparent
    val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
    val iconVector =
        when {
            callUi.isError -> Icons.Default.Close
            isOutgoing -> Icons.Default.CallMade
            else -> Icons.Default.CallReceived
        }

    Box(
        modifier =
            Modifier
                .padding(vertical = 4.dp)
                .widthIn(min = 200.dp)
                .heightIn(min = 60.dp)
                .clip(containerShape)
                .background(containerBackground)
                .border(width = 1.dp, color = containerBorder, shape = containerShape)
                .padding(horizontal = 10.dp, vertical = 8.dp)
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                    modifier =
                            Modifier.size(40.dp)
                                    .clip(CircleShape)
                                    .background(iconBackground),
                    contentAlignment = Alignment.Center
            ) {
                Icon(
                        imageVector = iconVector,
                        contentDescription = null,
                        tint = Color.White,
                        modifier = Modifier.size(20.dp)
                )
            }

            Spacer(modifier = Modifier.width(10.dp))

            Column(modifier = Modifier.weight(1f)) {
                Text(
                        text = callUi.title,
                        fontSize = 14.sp,
                        fontWeight = FontWeight.Medium,
                        color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color.Black,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                )
                Spacer(modifier = Modifier.height(2.dp))
                Text(
                    text = callUi.subtitle,
                    fontSize = 12.sp,
                    color =
                        if (callUi.isError && isOutgoing) {
                            Color.White.copy(alpha = 0.72f)
                        } else if (callUi.isError) {
                            Color(0xFFE55A5A)
                        } else if (isOutgoing) {
                            Color.White.copy(alpha = 0.72f)
                        } else {
                            if (isDarkTheme) Color(0xFF8EC9FF) else PrimaryBlue
                        },
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
            }

        }    
    }
}

private fun ensureVoiceAudioFile(
        context: android.content.Context,
        attachmentId: String,
        payload: String
): File? {
    val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
    val file = File(directory, "$attachmentId.webm")
    if (file.exists() && file.length() > 0L) return file
    val bytes = decodeVoicePayload(payload) ?: return null
    runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
    return file
}

@Composable
private fun VoiceAttachment(
        attachment: MessageAttachment,
        chachaKey: String,
        chachaKeyPlainHex: String,
        privateKey: String,
        senderPublicKey: String,
        senderDisplayName: String,
        dialogPublicKey: String,
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ,
        attachmentOrderInMessage: Int = 0,
        onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
    val isActiveTrack = activeAttachmentId == attachment.id
    val playbackPositionMs by
            (if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0))
                    .collectAsState(initial = 0)
    val playbackDurationMs by
            (if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0))
                    .collectAsState(initial = 0)
    val playbackIsPlaying by
            (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
                    .collectAsState(initial = false)
    val playbackSpeed by
            (if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
                    .collectAsState(initial = 1f)
    val isPlaying = isActiveTrack && playbackIsPlaying

    val (previewDurationSecRaw, previewWavesRaw) =
            remember(attachment.preview) { parseVoicePreview(attachment.preview) }
    val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
    val previewWaves =
            remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, VOICE_WAVEFORM_BAR_COUNT) }
    val waves =
            remember(previewWaves) {
                if (previewWaves.isEmpty()) List(VOICE_WAVEFORM_BAR_COUNT) { 0f } else previewWaves
            }

    var payload by
            remember(attachment.id, attachment.blob) {
                mutableStateOf(attachment.blob)
            }
    val cachedAudioPath =
            remember(attachment.id) {
                val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm")
                file.takeIf { it.exists() && it.length() > 0L }?.absolutePath
            }
    var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) }
    var downloadStatus by
            remember(attachment.id, attachment.blob, attachment.transportTag) {
                mutableStateOf(
                        when {
                            cachedAudioPath != null -> DownloadStatus.DOWNLOADED
                            attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
                            attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
                            else -> DownloadStatus.ERROR
                        }
                )
            }
    var errorText by remember { mutableStateOf("") }

    val effectiveDurationSec =
            remember(isPlaying, playbackDurationMs, previewDurationSec) {
                val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
                if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
            }
    val progress =
            if (isActiveTrack && playbackDurationMs > 0) {
                (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
            } else {
                0f
            }
    var isScrubbing by remember(attachment.id) { mutableStateOf(false) }
    var scrubProgress by remember(attachment.id) { mutableFloatStateOf(0f) }
    var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) }
    var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) }
    var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) }
    val progressAnimator = remember(attachment.id) { Animatable(0f) }
    LaunchedEffect(attachment.id, isActiveTrack) {
        if (!isActiveTrack) {
            progressAnimator.snapTo(0f)
        }
    }
    LaunchedEffect(progress, isPlaying, isScrubbing, isActiveTrack) {
        if (!isActiveTrack || isScrubbing) return@LaunchedEffect

        val target = progress.coerceIn(0f, 1f)
        val delta = kotlin.math.abs(target - progressAnimator.value)

        // Для seek/резких скачков сразу фиксируем позицию без "пробега" от старого значения.
        if (delta >= 0.12f) {
            progressAnimator.snapTo(target)
        } else {
            progressAnimator.animateTo(
                    targetValue = target,
                    animationSpec =
                            if (isPlaying) {
                                tween(durationMillis = 120, easing = LinearEasing)
                            } else {
                                tween(durationMillis = 150, easing = FastOutSlowInEasing)
                            }
            )
        }
    }
    val smoothProgress = progressAnimator.value
    val displayProgress = if (isScrubbing) scrubProgress else smoothProgress
    val liveWaveLevel =
            remember(isPlaying, progress, waves) {
                if (!isPlaying || waves.isEmpty()) {
                    0f
                } else {
                    val maxIndex = waves.lastIndex.coerceAtLeast(0)
                    val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
                    waves[sampleIndex].coerceIn(0f, 1f)
                }
            }
    val trackDurationMsForUi =
            if (isActiveTrack && playbackDurationMs > 0) playbackDurationMs
            else (effectiveDurationSec * 1000)
    val displayPositionMs =
            if (isScrubbing && trackDurationMsForUi > 0) {
                (trackDurationMsForUi.toFloat() * scrubProgress.coerceIn(0f, 1f)).toInt()
            } else {
                playbackPositionMs.coerceAtLeast(0)
            }
    val timeText =
            if (isActiveTrack && playbackDurationMs > 0) {
                val leftSec = ((playbackDurationMs - displayPositionMs).coerceAtLeast(0) / 1000)
                formatVoiceDuration(leftSec)
            } else {
                formatVoiceDuration(effectiveDurationSec)
            }
    val playbackSenderLabel =
            remember(isOutgoing, senderDisplayName) {
                val senderName = senderDisplayName.trim()
                when {
                    isOutgoing -> "You"
                    senderName.isNotBlank() -> senderName
                    else -> "Voice"
                }
            }
    val playbackTimeLabel =
            remember(timestamp.time) {
                runCatching {
                            SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
                        }
                        .getOrDefault("")
            }
    val initialPayload = remember(attachment.id, attachment.blob) { attachment.blob }
    val normalizedDialogKey = remember(dialogPublicKey) { dialogPublicKey.trim() }

    LaunchedEffect(
        attachment.id,
        normalizedDialogKey,
        timestamp.time,
        attachmentOrderInMessage,
        playbackSenderLabel,
        playbackTimeLabel,
        audioFilePath,
        attachment.transportTag,
        attachment.transportServer,
        chachaKey,
        chachaKeyPlainHex,
        privateKey
    ) {
        VoicePlaybackCoordinator.upsertQueueEntry(
            context = context,
            entry = VoiceQueueEntry(
                attachmentId = attachment.id,
                dialogKey = normalizedDialogKey,
                timestampMs = timestamp.time,
                orderInMessage = attachmentOrderInMessage,
                senderLabel = playbackSenderLabel,
                playedAtLabel = playbackTimeLabel,
                audioFilePath = audioFilePath,
                payload = payload.ifBlank { initialPayload },
                downloadTag = attachment.transportTag,
                transportServer = attachment.transportServer,
                chachaKey = chachaKey,
                chachaKeyPlainHex = chachaKeyPlainHex,
                privateKey = privateKey
            )
        )
    }

    DisposableEffect(attachment.id) {
        onDispose {
            onVoiceWaveGestureActiveChanged(false)
            VoicePlaybackCoordinator.removeQueueEntry(attachment.id)
        }
    }

    LaunchedEffect(attachment.id, downloadStatus, isActiveTrack, isPlaying) {
        if (VOICE_WAVE_DEBUG_LOG) {
            rosettaDev1AttachmentLog(
                    context = context,
                    tag = "VoiceWave",
                    message =
                            "INIT att=${attachment.id.take(8)} status=$downloadStatus " +
                                    "active=$isActiveTrack playing=$isPlaying hasFile=${audioFilePath?.isNotBlank() == true}"
            )
        }
    }

    val triggerDownload: () -> Unit = download@{
        if (attachment.transportTag.isBlank()) {
            downloadStatus = DownloadStatus.ERROR
            errorText = "Voice not available"
            return@download
        }
        scope.launch {
            downloadStatus = DownloadStatus.DOWNLOADING
            errorText = ""
            val decrypted =
                    downloadAndDecryptVoicePayload(
                            attachmentId = attachment.id,
                            downloadTag = attachment.transportTag,
                            chachaKey = chachaKey,
                            privateKey = privateKey,
                            transportServer = attachment.transportServer,
                            chachaKeyPlainHex = chachaKeyPlainHex
                    )
            if (decrypted.isNullOrBlank()) {
                downloadStatus = DownloadStatus.ERROR
                errorText = "Failed to decrypt"
                return@launch
            }
            downloadStatus = DownloadStatus.DECRYPTING
            val prepared =
                    withContext(Dispatchers.IO) {
                        ensureVoiceAudioFile(context, attachment.id, decrypted)
                    }
            if (prepared == null) {
                downloadStatus = DownloadStatus.ERROR
                errorText = "Cannot decode voice"
                return@launch
            }
            audioFilePath = prepared.absolutePath
            val saved =
                    withContext(Dispatchers.IO) {
                        runCatching {
                                    AttachmentFileManager.saveAttachment(
                                            context = context,
                                            blob = decrypted,
                                            attachmentId = attachment.id,
                                            publicKey = senderPublicKey,
                                            privateKey = privateKey
                                    )
                                }
                                .getOrDefault(false)
                    }
            payload = decrypted
            if (!saved) {
                // Не блокируем UI, но оставляем маркер в логе.
                runCatching { android.util.Log.w(TAG, "Voice cache save failed: ${attachment.id}") }
            }
            downloadStatus = DownloadStatus.DOWNLOADED
        }
    }

    val performMainAction: (Boolean) -> Unit = mainAction@{ ignoreWaveformTouchLock ->
        val now = SystemClock.elapsedRealtime()
        if (!ignoreWaveformTouchLock && (isWaveformTouchLocked || now < suppressMainActionUntilMs)) {
            return@mainAction
        }
        when (downloadStatus) {
            DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
            DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
            DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
                val file = audioFilePath?.let { File(it) }
                if (file == null || !file.exists()) {
                    if (payload.isNotBlank()) {
                        scope.launch {
                            downloadStatus = DownloadStatus.DECRYPTING
                            errorText = ""
                            val prepared =
                                    withContext(Dispatchers.IO) {
                                        ensureVoiceAudioFile(context, attachment.id, payload)
                                    }
                            if (prepared != null) {
                                audioFilePath = prepared.absolutePath
                                downloadStatus = DownloadStatus.DOWNLOADED
                                VoicePlaybackCoordinator.toggle(
                                        attachmentId = attachment.id,
                                        sourceFile = prepared,
                                        dialogKey = dialogPublicKey,
                                        senderLabel = playbackSenderLabel,
                                        playedAtLabel = playbackTimeLabel
                                ) { message ->
                                    downloadStatus = DownloadStatus.ERROR
                                    errorText = message
                                }
                            } else {
                                downloadStatus = DownloadStatus.ERROR
                                errorText = "Cannot decode voice"
                            }
                        }
                    } else {
                        triggerDownload()
                    }
                } else {
                    VoicePlaybackCoordinator.toggle(
                            attachmentId = attachment.id,
                            sourceFile = file,
                            dialogKey = dialogPublicKey,
                            senderLabel = playbackSenderLabel,
                            playedAtLabel = playbackTimeLabel
                    ) { message ->
                        downloadStatus = DownloadStatus.ERROR
                        errorText = message
                    }
                }
            }
        }
    }
    val onMainAction: () -> Unit = { performMainAction(false) }

    val canHandleWaveformTouch =
            downloadStatus == DownloadStatus.DOWNLOADED
    fun progressFromWaveX(x: Float): Float {
        if (waveformWidthPx <= 0f) return 0f
        return (x / waveformWidthPx).coerceIn(0f, 1f)
    }
    val viewConfiguration = LocalViewConfiguration.current
    val waveformDragStartThresholdPx =
            remember(viewConfiguration.touchSlop) {
                // Slightly below touchSlop to make waveform drag feel responsive.
                viewConfiguration.touchSlop * 0.75f
            }
    val waveformTapCancelThresholdPx =
            remember(viewConfiguration.touchSlop) {
                // If finger moved at least a bit, don't treat it as tap-to-toggle.
                viewConfiguration.touchSlop * 0.35f
            }

    val barInactiveColor =
            if (isOutgoing) Color.White.copy(alpha = 0.38f)
            else if (isDarkTheme) Color(0xFF5D6774)
            else Color(0xFFB6C0CC)
    val barActiveColor = if (isOutgoing) Color.White else PrimaryBlue
    val secondaryTextColor =
            if (isOutgoing) Color.White.copy(alpha = 0.72f)
            else if (isDarkTheme) Color(0xFF9EAABD)
            else Color(0xFF667283)

    val actionBackground =
            when (downloadStatus) {
                DownloadStatus.ERROR -> Color(0xFFE55757)
                else ->
                        if (isOutgoing) {
                            Color.White.copy(alpha = 0.2f)
                        } else if (isDarkTheme) {
                            Color(0xFF4C5562)
                        } else {
                            Color(0xFFE3E7EC)
                        }
            }
    val actionTint =
            when {
                downloadStatus == DownloadStatus.ERROR -> Color.White
                isOutgoing -> Color.White
                else -> if (isDarkTheme) Color(0xFFF1F3F5) else Color(0xFF3B4652)
            }

    val actionButtonInteraction = remember { MutableInteractionSource() }
    Row(
            modifier =
                    Modifier.fillMaxWidth()
                            .padding(vertical = 4.dp),
            verticalAlignment = Alignment.CenterVertically
    ) {
        Box(
                modifier = Modifier.size(40.dp),
                contentAlignment = Alignment.Center
        ) {
            if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
                VoicePlaybackButtonBlob(
                        level = liveWaveLevel,
                        isOutgoing = isOutgoing,
                        isDarkTheme = isDarkTheme,
                        modifier =
                                Modifier.requiredSize(64.dp).graphicsLayer {
                                    // Blob lives strictly behind the button; keep button geometry untouched.
                                    alpha = 0.96f
                                    compositingStrategy = CompositingStrategy.Offscreen
                                }
                )
            }
            Box(
                    modifier =
                            Modifier.size(40.dp)
                                    .clip(CircleShape)
                                    .background(actionBackground)
                                    .clickable(
                                            enabled = !isWaveformTouchLocked,
                                            interactionSource = actionButtonInteraction,
                                            indication = null
                                    ) { onMainAction() },
                    contentAlignment = Alignment.Center
            ) {
            if (downloadStatus == DownloadStatus.DOWNLOADING ||
                            downloadStatus == DownloadStatus.DECRYPTING
            ) {
                CircularProgressIndicator(
                        modifier = Modifier.size(22.dp),
                        color = Color.White,
                        strokeWidth = 2.2.dp
                )
            } else {
                when (downloadStatus) {
                    DownloadStatus.NOT_DOWNLOADED -> {
                        Icon(
                                painter = painterResource(R.drawable.msg_download),
                                contentDescription = null,
                                tint = actionTint,
                                modifier = Modifier.size(20.dp)
                        )
                    }
                    DownloadStatus.ERROR -> {
                        Icon(
                                imageVector = Icons.Default.Close,
                                contentDescription = null,
                                tint = actionTint,
                                modifier = Modifier.size(20.dp)
                        )
                    }
                    else -> {
                        Icon(
                                imageVector =
                                        if (isPlaying) Icons.Outlined.Pause
                                        else Icons.Default.PlayArrow,
                                contentDescription = null,
                                tint = actionTint,
                                modifier = Modifier.size(20.dp)
                        )
                    }
                }
            }
            }
        }

        Spacer(modifier = Modifier.width(10.dp))

        Column(modifier = Modifier.weight(1f)) {
            Row(
                    modifier = Modifier.fillMaxWidth().height(28.dp),
                    horizontalArrangement = Arrangement.spacedBy(1.dp)
            ) {
                Row(
                    modifier =
                            Modifier.fillMaxWidth()
                                    .height(28.dp)
                                    .onSizeChanged { size ->
                                        waveformWidthPx = size.width.toFloat()
                                    }
                                    .pointerInput(
                                            attachment.id,
                                            canHandleWaveformTouch
                                    ) {
                                        if (!canHandleWaveformTouch) return@pointerInput
                                        awaitEachGesture {
                                            val downChange =
                                                    awaitFirstDown(
                                                            requireUnconsumed = false,
                                                            pass = PointerEventPass.Main
                                                    )
                                            val wasPlayingOnTouchDown = isPlaying
                                            var preparedForInteraction = isActiveTrack
                                            var isDraggingWaveform = false
                                            var movedEnoughForTapCancel = false
                                            val downPosition = downChange.position
                                            var lastTouchX = downPosition.x
                                            var lastWaveLogAtMs = 0L
                                            fun waveLog(message: String) {
                                                if (!VOICE_WAVE_DEBUG_LOG) return
                                                val now = SystemClock.elapsedRealtime()
                                                if (
                                                        now - lastWaveLogAtMs < 70L &&
                                                                !message.startsWith("DOWN") &&
                                                                !message.startsWith("DRAG_START") &&
                                                                !message.startsWith("DRAG_END") &&
                                                                !message.startsWith("TAP")
                                                ) {
                                                    return
                                                }
                                                lastWaveLogAtMs = now
                                                rosettaDev1AttachmentLog(
                                                        context = context,
                                                        tag = "VoiceWave",
                                                        message =
                                                                "att=${attachment.id.take(8)} active=$isActiveTrack playing=$isPlaying " +
                                                                        "paused=${!wasPlayingOnTouchDown} $message"
                                                )
                                            }
                                            fun ensurePreparedForInteraction(): Boolean {
                                                if (preparedForInteraction) return true
                                                var sourceFile: File? =
                                                        audioFilePath
                                                                ?.let { path -> File(path) }
                                                                ?.takeIf { it.exists() && it.length() > 0L }
                                                if (sourceFile == null && payload.isNotBlank()) {
                                                    sourceFile =
                                                            ensureVoiceAudioFile(
                                                                    context = context,
                                                                    attachmentId = attachment.id,
                                                                    payload = payload
                                                            )
                                                    if (sourceFile != null) {
                                                        audioFilePath = sourceFile.absolutePath
                                                    }
                                                }
                                                if (sourceFile == null) return false
                                                VoicePlaybackCoordinator.prepareForScrub(
                                                        attachmentId = attachment.id,
                                                        sourceFile = sourceFile,
                                                        dialogKey = dialogPublicKey,
                                                        senderLabel = playbackSenderLabel,
                                                        playedAtLabel = playbackTimeLabel
                                                ) { message ->
                                                    downloadStatus = DownloadStatus.ERROR
                                                    errorText = message
                                                }
                                                preparedForInteraction = true
                                                waveLog("PREPARED file=${sourceFile.name}")
                                                return true
                                            }

                                            try {
                                                onVoiceWaveGestureActiveChanged(true)
                                                isWaveformTouchLocked = true
                                                suppressMainActionUntilMs =
                                                        SystemClock.elapsedRealtime() + 700L
                                                downChange.consume()
                                                waveLog(
                                                        "DOWN x=${downChange.position.x.toInt()} y=${downChange.position.y.toInt()}"
                                                )
                                                val pointerId = downChange.id

                                                while (true) {
                                                    val event = awaitPointerEvent(PointerEventPass.Main)
                                                    val trackedChange =
                                                            event.changes.firstOrNull { it.id == pointerId } ?: break
                                                    lastTouchX = trackedChange.position.x

                                                    val dx = trackedChange.position.x - downPosition.x
                                                    val dy = trackedChange.position.y - downPosition.y
                                                    if (!movedEnoughForTapCancel &&
                                                            (kotlin.math.abs(dx) > waveformTapCancelThresholdPx ||
                                                                    kotlin.math.abs(dy) > waveformTapCancelThresholdPx)
                                                    ) {
                                                        movedEnoughForTapCancel = true
                                                    }
                                                    if (!isDraggingWaveform &&
                                                            kotlin.math.abs(dx) > waveformDragStartThresholdPx &&
                                                            trackedChange.pressed
                                                    ) {
                                                        if (!ensurePreparedForInteraction()) {
                                                            waveLog("DRAG_ABORT no-source")
                                                            trackedChange.consume()
                                                            break
                                                        }
                                                        isDraggingWaveform = true
                                                        isScrubbing = true
                                                        waveLog(
                                                                "DRAG_START dx=${dx.toInt()} threshold=${waveformDragStartThresholdPx.toInt()}"
                                                        )
                                                    }

                                                    if (isDraggingWaveform) {
                                                        val progressAtTouch =
                                                                progressFromWaveX(trackedChange.position.x)
                                                        scrubProgress = progressAtTouch
                                                        waveLog(
                                                                "DRAG_MOVE p=${(progressAtTouch * 100).toInt()}% pressed=${trackedChange.pressed}"
                                                        )
                                                    }

                                                    trackedChange.consume()
                                                    if (!trackedChange.pressed) break
                                                }

                                                if (isDraggingWaveform) {
                                                    val releaseProgress = progressFromWaveX(lastTouchX)
                                                    scrubProgress = releaseProgress
                                                    scope.launch {
                                                        progressAnimator.snapTo(releaseProgress)
                                                    }
                                                    VoicePlaybackCoordinator.seekToProgress(
                                                            releaseProgress,
                                                            keepPaused = !wasPlayingOnTouchDown
                                                    )
                                                    waveLog(
                                                            "DRAG_END seek=${(releaseProgress * 100).toInt()}%"
                                                    )
                                                } else if (movedEnoughForTapCancel) {
                                                    waveLog("TAP_SUPPRESSED moved-before-drag")
                                                } else {
                                                    waveLog("TAP_ACTION")
                                                    performMainAction(true)
                                                }
                                            } finally {
                                                isScrubbing = false
                                                isWaveformTouchLocked = false
                                                suppressMainActionUntilMs =
                                                        SystemClock.elapsedRealtime() + 280L
                                                onVoiceWaveGestureActiveChanged(false)
                                            }
                                        }
                                    },
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.spacedBy(1.dp)
                ) {
                waves.forEachIndexed { index, value ->
                    val normalized = value.coerceIn(0f, 1f)
                    val waveCount = waves.size.coerceAtLeast(1)
                    val barStart = index.toFloat() / waveCount.toFloat()
                    val barEnd = (index + 1).toFloat() / waveCount.toFloat()
                    val fill =
                            ((displayProgress - barStart) / (barEnd - barStart))
                                    .coerceIn(0f, 1f)
                    val color = lerp(barInactiveColor, barActiveColor, fill)
                    Box(
                            modifier =
                                    Modifier.weight(1f)
                                            .height((4f + normalized * 18f).dp)
                                            .clip(RoundedCornerShape(100))
                                            .background(color)
                    )
                }
                }
            }
            Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(
                        text =
                                if (downloadStatus == DownloadStatus.ERROR && errorText.isNotBlank())
                                        errorText
                                else timeText,
                        fontSize = 12.sp,
                        color =
                                if (downloadStatus == DownloadStatus.ERROR) {
                                    Color(0xFFE55757)
                                } else {
                                    secondaryTextColor
                                }
                )

                Row(
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.spacedBy(4.dp)
                ) {
                    // Speed chip — always reserve space so the bubble doesn't reflow
                    // when playback starts/stops; fixed width so cycling 1x/1.5x/2x
                    // doesn't jitter the row either.
                    val speedChipBackground =
                            if (isOutgoing) {
                                Color.White.copy(alpha = 0.2f)
                            } else if (isDarkTheme) {
                                Color(0xFF31435A)
                            } else {
                                Color(0xFFDCEBFD)
                            }
                    val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
                    Box(
                            modifier =
                                    Modifier.width(32.dp)
                                            .graphicsLayer { alpha = if (isActiveTrack) 1f else 0f }
                                            .clip(RoundedCornerShape(10.dp))
                                            .background(speedChipBackground)
                                            .then(
                                                    if (isActiveTrack) {
                                                        Modifier.clickable(
                                                                interactionSource =
                                                                        remember { MutableInteractionSource() },
                                                                indication = null
                                                        ) {
                                                            VoicePlaybackCoordinator.cycleSpeed()
                                                        }
                                                    } else Modifier
                                            )
                                            .padding(horizontal = 6.dp, vertical = 2.dp),
                            contentAlignment = Alignment.Center
                    ) {
                        Text(
                                text = formatVoicePlaybackSpeedLabel(playbackSpeed),
                                fontSize = 10.sp,
                                fontWeight = FontWeight.SemiBold,
                                color = speedChipTextColor,
                                maxLines = 1
                        )
                    }
                    Text(
                            text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
                            fontSize = 11.sp,
                            color = secondaryTextColor
                    )

                    if (isOutgoing) {
                        when (messageStatus) {
                            MessageStatus.SENDING -> {
                                Icon(
                                        painter = TelegramIcons.Clock,
                                        contentDescription = null,
                                        tint = secondaryTextColor,
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                            MessageStatus.SENT,
                            MessageStatus.DELIVERED -> {
                                Icon(
                                        painter = TelegramIcons.Done,
                                        contentDescription = null,
                                        tint = secondaryTextColor,
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                            MessageStatus.READ -> {
                                Box(modifier = Modifier.height(14.dp)) {
                                    Icon(
                                            painter = TelegramIcons.Done,
                                            contentDescription = null,
                                            tint = secondaryTextColor,
                                            modifier = Modifier.size(14.dp)
                                    )
                                    Icon(
                                            painter = TelegramIcons.Done,
                                            contentDescription = null,
                                            tint = secondaryTextColor,
                                            modifier = Modifier.size(14.dp).offset(x = 4.dp)
                                    )
                                }
                            }
                            MessageStatus.ERROR -> {
                                Icon(
                                        imageVector = Icons.Default.Error,
                                        contentDescription = null,
                                        tint = Color(0xFFE55757),
                                        modifier = Modifier.size(14.dp)
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun VideoCircleAttachment(
        attachment: MessageAttachment,
        chachaKey: String,
        chachaKeyPlainHex: String,
        privateKey: String,
        senderPublicKey: String,
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val previewMeta = remember(attachment.preview) { parseVideoCirclePreview(getPreview(attachment)) }
    val fallbackDurationMs = previewMeta.durationSec.coerceAtLeast(1) * 1000

    var payload by
            remember(attachment.id, attachment.blob) {
                mutableStateOf(attachment.blob.trim())
            }
    var downloadStatus by
            remember(attachment.id, attachment.blob, attachment.transportTag, attachment.localUri) {
                mutableStateOf(
                        when {
                            attachment.localUri.isNotBlank() -> DownloadStatus.DOWNLOADED
                            attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
                            attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
                            else -> DownloadStatus.ERROR
                        }
                )
            }
    var errorText by remember { mutableStateOf("") }
    var playbackUri by remember(attachment.id, attachment.localUri) {
        mutableStateOf(
                runCatching {
                    if (attachment.localUri.isNotBlank()) Uri.parse(attachment.localUri) else null
                }.getOrNull()
        )
    }
    var boundUri by remember(attachment.id) { mutableStateOf<String?>(null) }
    var isPrepared by remember(attachment.id) { mutableStateOf(false) }
    var isPlaying by remember(attachment.id) { mutableStateOf(false) }
    var playbackPositionMs by remember(attachment.id) { mutableIntStateOf(0) }
    var playbackDurationMs by remember(attachment.id) { mutableIntStateOf(fallbackDurationMs) }
    var videoViewRef by remember(attachment.id) { mutableStateOf<VideoView?>(null) }

    LaunchedEffect(payload, attachment.localUri, attachment.id, previewMeta.mimeType) {
        if (playbackUri != null) return@LaunchedEffect
        if (attachment.localUri.isNotBlank()) {
            playbackUri = runCatching { Uri.parse(attachment.localUri) }.getOrNull()
            return@LaunchedEffect
        }
        if (payload.isBlank()) return@LaunchedEffect
        val prepared =
                ensureVideoCirclePlaybackUri(
                        context = context,
                        attachmentId = attachment.id,
                        payload = payload,
                        mimeType = previewMeta.mimeType
                )
        if (prepared != null) {
            playbackUri = prepared
            if (downloadStatus != DownloadStatus.DOWNLOADING &&
                            downloadStatus != DownloadStatus.DECRYPTING
            ) {
                downloadStatus = DownloadStatus.DOWNLOADED
            }
            if (errorText.isNotBlank()) errorText = ""
        } else {
            downloadStatus = DownloadStatus.ERROR
            if (errorText.isBlank()) errorText = "Cannot decode video"
        }
    }

    LaunchedEffect(isPlaying, videoViewRef) {
        val player = videoViewRef ?: return@LaunchedEffect
        while (isPlaying) {
            playbackPositionMs = runCatching { player.currentPosition }.getOrDefault(0).coerceAtLeast(0)
            delay(120)
        }
    }

    DisposableEffect(attachment.id) {
        onDispose {
            runCatching {
                videoViewRef?.stopPlayback()
            }
            videoViewRef = null
            isPlaying = false
            isPrepared = false
            boundUri = null
        }
    }

    val triggerDownload: () -> Unit = download@{
        if (attachment.transportTag.isBlank()) {
            downloadStatus = DownloadStatus.ERROR
            errorText = "Video is not available"
            return@download
        }
        scope.launch {
            downloadStatus = DownloadStatus.DOWNLOADING
            errorText = ""
            val decrypted =
                    downloadAndDecryptVoicePayload(
                            attachmentId = attachment.id,
                            downloadTag = attachment.transportTag,
                            chachaKey = chachaKey,
                            privateKey = privateKey,
                            transportServer = attachment.transportServer,
                            chachaKeyPlainHex = chachaKeyPlainHex
                    )
            if (decrypted.isNullOrBlank()) {
                downloadStatus = DownloadStatus.ERROR
                errorText = "Failed to decrypt"
                return@launch
            }
            val saved =
                    runCatching {
                                AttachmentFileManager.saveAttachment(
                                        context = context,
                                        blob = decrypted,
                                        attachmentId = attachment.id,
                                        publicKey = senderPublicKey,
                                        privateKey = privateKey
                                )
                            }
                            .getOrDefault(false)
            payload = decrypted
            playbackUri =
                    ensureVideoCirclePlaybackUri(
                            context = context,
                            attachmentId = attachment.id,
                            payload = decrypted,
                            mimeType = previewMeta.mimeType
                    )
            if (!saved) {
                runCatching { android.util.Log.w(TAG, "Video circle cache save failed: ${attachment.id}") }
            }
            if (playbackUri == null) {
                downloadStatus = DownloadStatus.ERROR
                errorText = "Cannot decode video"
            } else {
                downloadStatus = DownloadStatus.DOWNLOADED
            }
        }
    }

    val onMainAction: () -> Unit = {
        when (downloadStatus) {
            DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
            DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
            DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
                if (playbackUri == null) {
                    triggerDownload()
                } else {
                    isPlaying = !isPlaying
                }
            }
        }
    }

    val durationToShowSec =
            if (isPrepared && playbackDurationMs > 0) {
                (playbackDurationMs / 1000).coerceAtLeast(1)
            } else {
                previewMeta.durationSec.coerceAtLeast(1)
            }
    val secondaryTextColor =
            if (isOutgoing) Color.White.copy(alpha = 0.82f)
            else if (isDarkTheme) Color(0xFFCCD3E0)
            else Color(0xFF5F6D82)

    Box(
            modifier =
                    Modifier.padding(vertical = 4.dp)
                            .size(220.dp)
                            .clip(CircleShape)
                            .background(
                                    if (isOutgoing) {
                                        Color(0xFF3A9DFB)
                                    } else if (isDarkTheme) {
                                        Color(0xFF22252B)
                                    } else {
                                        Color(0xFFE8EEF7)
                                    }
                            )
                            .clickable(
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = null
                            ) { onMainAction() }
    ) {
        val uri = playbackUri
        if (uri != null && (downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.PENDING)) {
            AndroidView(
                    factory = { ctx -> VideoView(ctx) },
                    modifier = Modifier.fillMaxSize(),
                    update = { videoView ->
                        videoViewRef = videoView
                        val targetUri = uri.toString()
                        if (boundUri != targetUri) {
                            boundUri = targetUri
                            isPrepared = false
                            playbackPositionMs = 0
                            runCatching {
                                videoView.setVideoURI(uri)
                                videoView.setOnPreparedListener { mediaPlayer ->
                                    mediaPlayer.isLooping = false
                                    playbackDurationMs =
                                            mediaPlayer.duration
                                                    .coerceAtLeast(fallbackDurationMs)
                                    isPrepared = true
                                    if (isPlaying) {
                                        runCatching { videoView.start() }
                                    }
                                }
                                videoView.setOnCompletionListener {
                                    isPlaying = false
                                    playbackPositionMs = playbackDurationMs
                                }
                            }
                        }
                        if (isPrepared) {
                            if (isPlaying && !videoView.isPlaying) {
                                runCatching { videoView.start() }
                            } else if (!isPlaying && videoView.isPlaying) {
                                runCatching { videoView.pause() }
                            }
                        }
                    }
            )
        } else {
            Box(
                    modifier =
                            Modifier.fillMaxSize()
                                    .background(
                                            Brush.radialGradient(
                                                    colors =
                                                            if (isOutgoing) {
                                                                listOf(
                                                                        Color(0x6637A7FF),
                                                                        Color(0x3337A7FF),
                                                                        Color(0x0037A7FF)
                                                                )
                                                            } else if (isDarkTheme) {
                                                                listOf(
                                                                        Color(0x553A4150),
                                                                        Color(0x33262C39),
                                                                        Color(0x00262C39)
                                                                )
                                                            } else {
                                                                listOf(
                                                                        Color(0x5593B4E8),
                                                                        Color(0x338AB0E5),
                                                                        Color(0x008AB0E5)
                                                                )
                                                            }
                                            )
                                    )
            )
        }

        Box(
                modifier =
                        Modifier.align(Alignment.Center)
                                .size(52.dp)
                                .clip(CircleShape)
                                .background(Color.Black.copy(alpha = 0.38f)),
                contentAlignment = Alignment.Center
        ) {
            when (downloadStatus) {
                DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
                    CircularProgressIndicator(
                            modifier = Modifier.size(26.dp),
                            color = Color.White,
                            strokeWidth = 2.2.dp
                    )
                }
                DownloadStatus.NOT_DOWNLOADED -> {
                    Icon(
                            painter = painterResource(R.drawable.msg_download),
                            contentDescription = null,
                            tint = Color.White,
                            modifier = Modifier.size(24.dp)
                    )
                }
                DownloadStatus.ERROR -> {
                    Icon(
                            imageVector = Icons.Default.Close,
                            contentDescription = null,
                            tint = Color(0xFFFF8A8A),
                            modifier = Modifier.size(24.dp)
                    )
                }
                else -> {
                    Icon(
                            imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
                            contentDescription = null,
                            tint = Color.White,
                            modifier = Modifier.size(28.dp)
                    )
                }
            }
        }

        Row(
                modifier =
                        Modifier.align(Alignment.BottomEnd)
                                .padding(end = 10.dp, bottom = 8.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            Text(
                    text = formatVoiceDuration(durationToShowSec),
                    fontSize = 11.sp,
                    color = secondaryTextColor
            )
            if (isOutgoing) {
                when (messageStatus) {
                    MessageStatus.SENDING -> {
                        Icon(
                                painter = TelegramIcons.Clock,
                                contentDescription = null,
                                tint = secondaryTextColor,
                                modifier = Modifier.size(14.dp)
                        )
                    }
                    MessageStatus.SENT,
                    MessageStatus.DELIVERED -> {
                        Icon(
                                painter = TelegramIcons.Done,
                                contentDescription = null,
                                tint = secondaryTextColor,
                                modifier = Modifier.size(14.dp)
                        )
                    }
                    MessageStatus.READ -> {
                        Box(modifier = Modifier.height(14.dp)) {
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = null,
                                    tint = secondaryTextColor,
                                    modifier = Modifier.size(14.dp)
                            )
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = null,
                                    tint = secondaryTextColor,
                                    modifier = Modifier.size(14.dp).offset(x = 4.dp)
                            )
                        }
                    }
                    MessageStatus.ERROR -> {
                        Icon(
                                imageVector = Icons.Default.Error,
                                contentDescription = null,
                                tint = Color(0xFFE55757),
                                modifier = Modifier.size(14.dp)
                        )
                    }
                }
            }
        }
    }
}

/** File attachment - Telegram style */
@Composable
fun FileAttachment(
        attachment: MessageAttachment,
        chachaKey: String,
        privateKey: String,
        currentUserPublicKey: String = "",
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date,
        messageStatus: MessageStatus = MessageStatus.READ
) {
    val context = LocalContext.current
    var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
    var downloadProgress by remember { mutableStateOf(0f) }
    var isPaused by remember { mutableStateOf(false) }
    var lastActionAtMs by remember { mutableLongStateOf(0L) }

    // Bounce animation for icon
    val iconScale = remember { Animatable(0f) }
    LaunchedEffect(Unit) {
        iconScale.animateTo(
            targetValue = 1f,
            animationSpec = spring(
                dampingRatio = 0.5f, // Bouncy effect
                stiffness = 400f
            )
        )
    }

    val preview = getPreview(attachment)
    val downloadTag = getDownloadTag(attachment)
    val (fileSize, fileName) = parseFilePreview(preview)

    // Анимация прогресса
    val animatedProgress by
            animateFloatAsState(
                    targetValue = downloadProgress,
                    animationSpec = tween(durationMillis = 300),
                    label = "progress"
            )

    // Путь к скачанному файлу (как Desktop: ~/Rosetta Downloads/filename)
    val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
    val savedFile = remember(fileName) { File(downloadsDir, fileName) }

    // 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
    val managerState by com.rosetta.messenger.network.FileDownloadManager
        .progressOf(attachment.id)
        .collectAsState(initial = null)

    // Синхронизируем локальный UI с глобальным менеджером
    LaunchedEffect(managerState) {
        val state = managerState ?: return@LaunchedEffect
        downloadProgress = state.progress
        downloadStatus = when (state.status) {
            com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
            com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
            com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
            com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
            com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
            com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
        }
        isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
    }

    LaunchedEffect(attachment.id) {
        val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
        if (existingState != null) {
            downloadProgress = existingState.progress
            downloadStatus = when (existingState.status) {
                com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
                com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
                    DownloadStatus.DOWNLOADING
                com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
                    DownloadStatus.DOWNLOADING
                com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
                    DownloadStatus.DECRYPTING
                com.rosetta.messenger.network.FileDownloadStatus.DONE ->
                    DownloadStatus.DOWNLOADED
                com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
                    DownloadStatus.ERROR
            }
            isPaused =
                existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
            return@LaunchedEffect
        }

        // Если менеджер уже качает этот файл — подхватим состояние оттуда
        if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
            downloadStatus = DownloadStatus.DOWNLOADING
            isPaused = false
            return@LaunchedEffect
        }
        downloadStatus = if (downloadTag.isNotBlank()) {
            // Проверяем, был ли файл уже скачан ранее
            if (savedFile.exists()) DownloadStatus.DOWNLOADED
            else DownloadStatus.NOT_DOWNLOADED
        } else {
            // Для отправленных файлов без тега — проверяем локальный файл
            if (savedFile.exists()) DownloadStatus.DOWNLOADED
            else DownloadStatus.DOWNLOADED // blob есть в памяти
        }
        isPaused = false
    }

    // Открыть файл через системное приложение
    val openFile: () -> Unit = openFile@{
        try {
            if (!savedFile.exists()) {
                // Файл ещё не скачан — переключаем статус чтобы UI показал кнопку загрузки
                if (downloadTag.isNotEmpty()) {
                    downloadStatus = DownloadStatus.NOT_DOWNLOADED
                } else {
                    android.widget.Toast.makeText(context, "File not available", android.widget.Toast.LENGTH_SHORT).show()
                }
                return@openFile
            }
            val uri = FileProvider.getUriForFile(
                context,
                "${context.packageName}.provider",
                savedFile
            )
            val ext = fileName.substringAfterLast('.', "").lowercase()
            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
                ?: "application/octet-stream"
            val intent = Intent(Intent.ACTION_VIEW).apply {
                setDataAndType(uri, mimeType)
                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            }
            context.startActivity(intent)
        } catch (e: Exception) {
            android.widget.Toast.makeText(context, "Cannot open file: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
        }
    }

    // 📥 Запуск скачивания через глобальный FileDownloadManager
    val download: () -> Unit = {
        if (downloadTag.isNotEmpty()) {
            isPaused = false
            com.rosetta.messenger.network.FileDownloadManager.download(
                attachmentId = attachment.id,
                downloadTag = downloadTag,
                transportServer = attachment.transportServer,
                chachaKey = chachaKey,
                privateKey = privateKey,
                accountPublicKey = currentUserPublicKey,
                fileName = fileName,
                savedFile = savedFile
            )
        }
    }

    val pauseDownload: () -> Unit = {
        com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
        isPaused = true
    }

    val resumeDownload: () -> Unit = {
        isPaused = false
        com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
    }

    val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
    val isDownloadInProgress =
        !isPaused &&
            (downloadStatus == DownloadStatus.DOWNLOADING ||
                downloadStatus == DownloadStatus.DECRYPTING)
    val actionState = when {
        downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
        isSendingUpload -> TelegramFileActionState.CANCEL
        isDownloadInProgress -> TelegramFileActionState.PAUSE
        isPaused -> TelegramFileActionState.DOWNLOAD
        downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
        else -> TelegramFileActionState.FILE
    }
    val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null

    // Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
    Box(modifier = Modifier.fillMaxWidth()) {
        Row(
                modifier =
                        Modifier.fillMaxWidth()
                                .clickable(
                                        interactionSource = remember { MutableInteractionSource() },
                                        indication = null,
                                        enabled =
                                                !isSendingUpload &&
                                                        (downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
                                                                downloadStatus == DownloadStatus.DOWNLOADED ||
                                                                downloadStatus == DownloadStatus.ERROR ||
                                                                isDownloadInProgress ||
                                                                isPaused)
                                ) {
                                    val now = SystemClock.elapsedRealtime()
                                    if (now - lastActionAtMs < 220L) return@clickable
                                    lastActionAtMs = now

                                    when {
                                        isPaused -> resumeDownload()
                                        downloadStatus == DownloadStatus.DOWNLOADING ||
                                                downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
                                        downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
                                        else -> download()
                                    }
                                }
                                .padding(vertical = 4.dp),
                verticalAlignment = Alignment.CenterVertically
        ) {
            // File icon с индикатором прогресса - круглая иконка как в desktop
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .graphicsLayer {
                        scaleX = iconScale.value
                        scaleY = iconScale.value
                    },
                contentAlignment = Alignment.Center
            ) {
                TelegramFileActionButton(
                    state = actionState,
                    progress = actionProgress,
                    indeterminate = isSendingUpload,
                    modifier = Modifier.fillMaxSize()
                )
            }

            Spacer(modifier = Modifier.width(10.dp))

            // File info
            Column(modifier = Modifier.weight(1f)) {
                Text(
                        text = fileName,
                        fontSize = 14.sp,
                        fontWeight = FontWeight.Normal,
                        color =
                                if (isOutgoing) Color.White
                                else (if (isDarkTheme) Color.White else Color.Black),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                )
                Spacer(modifier = Modifier.height(2.dp))

                // Размер файла и тип
                val fileExtension = fileName.substringAfterLast('.', "").uppercase()
                val statusColor = if (downloadStatus == DownloadStatus.ERROR) {
                    Color(0xFFE53935)
                } else if (isOutgoing) {
                    Color.White.copy(alpha = 0.7f)
                } else {
                    PrimaryBlue
                }
                
                if (isSendingUpload) {
                    AnimatedDotsText(
                        baseText = "Uploading",
                        color = statusColor,
                        fontSize = 12.sp
                    )
                } else {
                    when (downloadStatus) {
                        DownloadStatus.DOWNLOADING -> {
                            if (isPaused) {
                                val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
                                val downloadedBytes = (cdnFraction * fileSize).toLong()
                                Text(
                                    text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
                                    fontSize = 12.sp,
                                    color = statusColor
                                )
                            } else {
                                // Telegram-style: "1.2 MB / 5.4 MB"
                                // CDN download maps to progress 0..0.8
                                val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
                                val downloadedBytes = (cdnFraction * fileSize).toLong()
                                Text(
                                    text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
                                    fontSize = 12.sp,
                                    color = statusColor
                                )
                            }
                        }
                        DownloadStatus.DECRYPTING -> {
                            AnimatedDotsText(
                                baseText = "Decrypting",
                                color = statusColor,
                                fontSize = 12.sp
                            )
                        }
                        else -> {
                            Text(
                                text = when (downloadStatus) {
                                    DownloadStatus.ERROR -> "File expired"
                                    else -> "${formatFileSize(fileSize)} $fileExtension"
                                },
                                fontSize = 12.sp,
                                color = statusColor
                            )
                        }
                    }
                }
            }
        }

        // Time and checkmarks (bottom-right overlay) for outgoing files
        if (isOutgoing) {
            Row(
                    modifier =
                            Modifier.align(Alignment.BottomEnd).padding(bottom = 6.dp, end = 4.dp),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.End
            ) {
                // Time
                Text(
                        text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
                        fontSize = 11.sp,
                        color = Color.White.copy(alpha = 0.7f)
                )

                Spacer(modifier = Modifier.width(4.dp))

                // Checkmarks
                when (messageStatus) {
                    MessageStatus.SENDING -> {
                        Icon(
                                painter = TelegramIcons.Clock,
                                contentDescription = null,
                                tint = Color.White.copy(alpha = 0.7f),
                                modifier = Modifier.size(14.dp)
                        )
                    }
                    MessageStatus.SENT -> {
                        Icon(
                                painter = TelegramIcons.Done,
                                contentDescription = null,
                                tint =
                                        if (isDarkTheme) Color.White
                                        else Color.White.copy(alpha = 0.7f),
                                modifier = Modifier.size(14.dp)
                        )
                    }
                    MessageStatus.DELIVERED -> {
                        Icon(
                                painter = TelegramIcons.Done,
                                contentDescription = null,
                                tint =
                                        if (isDarkTheme) Color.White
                                        else Color.White.copy(alpha = 0.7f),
                                modifier = Modifier.size(14.dp)
                        )
                    }
                    MessageStatus.READ -> {
                        Box(modifier = Modifier.height(14.dp)) {
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = null,
                                    tint = Color.White,
                                    modifier = Modifier.size(14.dp)
                            )
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = null,
                                    tint = Color.White,
                                    modifier = Modifier.size(14.dp).offset(x = 4.dp)
                            )
                        }
                    }
                    MessageStatus.ERROR -> {
                        Icon(
                                Icons.Default.Error,
                                contentDescription = null,
                                tint = Color(0xFFE53935),
                                modifier = Modifier.size(14.dp)
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun AvatarAttachment(
        attachment: MessageAttachment,
        chachaKey: String,
        privateKey: String,
        senderPublicKey: String,
        dialogPublicKey: String = "",
        isGroupChat: Boolean = false,
        avatarRepository: AvatarRepository?,
        currentUserPublicKey: String = "",
        isOutgoing: Boolean,
        isDarkTheme: Boolean,
        timestamp: java.util.Date = java.util.Date(),
        messageStatus: MessageStatus = MessageStatus.READ
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
    var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
    var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }

    val preview = getPreview(attachment)
    val downloadTag = getDownloadTag(attachment)

    val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }

    // Bounce animation for avatar
    val avatarScale = remember { Animatable(0f) }
    LaunchedEffect(Unit) {
        avatarScale.animateTo(
            targetValue = 1f,
            animationSpec = spring(
                dampingRatio = 0.5f, // Bouncy effect
                stiffness = 400f
            )
        )
    }

    // Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
    LaunchedEffect(attachment.id) {
        withContext(Dispatchers.IO) {
            downloadStatus =
                    when {
                        // 1. Если blob уже есть в памяти → DOWNLOADED
                        attachment.blob.isNotEmpty() -> {
                            DownloadStatus.DOWNLOADED
                        }
                        // 2. Нет transport tag → локальный файл → DOWNLOADED
                        downloadTag.isBlank() -> {
                            DownloadStatus.DOWNLOADED
                        }
                        // 3. Есть UUID (download tag) → проверяем файловую систему
                        // Desktop: readFile(`a/${md5(attachment.id + publicKey)}`)
                        else -> {
                            val hasLocal =
                                    AvatarFileManager.hasAvatarByAttachmentId(
                                            context,
                                            attachment.id,
                                            senderPublicKey
                                    )
                            if (hasLocal) {
                                DownloadStatus.DOWNLOADED
                            } else {
                                DownloadStatus.NOT_DOWNLOADED
                            }
                        }
                    }
        }

        // Decode blurhash
        if (preview.length >= 20) {
            withContext(Dispatchers.IO) {
                try {
                    blurhashBitmap = BlurHash.decode(preview, 100, 100)
                } catch (e: Exception) {
                }
            }
        }

        // Загружаем аватар если статус DOWNLOADED
        if (downloadStatus == DownloadStatus.DOWNLOADED) {
            withContext(Dispatchers.IO) {
                // 1. Сначала пробуем blob из памяти
                if (attachment.blob.isNotEmpty()) {
                    avatarBitmap = base64ToBitmap(attachment.blob)
                } else {
                    // 2. Читаем из файловой системы (как в Desktop getBlob)
                    val localBlob =
                            AvatarFileManager.readAvatarByAttachmentId(
                                    context,
                                    attachment.id,
                                    senderPublicKey
                            )
                    if (localBlob != null) {
                        avatarBitmap = base64ToBitmap(localBlob)
                    } else {
                        downloadStatus = DownloadStatus.NOT_DOWNLOADED
                    }
                }
            }
        }
    }

    val download: () -> Unit = {
        if (downloadTag.isNotEmpty()) {
            scope.launch {
                try {
                    downloadStatus = DownloadStatus.DOWNLOADING

                    val startTime = System.currentTimeMillis()
                    val encryptedContent =
                            TransportManager.downloadFile(
                                    attachment.id,
                                    downloadTag,
                                    attachment.transportServer
                            )
                    val downloadTime = System.currentTimeMillis() - startTime

                    downloadStatus = DownloadStatus.DECRYPTING

                    val decryptStartTime = System.currentTimeMillis()
                    val decrypted =
                            if (isGroupStoredKey(chachaKey)) {
                                val groupPassword = decodeGroupPassword(chachaKey, privateKey)
                                if (groupPassword.isNullOrBlank()) {
                                    null
                                } else {
                                    CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
                                }
                            } else {
                                // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
                                // Сначала расшифровываем его, получаем raw bytes
                                val decryptedKeyAndNonce =
                                        MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)

                                // Используем decryptAttachmentBlobWithPlainKey который правильно
                                // конвертирует bytes в password
                                MessageCrypto.decryptAttachmentBlobWithPlainKey(
                                        encryptedContent,
                                        decryptedKeyAndNonce
                                )
                            }
                    val decryptTime = System.currentTimeMillis() - decryptStartTime

                    if (decrypted != null) {
                        withContext(Dispatchers.IO) {
                            avatarBitmap = base64ToBitmap(decrypted)

                            // 💾 Сохраняем в файловую систему по attachment.id (как в Desktop)
                            // Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted)
                            val path =
                                    AvatarFileManager.saveAvatarByAttachmentId(
                                            context = context,
                                            base64Image = decrypted,
                                            attachmentId = attachment.id,
                                            publicKey = senderPublicKey
                                    )
                        }
                        // Сохраняем аватар в репозиторий (для UI обновления)
                        // Если это исходящее сообщение с аватаром, сохраняем для текущего
                        // пользователя
                        val normalizedDialogKey = dialogPublicKey.trim()
                        val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
                        val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
                        val targetPublicKey =
                                when {
                                    isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
                                            canonicalDialogKey
                                    isOutgoing && currentUserPublicKey.isNotEmpty() ->
                                            currentUserPublicKey
                                    else -> senderPublicKey
                                }

                        // ВАЖНО: ждем завершения сохранения в репозиторий
                        if (avatarRepository != null) {
                            try {
                                avatarRepository.saveAvatar(targetPublicKey, decrypted)
                            } catch (e: Exception) {
                            }
                        } else {
                        }

                        downloadStatus = DownloadStatus.DOWNLOADED
                    } else {
                        downloadStatus = DownloadStatus.ERROR
                    }
                } catch (e: Exception) {
                    e.printStackTrace()
                    downloadStatus = DownloadStatus.ERROR
                }
            }
        } else {
        }
    }

    // Telegram-style avatar attachment с временем и статусом
    Row(
            modifier =
                    Modifier.fillMaxWidth()
                            .clip(RoundedCornerShape(12.dp))
                            .background(
                                    if (isOutgoing) {
                                        Color.White.copy(alpha = 0.12f)
                                    } else {
                                        if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
                                    }
                            )
                            .clickable(
                                    enabled =
                                            downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
                                                    downloadStatus == DownloadStatus.ERROR
                            ) { download() }
                            .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
    ) {
        // Avatar preview - круглое изображение
        Box(
                modifier =
                        Modifier.size(56.dp)
                                .graphicsLayer {
                                    scaleX = avatarScale.value
                                    scaleY = avatarScale.value
                                }
                                .clip(CircleShape)
                                .background(
                                        if (isOutgoing) Color.White.copy(0.15f)
                                        else
                                                (if (isDarkTheme) Color(0xFF3A3A3A)
                                                else Color(0xFFE8E8E8))
                                ),
                contentAlignment = Alignment.Center
        ) {
            when {
                avatarBitmap != null -> {
                    Image(
                            bitmap = avatarBitmap!!.asImageBitmap(),
                            contentDescription = "Avatar",
                            modifier = Modifier.fillMaxSize(),
                            contentScale = ContentScale.Crop
                    )
                }
                blurhashBitmap != null -> {
                    Image(
                            bitmap = blurhashBitmap!!.asImageBitmap(),
                            contentDescription = "Avatar preview",
                            modifier = Modifier.fillMaxSize(),
                            contentScale = ContentScale.Crop
                    )
                }
                downloadStatus == DownloadStatus.DOWNLOADING ||
                        downloadStatus == DownloadStatus.DECRYPTING -> {
                    CircularProgressIndicator(
                            modifier = Modifier.size(24.dp),
                            color = if (isOutgoing) Color.White else PrimaryBlue,
                            strokeWidth = 2.dp
                    )
                }
                else -> {
                    Icon(
                            Icons.Default.Person,
                            contentDescription = null,
                            tint =
                                    if (isOutgoing) Color.White.copy(0.6f)
                                    else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray),
                            modifier = Modifier.size(28.dp)
                    )
                }
            }

            // Иконка скачивания поверх аватара если нужно скачать
            if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
                Box(
                        modifier =
                                Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)),
                        contentAlignment = Alignment.Center
                ) {
                    Icon(
                            Icons.Default.ArrowDownward,
                            contentDescription = "Download",
                            tint = Color.White,
                            modifier = Modifier.size(24.dp)
                    )
                }
            }
        }

        Spacer(modifier = Modifier.width(12.dp))

        // Info и время/статус
        Column(modifier = Modifier.weight(1f)) {
            // Заголовок с иконкой замка
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(
                        text = "Profile Photo",
                        fontSize = 15.sp,
                        fontWeight = FontWeight.SemiBold,
                        color =
                                if (isOutgoing) Color.White
                                else (if (isDarkTheme) Color.White else Color.Black)
                )
                Spacer(modifier = Modifier.width(6.dp))
                Icon(
                        Icons.Default.Lock,
                        contentDescription = "End-to-end encrypted",
                        tint =
                                if (isOutgoing) Color.White.copy(0.6f)
                                else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray),
                        modifier = Modifier.size(12.dp)
                )
            }

            Spacer(modifier = Modifier.height(2.dp))

            // Описание статуса
            val avatarStatusColor = if (isOutgoing) Color.White.copy(alpha = 0.7f)
                else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
            
            when (downloadStatus) {
                DownloadStatus.DOWNLOADING -> {
                    AnimatedDotsText(
                        baseText = "Downloading",
                        color = avatarStatusColor,
                        fontSize = 13.sp
                    )
                }
                DownloadStatus.DECRYPTING -> {
                    AnimatedDotsText(
                        baseText = "Decrypting",
                        color = avatarStatusColor,
                        fontSize = 13.sp
                    )
                }
                else -> {
                    Text(
                        text = when (downloadStatus) {
                            DownloadStatus.ERROR -> "Tap to retry"
                            DownloadStatus.DOWNLOADED -> "Shared profile photo"
                            else -> "Tap to download"
                        },
                        fontSize = 13.sp,
                        color = avatarStatusColor
                    )
                }
            }

            Spacer(modifier = Modifier.height(4.dp))

            // Время и статус доставки
            Row(
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.spacedBy(4.dp)
            ) {
                Text(
                        text = timeFormat.format(timestamp),
                        fontSize = 11.sp,
                        color =
                                if (isOutgoing) Color.White.copy(alpha = 0.6f)
                                else
                                        (if (isDarkTheme) Color.White.copy(alpha = 0.4f)
                                        else Color.Gray)
                )

                // Галочки статуса для исходящих сообщений
                if (isOutgoing) {
                    when (messageStatus) {
                        MessageStatus.SENDING -> {
                            Icon(
                                    painter = TelegramIcons.Clock,
                                    contentDescription = "Sending",
                                    tint = Color.White.copy(alpha = 0.6f),
                                    modifier = Modifier.size(14.dp)
                            )
                        }
                        MessageStatus.SENT -> {
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = "Sent",
                                    tint =
                                            if (isDarkTheme) Color.White
                                            else Color.White.copy(alpha = 0.6f),
                                    modifier = Modifier.size(14.dp)
                            )
                        }
                        MessageStatus.DELIVERED -> {
                            Icon(
                                    painter = TelegramIcons.Done,
                                    contentDescription = "Delivered",
                                    tint =
                                            if (isDarkTheme) Color.White
                                            else Color.White.copy(alpha = 0.6f),
                                    modifier = Modifier.size(14.dp)
                            )
                        }
                        MessageStatus.READ -> {
                            Box(modifier = Modifier.height(14.dp)) {
                                Icon(
                                        painter = TelegramIcons.Done,
                                        contentDescription = "Read",
                                        tint = Color.White,
                                        modifier = Modifier.size(14.dp)
                                )
                                Icon(
                                        painter = TelegramIcons.Done,
                                        contentDescription = "Read",
                                        tint = Color.White,
                                        modifier = Modifier.size(14.dp).offset(x = 4.dp)
                                )
                            }
                        }
                        MessageStatus.ERROR -> {
                            Icon(
                                    Icons.Default.Error,
                                    contentDescription = "Error",
                                    tint = Color(0xFFE53935),
                                    modifier = Modifier.size(14.dp)
                            )
                        }
                    }
                }
            }
        }

        // Иконка ошибки справа
        if (downloadStatus == DownloadStatus.ERROR) {
            Icon(
                    Icons.Default.Refresh,
                    contentDescription = "Retry",
                    tint = Color(0xFFE53935),
                    modifier = Modifier.size(24.dp)
            )
        }
    }
}

// ================================
// Helper functions
// ================================

private val uuidRegex =
        Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")

/**
 * Проверка является ли preview UUID тегом для скачивания Как в desktop:
 * attachment.preview.split("::")[0].match(uuidRegex)
 */
internal fun isDownloadTag(preview: String): Boolean {
    val firstPart = preview.split("::").firstOrNull() ?: return false
    return uuidRegex.matches(firstPart)
}

/** Получить download tag из preview */
internal fun getDownloadTag(preview: String): String {
    val parts = preview.split("::")
    if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
        return parts[0]
    }
    return ""
}

/** Получить download tag из attachment (new format first, legacy preview fallback). */
internal fun getDownloadTag(attachment: MessageAttachment): String {
    if (attachment.transportTag.isNotBlank()) return attachment.transportTag
    return getDownloadTag(attachment.preview)
}

/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
internal fun getPreview(preview: String): String {
    val parts = preview.split("::")
    if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
        return parts.drop(1).joinToString("::")
    }
    return preview
}

/** Получить attachment preview (new format raw preview, legacy preview fallback). */
internal fun getPreview(attachment: MessageAttachment): String {
    return if (attachment.transportTag.isNotBlank()) {
        attachment.preview
    } else {
        getPreview(attachment.preview)
    }
}

/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
private fun parseFilePreview(preview: String): Pair<Long, String> {
    val parts = preview.split("::")
    return when {
        parts.size >= 3 && uuidRegex.matches(parts[0]) -> {
            // UUID::size::filename
            val size = parts[1].toLongOrNull() ?: 0L
            val name = parts.drop(2).joinToString("::")
            Pair(size, name)
        }
        parts.size >= 2 && !uuidRegex.matches(parts[0]) -> {
            // size::filename
            val size = parts[0].toLongOrNull() ?: 0L
            Pair(size, parts.drop(1).joinToString("::"))
        }
        parts.size >= 2 -> {
            // UUID::filename (no size)
            Pair(0L, parts.drop(1).joinToString("::"))
        }
        else -> Pair(0L, "File")
    }
}

/** Декодирование base64 в Bitmap */
internal fun base64ToBitmap(base64: String): Bitmap? {
    return try {
        val payload = extractBase64Payload(base64) ?: return null
        val bytes = Base64.decode(payload, Base64.DEFAULT)
        if (bytes.isEmpty()) return null

        val decoded = decodeBitmapWithSampling(bytes) ?: return null

        val orientation =
                runCatching {
                            ByteArrayInputStream(bytes).use { stream ->
                                ExifInterface(stream)
                                        .getAttributeInt(
                                                ExifInterface.TAG_ORIENTATION,
                                                ExifInterface.ORIENTATION_NORMAL
                                        )
                            }
                        }
                        .getOrDefault(ExifInterface.ORIENTATION_NORMAL)

        applyExifOrientation(decoded, orientation)
    } catch (e: Exception) {
        null
    } catch (e: OutOfMemoryError) {
        null
    }
}

private fun extractBase64Payload(value: String): String? {
    val trimmed = value.trim()
    if (trimmed.isEmpty()) return null

    val payload =
            when {
                trimmed.startsWith("data:", ignoreCase = true) &&
                        trimmed.contains("base64,", ignoreCase = true) -> {
                    trimmed.substringAfter("base64,", "")
                }
                trimmed.contains(",") &&
                        trimmed.substringBefore(",").contains("base64", ignoreCase = true) -> {
                    trimmed.substringAfter(",", "")
                }
                else -> trimmed
            }

    val clean = payload.replace(whitespaceRegex, "")
    return clean.takeIf { it.isNotEmpty() }
}

private fun decodeBitmapWithSampling(bytes: ByteArray): Bitmap? {
    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
    if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null

    var sampleSize = 1
    while ((bounds.outWidth / sampleSize) > MAX_BITMAP_DECODE_DIMENSION ||
            (bounds.outHeight / sampleSize) > MAX_BITMAP_DECODE_DIMENSION) {
        sampleSize *= 2
    }

    repeat(4) {
        val options =
                BitmapFactory.Options().apply {
                    inSampleSize = sampleSize
                    inPreferredConfig = Bitmap.Config.ARGB_8888
                }
        val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) }.getOrNull()
        if (bitmap != null) return bitmap
        sampleSize *= 2
    }

    return null
}

private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
    if (orientation == ExifInterface.ORIENTATION_NORMAL ||
            orientation == ExifInterface.ORIENTATION_UNDEFINED) {
        return bitmap
    }

    val matrix = Matrix()
    when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
        ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
        ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
        ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
        ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
        ExifInterface.ORIENTATION_TRANSPOSE -> {
            matrix.postRotate(90f)
            matrix.preScale(-1f, 1f)
        }
        ExifInterface.ORIENTATION_TRANSVERSE -> {
            matrix.postRotate(270f)
            matrix.preScale(-1f, 1f)
        }
        else -> return bitmap
    }

    return try {
        val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
        if (rotated != bitmap) {
            bitmap.recycle()
        }
        rotated
    } catch (_: Exception) {
        bitmap
    } catch (_: OutOfMemoryError) {
        bitmap
    }
}

/**
 * Process downloaded encrypted content: decrypt → decode → cache → save.
 * Extracted to avoid duplication between first attempt and retry.
 */
private suspend fun processDownloadedImage(
    encryptedContent: String,
    chachaKey: String,
    privateKey: String,
    attachment: MessageAttachment,
    senderPublicKey: String,
    context: android.content.Context,
    cacheKey: String,
    idShort: String,
    onProgress: (Float) -> Unit,
    onStatus: (DownloadStatus) -> Unit,
    onBitmap: (Bitmap?) -> Unit,
    onError: (String) -> Unit
) {
    onProgress(0.5f)
    onStatus(DownloadStatus.DECRYPTING)

    // Расшифровываем ключ
    var keyCandidates: List<ByteArray> = emptyList()
    var groupPassword: String? = null
    try {
        if (isGroupStoredKey(chachaKey)) {
            groupPassword = decodeGroupPassword(chachaKey, privateKey)
            if (groupPassword.isNullOrBlank()) {
                throw IllegalArgumentException("empty group password")
            }
            logPhotoDebug("Group key decrypt OK: id=$idShort, keyLen=${groupPassword.length}")
        } else {
            keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
            if (keyCandidates.isEmpty()) {
                throw IllegalArgumentException("empty key candidates")
            }
            logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
            keyCandidates.forEachIndexed { idx, candidate ->
                val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
                logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
            }
        }
    } catch (e: Exception) {
        onError("Error")
        onStatus(DownloadStatus.ERROR)
        val keyPrefix =
                when {
                    chachaKey.startsWith("sync:") -> "sync"
                    chachaKey.startsWith("group:") -> "group"
                    else -> "ecdh"
                }
        logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
        return
    }

    // Расшифровываем контент
    val decryptStartTime = System.currentTimeMillis()
    var successKeyIdx = -1
    var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
    val decrypted =
            if (groupPassword != null) {
                // Try raw group key first, then hex-encoded (Desktop v1.2.1+ sends hex-encrypted attachments)
                var plain = CryptoManager.decryptWithPassword(encryptedContent, groupPassword!!)
                if (plain == null) {
                    val hexKey = groupPassword!!.toByteArray(Charsets.ISO_8859_1)
                        .joinToString("") { "%02x".format(it.toInt() and 0xff) }
                    logPhotoDebug("Group raw key failed, trying hex key: id=$idShort, hexKeyLen=${hexKey.length}")
                    plain = CryptoManager.decryptWithPassword(encryptedContent, hexKey)
                }
                decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(plain, emptyList())
                plain
            } else {
                var value: String? = null
                for ((idx, keyCandidate) in keyCandidates.withIndex()) {
                    val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
                    if (attempt.decrypted != null) {
                        successKeyIdx = idx
                        decryptDebug = attempt
                        value = attempt.decrypted
                        break
                    }
                    // Keep last trace for diagnostics if all fail.
                    decryptDebug = attempt
                }
                value
            }
    val decryptTime = System.currentTimeMillis() - decryptStartTime
    onProgress(0.8f)

    if (decrypted != null) {
        var decodedBitmap: Bitmap? = null
        var saved = false
        logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms, decryptedLen=${decrypted.length}, keyIdx=$successKeyIdx")
        decryptDebug.trace.lastOrNull { it.contains("SUCCESS") }?.let { successLine ->
            logPhotoDebug("Blob decrypt trace: id=$idShort, $successLine")
        }
        withContext(Dispatchers.IO) {
            try {
                decodedBitmap = base64ToBitmap(decrypted)
            } catch (oom: OutOfMemoryError) {
                System.gc()
                logPhotoDebug("Bitmap OOM: id=$idShort, decryptedLen=${decrypted.length}")
            }
            if (decodedBitmap != null) {
                onBitmap(decodedBitmap)
                ImageBitmapCache.put(cacheKey, decodedBitmap!!)

                saved = AttachmentFileManager.saveAttachment(
                    context = context,
                    blob = decrypted,
                    attachmentId = attachment.id,
                    publicKey = senderPublicKey,
                    privateKey = privateKey
                )
            }
        }
        if (decodedBitmap != null) {
            onProgress(1f)
            onStatus(DownloadStatus.DOWNLOADED)
            logPhotoDebug("Image ready: id=$idShort, saved=$saved")
        } else {
            onError("Error")
            onStatus(DownloadStatus.ERROR)
            val preview = decrypted.take(30).replace("\n", "")
            logPhotoDebug("Image decode FAILED: id=$idShort, decryptedLen=${decrypted.length}, preview=$preview")
        }
    } else {
        onError("Error")
        onStatus(DownloadStatus.ERROR)
        val keyPrefix =
                when {
                    chachaKey.startsWith("sync:") -> "sync"
                    chachaKey.startsWith("group:") -> "group"
                    else -> "ecdh"
                }
        val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
        logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
        decryptDebug.trace.take(96).forEachIndexed { index, line ->
            logPhotoDebug("Blob decrypt TRACE[$index]: id=$idShort, $line")
        }
    }
}

/**
 * CDN download + decrypt helper for voice payload (hex string).
 */
internal suspend fun downloadAndDecryptVoicePayload(
    attachmentId: String,
    downloadTag: String,
    chachaKey: String,
    privateKey: String,
    transportServer: String = "",
    chachaKeyPlainHex: String = ""
): String? {
    if (downloadTag.isBlank() || privateKey.isBlank()) return null
    if (chachaKeyPlainHex.isBlank() && chachaKey.isBlank()) return null

    return withContext(Dispatchers.IO) {
        runCatching {
            val encryptedContent =
                TransportManager.downloadFile(
                    attachmentId,
                    downloadTag,
                    transportServer.ifBlank { null }
                )
            if (encryptedContent.isBlank()) return@withContext null

            when {
                chachaKeyPlainHex.isNotBlank() -> {
                    val plainKey =
                        chachaKeyPlainHex.chunked(2)
                            .mapNotNull { part -> part.toIntOrNull(16)?.toByte() }
                            .toByteArray()
                    if (plainKey.isEmpty()) {
                        null
                    } else {
                        MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
                            ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey)
                                .takeIf { it.isNotEmpty() }
                    }
                }
                isGroupStoredKey(chachaKey) -> {
                    val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
                    CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
                        ?: run {
                            val hexKey =
                                groupPassword.toByteArray(Charsets.ISO_8859_1)
                                    .joinToString("") { "%02x".format(it.toInt() and 0xff) }
                            CryptoManager.decryptWithPassword(encryptedContent, hexKey)
                        }
                }
                else -> MessageCrypto.decryptAttachmentBlob(encryptedContent, chachaKey, privateKey)
            }
        }.getOrNull()
    }
}

/**
 * CDN download + decrypt + cache + save.
 * Shared between ReplyBubble and ForwardedImagePreview.
 *
 * @return loaded Bitmap or null
 */
internal suspend fun downloadAndDecryptImage(
    attachmentId: String,
    downloadTag: String,
    chachaKey: String,
    privateKey: String,
    cacheKey: String,
    context: android.content.Context,
    senderPublicKey: String,
    recipientPrivateKey: String,
    chachaKeyPlainHex: String = ""
): Bitmap? {
    if (downloadTag.isEmpty() || privateKey.isEmpty()) return null
    if (chachaKeyPlainHex.isEmpty() && chachaKey.isEmpty()) return null

    return withContext(Dispatchers.IO) {
        val idShort = shortDebugId(attachmentId)
        val tagShort = shortDebugId(downloadTag)
        val server = TransportManager.getTransportServer() ?: "unset"
        try {
            logPhotoDebug("Start helper image download: id=$idShort, tag=$tagShort, server=$server")
            val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
            if (encryptedContent.isEmpty()) return@withContext null
            logPhotoDebug(
                    "Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
            )

            val decrypted: String? =
                    if (chachaKeyPlainHex.isNotEmpty()) {
                        // Desktop/iOS parity: используем готовый plainKeyAndNonce из chacha_key_plain
                        val plainKey = chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
                        logPhotoDebug("Helper using chacha_key_plain: id=$idShort, keySize=${plainKey.size}")
                        MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
                            ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
                    } else if (isGroupStoredKey(chachaKey)) {
                        val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
                        CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
                            ?: run {
                                val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1)
                                    .joinToString("") { "%02x".format(it.toInt() and 0xff) }
                                CryptoManager.decryptWithPassword(encryptedContent, hexKey)
                            }
                    } else {
                        val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
                        if (keyCandidates.isEmpty()) return@withContext null
                        val plainKeyAndNonce = keyCandidates.first()
                        logPhotoDebug(
                                "Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
                        )
                        logPhotoDebug(
                                "Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
                        )

                        // Primary path for image attachments
                        var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
                        var value: String? = null
                        for ((idx, keyCandidate) in keyCandidates.withIndex()) {
                            val attempt =
                                    MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
                                            encryptedContent,
                                            keyCandidate
                                    )
                            if (attempt.decrypted != null) {
                                decryptDebug = attempt
                                value = attempt.decrypted
                                logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
                                break
                            }
                            decryptDebug = attempt
                        }

                        // Fallback for legacy payloads
                        if (value.isNullOrEmpty()) {
                            decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
                                logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
                            }
                            value =
                                    try {
                                        MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
                                                .takeIf { it.isNotEmpty() && it != encryptedContent }
                                    } catch (_: Exception) {
                                        null
                                    }
                        }
                        value
                    }

            if (decrypted.isNullOrEmpty()) return@withContext null

            val base64Data = extractBase64Payload(decrypted) ?: return@withContext null
            val bitmap = base64ToBitmap(base64Data) ?: return@withContext null

            ImageBitmapCache.put(cacheKey, bitmap)
            val saved = AttachmentFileManager.saveAttachment(
                context = context,
                blob = base64Data,
                attachmentId = attachmentId,
                publicKey = senderPublicKey,
                privateKey = recipientPrivateKey
            )
            logPhotoDebug("Helper image ready: id=$idShort, saved=$saved")

            bitmap
        } catch (e: Exception) {
            logPhotoDebug(
                    "Helper image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
            )
            null
        }
    }
}

/** Форматирование размера файла */
private fun formatFileSize(bytes: Long): String {
    return when {
        bytes < 1024 -> "$bytes B"
        bytes < 1024 * 1024 -> "${bytes / 1024} KB"
        bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
        else -> String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0))
    }
}
