import UIKit
import CoreText

/// Pre-calculated layout for a message cell.
/// Computed on ANY thread (background-safe). Applied on main thread.
/// This is the Rosetta equivalent of Telegram's `asyncLayout()` pattern.
///
/// Pattern:
///   1. `MessageCellLayout.calculate(message:, config:)` — runs on background
///   2. Returns `MessageCellLayout` with ALL frame rects
///   3. `NativeMessageCell.apply(layout:)` — runs on main thread, just sets frames
struct MessageCellLayout: Sendable {

    // MARK: - Cell

    var totalHeight: CGFloat
    let groupGap: CGFloat
    let isOutgoing: Bool
    let position: BubblePosition
    let messageType: MessageType

    // MARK: - Bubble

    let bubbleFrame: CGRect       // Bubble view frame in cell coords
    let bubbleSize: CGSize        // Bubble size (for shape path)
    let mergeType: BubbleMergeType
    let hasTail: Bool

    // MARK: - Text

    let textFrame: CGRect         // Text label frame in bubble coords
    let textSize: CGSize
    let timestampInline: Bool     // true = timestamp on same line as last text line

    // MARK: - Timestamp

    let timestampFrame: CGRect    // Timestamp label frame in bubble coords
    let checkSentFrame: CGRect    // Sent-check (✓) frame in bubble coords
    let checkReadFrame: CGRect    // Read-check (/) frame in bubble coords (overlaps sent for ✓✓)
    let clockFrame: CGRect        // Sending clock frame in bubble coords
    let showsDeliveryFailedIndicator: Bool
    let deliveryFailedInset: CGFloat

    // MARK: - Reply Quote (optional)

    let hasReplyQuote: Bool
    let replyContainerFrame: CGRect
    let replyBarFrame: CGRect
    let replyNameFrame: CGRect
    let replyTextFrame: CGRect

    // MARK: - Photo (optional)

    let hasPhoto: Bool
    let isForwardPhotoOnly: Bool  // forward with photo, no caption → photo-style badge
    let photoFrame: CGRect        // Photo view frame in bubble coords
    let photoCollageHeight: CGFloat

    // MARK: - File (optional)

    let hasFile: Bool
    let fileFrame: CGRect         // File view frame in bubble coords

    // MARK: - Group Invite (optional)

    let hasGroupInvite: Bool
    let groupInviteTitle: String
    let groupInviteGroupId: String

    // MARK: - Forward Header (optional)

    let isForward: Bool
    let forwardHeaderFrame: CGRect
    let forwardAvatarFrame: CGRect
    let forwardNameFrame: CGRect
    let forwardChachaKeyPlain: String   // hex key for decrypting forwarded CDN attachments
    let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
    let additionalForwardItems: [ForwardItemFrame] // items 2+ for multi-forward (Android parity)

    // MARK: - Date Header (optional)

    let showsDateHeader: Bool
    let dateHeaderText: String
    let dateHeaderHeight: CGFloat

    // MARK: - Group Sender (optional)

    var showsSenderName: Bool     // true for .top/.single incoming in group
    var showsSenderAvatar: Bool   // true for .bottom/.single incoming in group
    var senderName: String
    var senderKey: String
    var isGroupAdmin: Bool        // true if sender is group owner (members[0])

    // MARK: - Types

    enum MessageType: Sendable {
        case text
        case textWithReply
        case photo
        case photoWithCaption
        case file
        case forward
        case emojiOnly
        case groupInvite
        case service
    }
}

/// Forward item info for multi-forward layout (Android parity).
struct ForwardItemInfo: Sendable {
    let senderName: String
    let senderKey: String
    let caption: String
    let chachaKeyPlain: String
    let attachments: [ReplyAttachmentData]
    let imageCount: Int
    let fileCount: Int
    let voiceCount: Int
}

/// Pre-calculated frame for a single additional forward item (items 2+).
struct ForwardItemFrame: Sendable {
    let dividerY: CGFloat       // Y of horizontal divider (bubble coords)
    let headerFrame: CGRect     // "Forwarded from" label frame
    let avatarFrame: CGRect     // Mini avatar frame
    let nameFrame: CGRect       // Sender name label frame
    let textFrame: CGRect       // Caption text frame
    let photoFrame: CGRect      // Photo collage frame
    let voiceFrame: CGRect      // Voice player frame
    let info: ForwardItemInfo   // Full data for rendering
}

// MARK: - Layout Calculation (Thread-Safe)

extension MessageCellLayout {

    struct Config: Sendable {
        let maxBubbleWidth: CGFloat
        let isOutgoing: Bool
        let isDarkMode: Bool
        let position: BubblePosition
        let deliveryStatus: DeliveryStatus
        let text: String
        let timestampText: String
        let hasReplyQuote: Bool
        let replyName: String?
        let replyText: String?
        let imageCount: Int
        let imageDimensions: CGSize?
        let fileCount: Int
        let avatarCount: Int
        let callCount: Int
        let voiceCount: Int
        let voiceDuration: TimeInterval
        let isForward: Bool
        let forwardImageCount: Int
        let forwardFileCount: Int
        let forwardVoiceCount: Int
        let forwardCaption: String?
        let showsDateHeader: Bool
        let dateHeaderText: String
        let groupInviteCount: Int
        let groupInviteTitle: String
        let groupInviteGroupId: String
        let forwardSenderName: String // forward sender name (for dynamic min bubble width)
        let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
        let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
        let allForwardItems: [ForwardItemInfo] // all forward items for multi-forward
        let senderName: String       // group sender name (for min bubble width)
        let isGroupAdmin: Bool       // sender is group owner (admin badge takes extra space)
    }

    private struct MediaDimensions {
        let maxWidth: CGFloat
        let maxHeight: CGFloat
        let minWidth: CGFloat
        let minHeight: CGFloat
    }

    private struct TextStatusLaneMetrics {
        let textToMetadataGap: CGFloat
        let timeToCheckGap: CGFloat
        let textStatusRightInset: CGFloat
        let statusWidth: CGFloat
        let checkOffset: CGFloat
        let verticalOffset: CGFloat
        let checkBaselineOffset: CGFloat

        static func telegram(fontPointSize: CGFloat, screenPixel: CGFloat) -> TextStatusLaneMetrics {
            TextStatusLaneMetrics(
                textToMetadataGap: 5,
                timeToCheckGap: 5,
                textStatusRightInset: 5,
                statusWidth: floor(floor(fontPointSize * 13.0 / 17.0)),
                checkOffset: floor(fontPointSize * 6.0 / 17.0),
                // Lift text status lane by one pixel to match Telegram vertical seating.
                verticalOffset: -screenPixel,
                checkBaselineOffset: 3.0 - screenPixel
            )
        }
    }

    // MARK: - Cached Fonts (PERF: avoid UIFont alloc per cell)

    private static let textFont17 = UIFont.systemFont(ofSize: 17, weight: .regular)
    private static let emojiFont53 = UIFont.systemFont(ofSize: 53, weight: .regular)
    private static let tsFont11 = UIFont.systemFont(ofSize: floor(17 * 11.0 / 17.0), weight: .regular)
    private static let fwdLabelFont14 = UIFont.systemFont(ofSize: 14, weight: .regular)
    private static let fwdNameFont14 = UIFont.systemFont(ofSize: 14, weight: .semibold)
    private static let senderNameFont13 = UIFont.systemFont(ofSize: 13, weight: .semibold)
    private static let fwdTextFont17 = UIFont.systemFont(ofSize: 17, weight: .regular)
    /// PERF: Cached JSONDecoder — avoids heap alloc per forward message in batchCalculate loop.
    private static let forwardDecoder = JSONDecoder()

    /// Calculate complete cell layout on ANY thread.
    /// Uses CoreText for text measurement (thread-safe).
    /// Returns layout with all frame rects + cached CoreTextTextLayout for rendering.
    ///
    /// Telegram-style tight bubbles: timestamp goes inline with last text line
    /// when there's space, or on a new line when there isn't.
    static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) {
        // emojiOnly detection needs text first, but font is needed for layout.
        // Pre-check for emojiOnly to choose font size (40pt vs 17pt).
        let isEmojiOnlyPrecheck = !config.text.isEmpty
            && config.imageCount == 0 && config.fileCount == 0
            && config.avatarCount == 0 && config.callCount == 0 && config.voiceCount == 0
            && !config.isForward && !config.hasReplyQuote
            && EmojiParser.isEmojiOnly(config.text)
        // Telegram: messageEmojiFont = Font.regular(53.0) (ChatPresentationData.swift line 58)
        let font = isEmojiOnlyPrecheck ? emojiFont53 : textFont17
        let tsFont = tsFont11
        let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
        let metrics = BubbleMetrics.telegram()
        let mergeType = BubbleGeometryEngine.mergeType(for: config.position)
        let hasTail = BubbleGeometryEngine.hasTail(for: mergeType)
        let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error
        let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0
        let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset)

        // Classify message type
        var messageType: MessageType
        if config.text.hasPrefix("$a=") {
            messageType = .service
        } else if config.isForward {
            messageType = .forward
        } else if config.imageCount > 0 && !config.text.isEmpty {
            messageType = .photoWithCaption
        } else if config.imageCount > 0 {
            messageType = .photo
        } else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 || config.voiceCount > 0 {
            messageType = .file
        } else if config.groupInviteCount > 0 {
            messageType = .groupInvite
        } else if config.hasReplyQuote {
            messageType = .textWithReply
        } else {
            messageType = .text
        }
        // Service messages ($a= prefix): centered pill, no bubble, fixed height
        if messageType == .service {
            let serviceHeight: CGFloat = 30
            let dateH: CGFloat = config.showsDateHeader ? 38 : 0
            let layout = MessageCellLayout(
                totalHeight: serviceHeight + dateH,
                groupGap: 0,
                isOutgoing: false,
                position: .single,
                messageType: .service,
                bubbleFrame: CGRect(x: 0, y: dateH, width: config.maxBubbleWidth, height: serviceHeight),
                bubbleSize: CGSize(width: config.maxBubbleWidth, height: serviceHeight),
                mergeType: .none,
                hasTail: false,
                textFrame: .zero,
                textSize: .zero,
                timestampInline: false,
                timestampFrame: .zero,
                checkSentFrame: .zero,
                checkReadFrame: .zero,
                clockFrame: .zero,
                showsDeliveryFailedIndicator: false,
                deliveryFailedInset: 0,
                hasReplyQuote: false,
                replyContainerFrame: .zero,
                replyBarFrame: .zero,
                replyNameFrame: .zero,
                replyTextFrame: .zero,
                hasPhoto: false,
                isForwardPhotoOnly: false,
                photoFrame: .zero,
                photoCollageHeight: 0,
                hasFile: false,
                fileFrame: .zero,
                hasGroupInvite: false,
                groupInviteTitle: "",
                groupInviteGroupId: "",
                isForward: false,
                forwardHeaderFrame: .zero,
                forwardAvatarFrame: .zero,
                forwardNameFrame: .zero,
                forwardChachaKeyPlain: "",
                forwardAttachments: [],
                additionalForwardItems: [],
                showsDateHeader: config.showsDateHeader,
                dateHeaderText: config.dateHeaderText,
                dateHeaderHeight: dateH,
                showsSenderName: false,
                showsSenderAvatar: false,
                senderName: "",
                senderKey: "",
                isGroupAdmin: false
            )
            return (layout, nil)
        }

        // Emoji-only: single emoji without other text → large font, no bubble
        if messageType == .text && !config.text.isEmpty && EmojiParser.isEmojiOnly(config.text) {
            messageType = .emojiOnly
        }
        let isTextMessage = (messageType == .text || messageType == .textWithReply)
        let isForwardWithCaption = messageType == .forward && !config.text.isEmpty
        let isForwardPhotoOnly = messageType == .forward && config.text.isEmpty
            && (config.forwardImageCount > 0 || config.imageCount > 0)
        // Status lane metrics always use 17pt base (not the emoji-enlarged font).
        let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
            fontPointSize: 17,
            screenPixel: screenPixel
        )
        let groupGap: CGFloat = {
            guard config.position == .mid || config.position == .bottom else {
                return metrics.defaultSpacing
            }
            // Keep grouped text bubbles compact, but still visually split.
            if isTextMessage {
                return screenPixel
            }
            return metrics.mergedSpacing
        }()

        // ── STEP 1: Asymmetric paddings + base text measurement (full width) ──
        let topPad: CGFloat = metrics.textInsets.top
        let bottomPad: CGFloat = metrics.textInsets.bottom
        let leftPad: CGFloat = metrics.textInsets.left
        let rightPad: CGFloat = metrics.textInsets.right
        // Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation)
        // Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation
        let statusTrailingCompensation: CGFloat
        if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && config.isOutgoing {
            statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
        } else {
            statusTrailingCompensation = 0
        }

        // maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
        // Text is measured at the WIDEST possible constraint.
        let maxTextWidth = effectiveMaxBubbleWidth - leftPad - rightPad

        let textMeasurement: TextMeasurement
        var cachedTextLayout: CoreTextTextLayout?
        let needsDetailedTextLayout = isTextMessage || messageType == .emojiOnly || messageType == .photoWithCaption || (messageType == .forward && !config.text.isEmpty)
        if !config.text.isEmpty && needsDetailedTextLayout {
            // CoreText (CTTypesetter) — returns per-line widths including lastLineWidth.
            // Also captures CoreTextTextLayout for cell rendering (avoids double computation).
            let bubbleTextColor: UIColor = (config.isOutgoing || config.isDarkMode) ? .white : .black
            let bubbleLinkColor: UIColor = config.isOutgoing ? .white : CoreTextTextLayout.linkColor
            let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font, textColor: bubbleTextColor, linkColor: bubbleLinkColor)
            textMeasurement = measurement
            cachedTextLayout = layout
        } else if !config.text.isEmpty {
            // Forwards, files (no CoreTextLabel rendering needed)
            let size = measureText(config.text, maxWidth: max(maxTextWidth, 50), font: font)
            textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width)
        } else {
            textMeasurement = TextMeasurement(size: .zero, trailingLineWidth: 0)
        }

        // ── STEP 2: Meta-info dimensions ──
        let timestampText = config.timestampText.isEmpty ? "00:00" : config.timestampText
        let tsSize = measureText(timestampText, maxWidth: 60, font: tsFont)
        let hasStatusIcon = config.isOutgoing && !isOutgoingFailed
        let statusWidth: CGFloat = hasStatusIcon
            ? textStatusLaneMetrics.statusWidth
            : 0
        let checkW: CGFloat = statusWidth
        let timeToCheckGap: CGFloat = hasStatusIcon
            ? textStatusLaneMetrics.timeToCheckGap
            : 0
        let metadataWidth = tsSize.width + timeToCheckGap + checkW

        let trailingWidthForStatus: CGFloat
        if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && !config.text.isEmpty {
            if let cachedTextLayout {
                if cachedTextLayout.lastLineHasRTL {
                    trailingWidthForStatus = 10_000
                } else if cachedTextLayout.lastLineHasBlockQuote {
                    trailingWidthForStatus = textMeasurement.size.width
                } else {
                    trailingWidthForStatus = textMeasurement.trailingLineWidth
                }
            } else {
                trailingWidthForStatus = textMeasurement.trailingLineWidth
            }
        } else {
            trailingWidthForStatus = 0
        }

        let inlineStatusContentWidth = max(
            0,
            trailingWidthForStatus
                + textStatusLaneMetrics.textToMetadataGap
                + metadataWidth
                - statusTrailingCompensation
        )
        let wrappedStatusContentWidth = max(
            0,
            metadataWidth - statusTrailingCompensation
        )
        let isShortSingleLineText: Bool = {
            guard messageType == .text, !config.text.isEmpty else { return false }
            guard !config.text.contains("\n") else { return false }
            if let cachedTextLayout {
                return cachedTextLayout.lines.count == 1
            }
            return textMeasurement.size.height <= ceil(font.lineHeight + 1.0)
        }()

        // ── STEP 3: Inline vs Wrapped determination ──
        var timestampInline: Bool
        if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && !config.text.isEmpty {
            timestampInline = inlineStatusContentWidth <= maxTextWidth
        } else {
            timestampInline = true
        }

        // ── STEP 4: Bubble dimensions (unified width + height) ──

        // Content blocks above the text area
        let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
        // Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container
        let replyContainerH: CGFloat = 42
        let replyTopInset: CGFloat = 5
        let replyBottomGap: CGFloat = 3
        let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
        var photoH: CGFloat = 0
        // Two-row: "Forwarded from" + [avatar] Name
        let forwardHeaderH: CGFloat = config.isForward ? 38 : 0
        var fileH: CGFloat = CGFloat(config.fileCount) * 52
            + CGFloat(config.callCount) * 42
            + CGFloat(config.avatarCount) * 52
            + CGFloat(config.voiceCount) * 38
            + CGFloat(config.isForward ? config.forwardFileCount : 0) * 52
            + CGFloat(config.isForward ? config.forwardVoiceCount : 0) * 44 // 38pt voice + 6pt bottom pad
        let originalFileContentH = fileH // before potential expansion for timestamp centering

        // Tiny floor just to prevent zero-width collapse.
        // Telegram does NOT force a large minW — short messages get tight bubbles.
        let minW: CGFloat = 40
        let mediaBubbleMaxWidth = min(effectiveMaxBubbleWidth, mediaDimensions.maxWidth)
        let mediaBubbleMinWidth = min(mediaDimensions.minWidth, mediaBubbleMaxWidth)

        var bubbleW: CGFloat
        var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
        var fileOnlyTsPad: CGFloat = 0 // symmetric bottom inset for file-only bubbles

        if config.imageCount > 0 || (config.isForward && config.forwardImageCount > 0) {
            let effectiveImageCount = config.imageCount > 0 ? config.imageCount : config.forwardImageCount
            // Telegram-exact photo sizing (ChatMessageInteractiveMediaNode.swift):
            // 1. unboundSize = pixelDims * 0.5 (or 200×100 default)
            // 2. fitted = unbound.aspectFitted(maxDimensions)
            // 3. width = min(unbound.width, fitted.width, maxWidth)  ← KEY: cap to natural size
            // 4. height = fit to width preserving aspect ratio, then clamp
            // 5. Photo inset: 2pt on ALL four sides
            let photoInset: CGFloat = 2
            if effectiveImageCount == 1, let dims = config.imageDimensions {
                // Telegram-exact: unboundSize = pixelDims * 0.5, then aspectFitted
                let unbound = CGSize(
                    width: floor(dims.width * 0.5),
                    height: floor(dims.height * 0.5)
                )
                let maxConstraint = CGSize(width: mediaBubbleMaxWidth, height: mediaDimensions.maxHeight)
                let fitted = unbound.aspectFitted(maxConstraint)
                bubbleW = max(mediaBubbleMinWidth,
                              min(ceil(unbound.width), min(ceil(fitted.width), mediaBubbleMaxWidth)))
                let aspectH = ceil(bubbleW * unbound.height / unbound.width)
                photoH = max(mediaDimensions.minHeight, min(aspectH, mediaDimensions.maxHeight))
            } else if effectiveImageCount == 1 {
                // No dimensions (legacy or forwarded) — use full max width with 0.75 aspect
                bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
                photoH = max(mediaDimensions.minHeight,
                             min(ceil(bubbleW * 0.75), mediaDimensions.maxHeight))
            } else {
                bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
                photoH = Self.collageHeight(
                    count: effectiveImageCount,
                    width: bubbleW - 4,
                    maxHeight: mediaDimensions.maxHeight,
                    minHeight: mediaDimensions.minHeight
                )
            }
            // Photo+caption: ensure bubble is wide enough for text
            if !config.text.isEmpty {
                let textNeedW = leftPad + textMeasurement.size.width + rightPad
                bubbleW = max(bubbleW, min(textNeedW, effectiveMaxBubbleWidth))
            }
            // Recheck: photo bubble may be narrower than maxTextWidth
            if messageType == .photoWithCaption && !config.text.isEmpty {
                let actualMaxInline = bubbleW - leftPad - rightPad
                if inlineStatusContentWidth > actualMaxInline {
                    timestampInline = false
                }
            }
            // Telegram: 2pt inset on all 4 sides → bubble is photoH + 4pt taller
            bubbleH += photoH + photoInset * 2
            if !config.text.isEmpty {
                if timestampInline {
                    bubbleH += topPad + textMeasurement.size.height + bottomPad
                } else {
                    bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
                }
                if photoH > 0 { bubbleH += 3 }
            }
        } else if messageType == .emojiOnly {
            // Emoji-only: no bubble — rendered like a sticker.
            // Telegram source: ChatMessageStickerItemNode.swift
            //   innerImageInset = 10pt (line 597)
            //   imageSize = CoreText measured size at 53pt font
            //   innerImageSize = imageSize + inset*2 on each side (line 598)
            //   outgoing adds statusHeight to layoutSize (line 955-957)
            let emojiInset: CGFloat = 10
            let emojiW = textMeasurement.size.width
            let emojiH = textMeasurement.size.height
            bubbleW = emojiW + emojiInset * 2
            bubbleH = emojiH + emojiInset * 2
            // Telegram: outgoing adds status height, incoming overlaps
            let statusPillH = tsSize.height + 4 // pill vertical padding (2+2)
            if config.isOutgoing {
                bubbleH += statusPillH
            }
        } else if isTextMessage && !config.text.isEmpty {
            // ── EXACT TELEGRAM MATH — no other modifiers ──
            let actualTextW = textMeasurement.size.width

            let finalContentW: CGFloat
            if timestampInline {
                // INLINE: width = max(widest line, last line + gap + status)
                finalContentW = max(
                    actualTextW,
                    inlineStatusContentWidth
                )
                bubbleH += topPad + textMeasurement.size.height + bottomPad
            } else {
                // WRAPPED: status drops to new line below text
                finalContentW = max(actualTextW, wrappedStatusContentWidth)
                bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
            }

            // Set bubble width TIGHTLY: leftPad + content + rightPad
            bubbleW = leftPad + finalContentW + rightPad
            bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
            if config.hasReplyQuote { bubbleW = max(bubbleW, 180) }
        } else if isForwardWithCaption {
            // Forward with caption — SAME sizing as text messages (Telegram parity)
            // 2pt gap from forward header to text (Telegram: spacing after forward = 2pt)
            let actualTextW = textMeasurement.size.width
            let finalContentW: CGFloat
            if timestampInline {
                finalContentW = max(actualTextW, inlineStatusContentWidth)
                bubbleH += 2 + textMeasurement.size.height + bottomPad
            } else {
                finalContentW = max(actualTextW, wrappedStatusContentWidth)
                bubbleH += 2 + textMeasurement.size.height + 15 + bottomPad
            }
            bubbleW = leftPad + finalContentW + rightPad
            bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
        } else if !config.text.isEmpty {
            // Non-text with caption (file)
            let finalContentW = max(textMeasurement.size.width, metadataWidth)
            bubbleW = leftPad + finalContentW + rightPad
            bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
            let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220
            if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) }
            bubbleH += topPad + textMeasurement.size.height + bottomPad
        } else if fileH > 0 {
            // Telegram: call width = title + button(54) + insets ≈ 200pt
            // Telegram: file width = icon(55) + filename + insets ≈ 220pt
            let fileMinW: CGFloat
            let hasVoice = config.voiceCount > 0 || (config.isForward && config.forwardVoiceCount > 0)
                || (config.isForward && config.allForwardItems.contains { $0.voiceCount > 0 })
            if hasVoice {
                // Telegram: voice width scales with duration — min 55% of maxW
                let minVoiceW = effectiveMaxBubbleWidth * 0.55
                let maxVoiceW = effectiveMaxBubbleWidth - 36
                let clampedDur = max(2, min(30, config.voiceDuration))
                fileMinW = minVoiceW + (maxVoiceW - minVoiceW) * CGFloat(clampedDur - 2) / CGFloat(30 - 2)
            } else {
                fileMinW = config.callCount > 0 ? 200 : 220
            }
            bubbleW = min(fileMinW, effectiveMaxBubbleWidth)
            bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
            // Symmetric centering: content + gap + timestamp block centered in bubble.
            // Content is centered within fileContainer (in layoutSubviews) above the timestamp.
            // To achieve visual symmetry, fileH spans the ENTIRE bubble
            // and metadataBottomInset = (fileH - contentH) / 2 (same as content topY).
            let tsGap: CGFloat = 6
            let contentH: CGFloat = config.callCount > 0 ? 36 : (hasVoice ? 38 : 44)
            let tsPad: CGFloat
            if config.isForward {
                // Forwarded: no vertical centering, tight fit
                tsPad = 4
            } else {
                tsPad = ceil((fileH + tsGap - contentH) / 2)
            }
            fileOnlyTsPad = tsPad
            bubbleH += tsGap + tsSize.height + tsPad
            fileH = bubbleH - forwardHeaderH - replyH  // fileContainer spans content area (below forward header)
        } else if config.groupInviteCount > 0 {
            // Group invite card: icon(44) + status + button(28) = ~80pt content
            let inviteCardH: CGFloat = 80
            let inviteMinW: CGFloat = 220
            bubbleW = min(inviteMinW, effectiveMaxBubbleWidth)
            bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
            let tsGap: CGFloat = 4
            let tsPad: CGFloat = 4
            fileOnlyTsPad = tsPad
            bubbleH += inviteCardH + tsGap + tsSize.height + tsPad
        } else {
            // No text, no file (forward header only, empty)
            bubbleW = leftPad + metadataWidth + rightPad
            bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
        }

        if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
            bubbleH = max(bubbleH, 37)
        }
        // Forward header min width: "Forwarded from" label + [avatar] Name row
        if config.isForward {
            let headerW = ("Forwarded from" as NSString).size(withAttributes: [.font: fwdLabelFont14]).width
            let nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont14]).width
            // Row 1: 10pt + "Forwarded from" + 10pt
            // Row 2: 10pt + 16pt avatar + 4pt + name + 10pt
            let fwdMinW = ceil(max(headerW + 20, nameW + 40))
            bubbleW = max(bubbleW, min(fwdMinW, effectiveMaxBubbleWidth))
        }
        // Ensure bubble is wide enough for sender name (group chats).
        // Name font: 13pt semibold. Admin badge: +20pt. Padding: 10 left + 14 right = 24.
        if !config.senderName.isEmpty {
            let nameW = (config.senderName as NSString).size(withAttributes: [.font: senderNameFont13]).width
            let adminExtra: CGFloat = config.isGroupAdmin ? 20 : 0
            let neededW = ceil(nameW) + adminExtra + 24
            bubbleW = max(bubbleW, min(neededW, effectiveMaxBubbleWidth))
        }
        // Stretchable bubble image min height
        bubbleH = max(bubbleH, 37)

        // Forward/reply text area must match regular text bubble min-height behavior.
        // Without this, text and timestamp share the same bottom edge (look "centered").
        // With this, the text area gets the same ~5pt extra padding as regular text bubbles.
        if (isForwardWithCaption || (config.hasReplyQuote && !config.text.isEmpty)) {
            let headerH = forwardHeaderH + replyH
            let textAreaH = bubbleH - headerH
            if textAreaH < 37 {
                bubbleH = headerH + 37
            }
        }

        // Multi-forward: add height for additional items (Android parity).
        // Item 0 is already included in bubbleH above. Items 1+ are appended.
        // IMPORTANT: widen bubbleW FIRST (for sender names), THEN compute heights
        // using the final bubbleW. Both passes must use the same width to avoid
        // collage height mismatch between height estimation and frame positioning.
        var additionalFwdH: CGFloat = 0
        if config.isForward && config.allForwardItems.count > 1 {
            // Step 1: Widen bubbleW for additional senders' two-row headers + voice
            let fwdLabelW = ("Forwarded from" as NSString).size(
                withAttributes: [.font: fwdLabelFont14]
            ).width
            for i in 1..<config.allForwardItems.count {
                let nameW = (config.allForwardItems[i].senderName as NSString).size(
                    withAttributes: [.font: fwdNameFont14]
                ).width
                let neededW = ceil(max(fwdLabelW + 20, nameW + 40))
                bubbleW = max(bubbleW, min(neededW, effectiveMaxBubbleWidth))
            }
            // Widen for voice in any forward item — min 55% of maxW (Telegram parity)
            for item in config.allForwardItems.dropFirst() where item.voiceCount > 0 {
                var fwdDur: TimeInterval = 0
                if let vPreview = item.attachments.first(where: { $0.type == 5 })?.preview {
                    let ps = vPreview.components(separatedBy: "::")
                    if ps.count >= 3, let d = Int(ps[1]) { fwdDur = TimeInterval(d) }
                    else if let d = Int(ps[0]) { fwdDur = TimeInterval(d) }
                }
                let clamped = max(2, min(30, fwdDur))
                let minVW = effectiveMaxBubbleWidth * 0.55
                let maxVW = effectiveMaxBubbleWidth - 36
                let voiceW = minVW + (maxVW - minVW) * CGFloat(clamped - 2) / CGFloat(30 - 2)
                bubbleW = max(bubbleW, min(voiceW, effectiveMaxBubbleWidth))
            }

            // Step 2: Compute heights with final bubbleW
            let fwdMaxTextW = bubbleW - leftPad - rightPad

            for i in 1..<config.allForwardItems.count {
                let item = config.allForwardItems[i]
                additionalFwdH += 8   // divider spacing
                additionalFwdH += 38  // forward header (two-row)

                if item.imageCount > 0 {
                    let ph = Self.collageHeight(
                        count: item.imageCount, width: bubbleW - 4,
                        maxHeight: mediaDimensions.maxHeight,
                        minHeight: mediaDimensions.minHeight
                    )
                    additionalFwdH += ph + 4 // photo + 2pt inset top/bottom
                }

                additionalFwdH += CGFloat(item.fileCount) * 52
                if item.voiceCount > 0 {
                    additionalFwdH += 4 + CGFloat(item.voiceCount) * 44
                }

                let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
                if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
                    let textSize = (cleanCaption as NSString).boundingRect(
                        with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
                        options: [.usesLineFragmentOrigin, .usesFontLeading],
                        attributes: [.font: fwdTextFont17],
                        context: nil
                    ).size
                    additionalFwdH += 2 + ceil(textSize.height) + 4
                }
            }
        }
        bubbleH += additionalFwdH

        // Date header adds height above the bubble.
        let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0

        let totalH = dateHeaderH + groupGap + bubbleH

        // Bubble X (approximate — overridden in layoutSubviews with actual cellWidth)
        let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8
        let bubbleFrame = CGRect(x: bubbleX, y: dateHeaderH + groupGap, width: bubbleW, height: bubbleH)

        // ── STEP 5: Geometry assignment ──

        // Metadata frames:
        //   checkFrame.maxX  = bubbleW - textStatusRightInset for text bubbles
        //   tsFrame.maxX     = checkFrame.minX - timeToCheckGap
        //   checkFrame.minX  = bubbleW - inset - checkW
        let metadataRightInset: CGFloat
        if messageType == .emojiOnly {
            // Telegram: pill background insets = (top:2, left:7, bottom:2, right:7)
            // Status pill right-aligned with 7pt pill padding
            metadataRightInset = 7
        } else if messageType == .photo || isForwardPhotoOnly {
            metadataRightInset = 15
        } else if isTextMessage || isForwardWithCaption || messageType == .photoWithCaption {
            metadataRightInset = config.isOutgoing
                ? textStatusLaneMetrics.textStatusRightInset
                : rightPad
        } else {
            metadataRightInset = rightPad
        }
        // Telegram: pill bottom = statusEndY + mediaStatusInsets.bottom(2).
        // Photo bottom = bubbleH - 2. Gap = 6pt → pill bottom = bubbleH - 8.
        // → statusEndY = bubbleH - 10 → metadataBottomInset = 10.
        let metadataBottomInset: CGFloat
        if messageType == .photo || isForwardPhotoOnly {
            metadataBottomInset = 10
        } else if messageType == .emojiOnly {
            // Telegram: outgoing dateOffset.y = 12pt, incoming overlaps emoji area
            metadataBottomInset = config.isOutgoing ? 2 : 12
        } else if messageType == .file && config.text.isEmpty {
            metadataBottomInset = fileOnlyTsPad
        } else {
            metadataBottomInset = bottomPad
        }
        let statusEndX = bubbleW - metadataRightInset
        let statusEndY = bubbleH - metadataBottomInset
        let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption)
            ? textStatusLaneMetrics.verticalOffset
            : 0

        let tsFrame: CGRect
        if config.isOutgoing {
            // [timestamp][timeGap][checkW] anchored right at statusEndX
            tsFrame = CGRect(
                x: statusEndX - checkW - timeToCheckGap - tsSize.width,
                y: statusEndY - tsSize.height + statusVerticalOffset,
                width: tsSize.width, height: tsSize.height
            )
        } else {
            // Incoming: [timestamp] anchored right at statusEndX
            tsFrame = CGRect(
                x: statusEndX - tsSize.width,
                y: statusEndY - tsSize.height + statusVerticalOffset,
                width: tsSize.width, height: tsSize.height
            )
        }

        let checkSentFrame: CGRect
        let checkReadFrame: CGRect
        let clockFrame: CGRect
        if hasStatusIcon {
            // Always use 17pt base for status icons (not the emoji-enlarged font).
            let baseFontSize: CGFloat = 17
            let checkImgW: CGFloat = floor(floor(baseFontSize * 11.0 / 17.0))
            let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
            let checkOffset: CGFloat = (isTextMessage || isForwardWithCaption)
                ? textStatusLaneMetrics.checkOffset
                : floor(baseFontSize * 6.0 / 17.0)
            let checkReadX = statusEndX - checkImgW
            let checkSentX = checkReadX - checkOffset
            let checkBaselineOffset: CGFloat = isTextMessage
                ? textStatusLaneMetrics.checkBaselineOffset
                : (3 - screenPixel)
            let checkY = tsFrame.minY + checkBaselineOffset
            checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH)
            checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH)
            // Telegram DateAndStatusNode:
            // clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks.
            clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11)
        } else {
            checkSentFrame = .zero
            checkReadFrame = .zero
            clockFrame = .zero
        }

        // Text frame — MUST fill bubbleW - leftPad - rightPad (the content area),
        // NOT textMeasurement.size.width. Using the measured width causes UILabel to
        // re-wrap at a narrower constraint than CoreText measured, producing different
        // line breaks ("jagged first line"). The content area is always ≥ measured width.
        var textY: CGFloat = topPad
        if config.hasReplyQuote { textY = replyH + topPad }
        if forwardHeaderH > 0 { textY = forwardHeaderH + 2 }
        if photoH > 0 {
            // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
            textY = photoH + 4 + 3 + topPad
            if config.hasReplyQuote { textY = replyH + photoH + 4 + 3 + topPad }
        }
        if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad }

        if isShortSingleLineText && timestampInline {
            // Optical centering for short one-line text without inflating bubble height.
            let maxTextY = tsFrame.minY - textStatusLaneMetrics.textToMetadataGap - textMeasurement.size.height
            if maxTextY > textY {
                textY = min(textY + 1.5, maxTextY)
            }
        }

        let textFrame: CGRect
        if messageType == .emojiOnly {
            // Telegram: emoji at innerImageInset (10pt) from edges
            textFrame = CGRect(
                x: 10, y: 10,
                width: textMeasurement.size.width,
                height: textMeasurement.size.height
            )
        } else {
            textFrame = CGRect(
                x: leftPad,
                y: textY,
                width: bubbleW - leftPad - rightPad,
                height: textMeasurement.size.height
            )
        }

        // Accessory frames (reply, photo, file, forward)
        let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: replyContainerH)
        let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: replyContainerH)
        let replyNameFrame = CGRect(x: 11, y: 3, width: bubbleW - 27, height: 17)
        let replyTextFrame = CGRect(x: 11, y: 22, width: bubbleW - 27, height: 17)

        // Telegram: 2pt inset on all four sides between photo and bubble edge
        // Forward header (41pt) must be accounted for so content doesn't overlap it
        let contentTopOffset: CGFloat = (config.hasReplyQuote ? replyH : 0) + forwardHeaderH
        let photoY: CGFloat = contentTopOffset + 2
        let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
        let fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH)

        // Two-row header: "Forwarded from" on line 1, [avatar] Name on line 2
        let fwdHeaderFrame = CGRect(x: 10, y: 5, width: bubbleW - 20, height: 14)
        let fwdAvatarFrame = CGRect(x: 10, y: 20, width: 16, height: 16)
        let fwdNameFrame = CGRect(x: 30, y: 20, width: bubbleW - 40, height: 17)

        // Multi-forward: compute per-item frame positions (items 2+).
        var additionalFwdLayouts: [ForwardItemFrame] = []
        if config.isForward && config.allForwardItems.count > 1 {
            let fwdMaxTextW = bubbleW - leftPad - rightPad

            // Where does the first item's content end?
            // Note: fileFrame.maxY includes expanded fileH (for timestamp centering).
            // For multi-forward, use actual file content height to avoid gap.
            var currentY: CGFloat
            if !config.text.isEmpty {
                currentY = textFrame.maxY + 4
            } else if photoH > 0 {
                currentY = photoFrame.maxY + 2
            } else if fileH > 0 {
                currentY = forwardHeaderH + replyH + originalFileContentH
            } else {
                currentY = forwardHeaderH
            }

            for i in 1..<config.allForwardItems.count {
                let item = config.allForwardItems[i]
                let divY = currentY
                currentY += 8

                // Two-row header: "Forwarded from" + [avatar] Name
                let hdrFrame = CGRect(x: 10, y: currentY + 5, width: bubbleW - 20, height: 14)
                let avtFrame = CGRect(x: 10, y: currentY + 20, width: 16, height: 16)
                let nmFrame = CGRect(x: 30, y: currentY + 20, width: bubbleW - 40, height: 17)
                currentY += 38

                var pFrame: CGRect = .zero
                if item.imageCount > 0 {
                    let ph = Self.collageHeight(
                        count: item.imageCount, width: bubbleW - 4,
                        maxHeight: mediaDimensions.maxHeight,
                        minHeight: mediaDimensions.minHeight
                    )
                    pFrame = CGRect(x: 2, y: currentY + 2, width: bubbleW - 4, height: ph)
                    currentY += ph + 4
                }

                currentY += CGFloat(item.fileCount) * 52

                var vFrame: CGRect = .zero
                if item.voiceCount > 0 {
                    currentY += 4 // spacing between header and voice
                    vFrame = CGRect(x: 6, y: currentY, width: bubbleW - 12, height: 38)
                    currentY += 44 // 38pt voice + 6pt pad
                }

                var tFrame: CGRect = .zero
                let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
                if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
                    let textSize = (cleanCaption as NSString).boundingRect(
                        with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
                        options: [.usesLineFragmentOrigin, .usesFontLeading],
                        attributes: [.font: fwdTextFont17],
                        context: nil
                    ).size
                    tFrame = CGRect(x: leftPad, y: currentY + 2,
                                    width: fwdMaxTextW, height: ceil(textSize.height))
                    currentY += 2 + ceil(textSize.height) + 4
                }

                additionalFwdLayouts.append(ForwardItemFrame(
                    dividerY: divY,
                    headerFrame: hdrFrame,
                    avatarFrame: avtFrame,
                    nameFrame: nmFrame,
                    textFrame: tFrame,
                    photoFrame: pFrame,
                    voiceFrame: vFrame,
                    info: item
                ))
            }
        }

        let layout = MessageCellLayout(
            totalHeight: totalH,
            groupGap: groupGap,
            isOutgoing: config.isOutgoing,
            position: config.position,
            messageType: messageType,
            bubbleFrame: bubbleFrame,
            bubbleSize: CGSize(width: bubbleW, height: bubbleH),
            mergeType: mergeType,
            hasTail: hasTail,
            textFrame: textFrame,
            textSize: textMeasurement.size,
            timestampInline: timestampInline,
            timestampFrame: tsFrame,
            checkSentFrame: checkSentFrame,
            checkReadFrame: checkReadFrame,
            clockFrame: clockFrame,
            showsDeliveryFailedIndicator: isOutgoingFailed,
            deliveryFailedInset: deliveryFailedInset,
            hasReplyQuote: config.hasReplyQuote,
            replyContainerFrame: replyContainerFrame,
            replyBarFrame: replyBarFrame,
            replyNameFrame: replyNameFrame,
            replyTextFrame: replyTextFrame,
            hasPhoto: config.imageCount > 0 || (config.isForward && config.forwardImageCount > 0),
            isForwardPhotoOnly: isForwardPhotoOnly,
            photoFrame: photoFrame,
            photoCollageHeight: photoH,
            hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 || config.voiceCount > 0
                || (config.isForward && (config.forwardFileCount > 0 || config.forwardVoiceCount > 0)),
            fileFrame: fileFrame,
            hasGroupInvite: config.groupInviteCount > 0,
            groupInviteTitle: config.groupInviteTitle,
            groupInviteGroupId: config.groupInviteGroupId,
            isForward: config.isForward,
            forwardHeaderFrame: fwdHeaderFrame,
            forwardAvatarFrame: fwdAvatarFrame,
            forwardNameFrame: fwdNameFrame,
            forwardChachaKeyPlain: config.forwardChachaKeyPlain,
            forwardAttachments: config.forwardAttachments,
            additionalForwardItems: additionalFwdLayouts,
            showsDateHeader: config.showsDateHeader,
            dateHeaderText: config.dateHeaderText,
            dateHeaderHeight: dateHeaderH,
            showsSenderName: false,
            showsSenderAvatar: false,
            senderName: "",
            senderKey: "",
            isGroupAdmin: false
        )
        return (layout, cachedTextLayout)
    }

    // MARK: - Collage Height (Thread-Safe)

    /// Photo collage height — same formulas as PhotoCollageView.swift.
    private static func mediaDimensions(for screenWidth: CGFloat) -> MediaDimensions {
        if screenWidth > 680 {
            return MediaDimensions(maxWidth: 440, maxHeight: 440, minWidth: 170, minHeight: 74)
        }
        return MediaDimensions(maxWidth: 300, maxHeight: 380, minWidth: 170, minHeight: 74)
    }

    private static func collageHeight(
        count: Int,
        width: CGFloat,
        maxHeight: CGFloat,
        minHeight: CGFloat
    ) -> CGFloat {
        guard count > 0 else { return 0 }
        if count == 1 { return min(max(width * 0.93, minHeight), maxHeight) }
        if count == 2 {
            let cellW = (width - 2) / 2
            return min(max(cellW * 1.28, minHeight), maxHeight)
        }
        if count == 3 {
            let leftW = width * 0.66
            return min(max(leftW * 1.16, minHeight), maxHeight)
        }
        if count == 4 {
            let cellW = (width - 2) / 2
            let cellH = min(max(cellW * 0.85, minHeight / 2), maxHeight / 2)
            return min(max(cellH * 2 + 2, minHeight), maxHeight)
        }
        // 5+
        let topH = min(width / 2 * 0.85, 176)
        let botH = min(width / 3 * 0.85, 144)
        return min(max(topH + 2 + botH, minHeight), maxHeight)
    }

    // MARK: - Text Measurement (Thread-Safe)

    /// Simple text measurement — safe to call from any thread.
    private static func measureText(_ text: String, maxWidth: CGFloat, font: UIFont) -> CGSize {
        guard !text.isEmpty else { return .zero }
        let attrs: [NSAttributedString.Key: Any] = [.font: font]
        let attrStr = NSAttributedString(string: text, attributes: attrs)
        let rect = attrStr.boundingRect(
            with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
            options: .usesLineFragmentOrigin,
            context: nil
        )
        return CGSize(width: ceil(rect.width), height: ceil(rect.height))
    }

    /// Detailed text measurement result (Telegram pattern).
    struct TextMeasurement: Sendable {
        let size: CGSize              // Bounding box (max line width, total height)
        let trailingLineWidth: CGFloat // Width of the LAST line only
    }

    /// Telegram-exact text measurement using CTTypesetter + manual line breaking.
    /// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache.
    /// This eliminates the double CoreText computation (measure + render).
    private static func measureTextDetailedWithLayout(
        _ text: String, maxWidth: CGFloat, font: UIFont, textColor: UIColor = .white, linkColor: UIColor = CoreTextTextLayout.linkColor
    ) -> (TextMeasurement, CoreTextTextLayout) {
        let layout = CoreTextTextLayout.calculate(
            text: text, maxWidth: maxWidth, font: font, textColor: textColor, linkColor: linkColor
        )
        let measurement = TextMeasurement(
            size: layout.size,
            trailingLineWidth: layout.lastLineWidth
        )
        return (measurement, layout)
    }

    // MARK: - Garbage Text Detection (Thread-Safe)

    /// Returns true if text is garbage (U+FFFD, control chars) or encrypted ciphertext.
    static func isGarbageOrEncrypted(_ text: String) -> Bool {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty { return true }

        // Check for U+FFFD / control characters
        let validChars = trimmed.unicodeScalars.filter { scalar in
            scalar.value != 0xFFFD &&
            scalar.value > 0x1F &&
            scalar.value != 0x7F
        }
        if validChars.isEmpty { return true }

        // Chunked format
        if trimmed.hasPrefix("CHNK:") { return true }

        // Check for encrypted ciphertext: Base64:Base64 pattern
        let parts = trimmed.components(separatedBy: ":")
        if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 {
            let base64Chars = CharacterSet.alphanumerics
                .union(CharacterSet(charactersIn: "+/="))
            let allBase64 = parts.allSatisfy { part in
                !part.isEmpty && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
            }
            if allBase64 { return true }
        }

        return false
    }

    // MARK: - Bubble Grouping (Telegram-like)

    private enum BubbleGroupingKind {
        case text
        case media
        case file
        case forward
    }

    /// Conservative grouping window to keep groups visually close to Telegram behavior.
    /// Messages far apart in time should split into separate bubble groups.
    private static let mergeTimeWindowMs: Int64 = 10 * 60 * 1000

    private static func groupingKind(for message: ChatMessage, displayText: String) -> BubbleGroupingKind {
        let hasImage = message.attachments.contains { $0.type == .image }
        if hasImage {
            return .media
        }
        let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar || $0.type == .call || $0.type == .voice }
        if hasFileLike {
            return .file
        }
        let hasReplyAttachment = message.attachments.contains { $0.type == .messages }
        if hasReplyAttachment && displayText.isEmpty {
            return .forward
        }
        return .text
    }

    private static func isFromMe(_ message: ChatMessage, currentPublicKey: String) -> Bool {
        message.fromPublicKey == currentPublicKey
    }

    private static func timestampDeltaMs(_ lhs: Int64, _ rhs: Int64) -> Int64 {
        lhs >= rhs ? (lhs - rhs) : (rhs - lhs)
    }

    private static func shouldMerge(
        current message: ChatMessage,
        currentDisplayText: String,
        with neighbor: ChatMessage,
        neighborDisplayText: String,
        currentPublicKey: String,
        calendar: Calendar = .current
    ) -> Bool {
        // Telegram-like: only same direction (incoming with incoming / outgoing with outgoing)
        guard isFromMe(message, currentPublicKey: currentPublicKey) == isFromMe(neighbor, currentPublicKey: currentPublicKey) else {
            return false
        }

        // Group chats: different senders NEVER merge (Telegram parity).
        if message.fromPublicKey != neighbor.fromPublicKey {
            return false
        }

        // Keep failed messages visually isolated (external failed indicator behavior).
        if message.deliveryStatus == .error || neighbor.deliveryStatus == .error {
            return false
        }

        // Break groups at day boundaries (date separator will appear between them).
        let msgDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
        let neighborDate = Date(timeIntervalSince1970: TimeInterval(neighbor.timestamp) / 1000)
        if !calendar.isDate(msgDate, inSameDayAs: neighborDate) {
            return false
        }

        // Long gaps should split groups.
        if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
            return false
        }

        let currentKind = groupingKind(for: message, displayText: currentDisplayText)
        let neighborKind = groupingKind(for: neighbor, displayText: neighborDisplayText)

        // Forwards never merge (Telegram parity). All other kinds merge freely.
        if currentKind == .forward || neighborKind == .forward {
            return false
        }
        return true
    }
}

// MARK: - Batch Calculation (Background Thread)

extension MessageCellLayout {

    // Static DateFormatters — avoid creating 3 instances per batchCalculate call.
    // DateFormatter init + configuration ~0.5ms each × 3 × every call = significant in hot path.
    // nonisolated(unsafe) is safe: LayoutEngine actor serializes all batchCalculate calls,
    // and the sync path runs on main thread (single-threaded).
    nonisolated(unsafe) private static let timestampFmt: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "HH:mm"
        f.locale = .autoupdatingCurrent
        f.timeZone = .autoupdatingCurrent
        return f
    }()

    nonisolated(unsafe) private static let sameYearHeaderFmt: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "MMMM d"
        f.locale = .autoupdatingCurrent
        return f
    }()

    nonisolated(unsafe) private static let diffYearHeaderFmt: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "MMMM d, yyyy"
        f.locale = .autoupdatingCurrent
        return f
    }()

    /// Pre-calculate layouts for all messages on background queue.
    /// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering.
    /// Telegram equivalent: ListView calls asyncLayout() on background.
    static func batchCalculate(
        messages: [ChatMessage],
        maxBubbleWidth: CGFloat,
        currentPublicKey: String,
        opponentPublicKey: String,
        opponentTitle: String,
        isGroupChat: Bool = false,
        groupAdminKey: String = "",
        isDarkMode: Bool = true,
        dirtyIds: Set<String>? = nil,
        existingLayouts: [String: MessageCellLayout]? = nil,
        existingTextLayouts: [String: CoreTextTextLayout]? = nil
    ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
        var result: [String: MessageCellLayout] = existingLayouts ?? [:]
        var textResult: [String: CoreTextTextLayout] = existingTextLayouts ?? [:]

        let calendar = Calendar.current
        let now = Date()

        for (index, message) in messages.enumerated() {
            // Incremental: skip messages not in dirty set — period.
            // Previous logic also checked `result[id] != nil` which caused
            // ALL messages without cached layouts to be computed (1225ms freeze
            // during pagination when 150+ messages lacked layouts).
            if let dirtyIds, !dirtyIds.contains(message.id) {
                continue
            }
            let isOutgoing = message.fromPublicKey == currentPublicKey
            let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
            let timestampText = Self.timestampFmt.string(from: messageDate)

            // Date header: show on first message of each calendar day
            let showsDateHeader: Bool
            if index == 0 {
                showsDateHeader = true
            } else {
                let prevDate = Date(timeIntervalSince1970: TimeInterval(messages[index - 1].timestamp) / 1000)
                showsDateHeader = !calendar.isDate(messageDate, inSameDayAs: prevDate)
            }
            let dateHeaderText = showsDateHeader
                ? Self.formatDateHeader(messageDate, now: now, calendar: calendar,
                                        sameYearFormatter: Self.sameYearHeaderFmt,
                                        diffYearFormatter: Self.diffYearHeaderFmt)
                : ""

            // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
            // Convert emoji shortcodes (:emoji_1f631: → 😱) — Android/Desktop send shortcodes.
            let rawText = isGarbageOrEncrypted(message.text) ? "" : message.text
            let displayText = EmojiParser.replaceShortcodes(in: rawText)

            // Calculate position (Telegram-like grouping rules)
            let position: BubblePosition = {
                let hasPrev: Bool = {
                    guard index > 0 else { return false }
                    let prev = messages[index - 1]
                    let prevDisplayText = isGarbageOrEncrypted(prev.text) ? "" : prev.text
                    return shouldMerge(
                        current: message,
                        currentDisplayText: displayText,
                        with: prev,
                        neighborDisplayText: prevDisplayText,
                        currentPublicKey: currentPublicKey,
                        calendar: calendar
                    )
                }()
                let hasNext: Bool = {
                    guard index + 1 < messages.count else { return false }
                    let next = messages[index + 1]
                    let nextDisplayText = isGarbageOrEncrypted(next.text) ? "" : next.text
                    return shouldMerge(
                        current: message,
                        currentDisplayText: displayText,
                        with: next,
                        neighborDisplayText: nextDisplayText,
                        currentPublicKey: currentPublicKey,
                        calendar: calendar
                    )
                }()
                switch (hasPrev, hasNext) {
                case (false, false): return .single
                case (false, true): return .top
                case (true, true): return .mid
                case (true, false): return .bottom
                }
            }()

            // Classify
            let images = message.attachments.filter { $0.type == .image }
            let files = message.attachments.filter { $0.type == .file }
            let avatars = message.attachments.filter { $0.type == .avatar }
            let calls = message.attachments.filter { $0.type == .call }
            let voices = message.attachments.filter { $0.type == .voice }
            let voiceDuration: TimeInterval = {
                guard let preview = voices.first?.preview else { return 0 }
                let parts = preview.components(separatedBy: "::")
                if parts.count >= 3, let dur = Int(parts[1]) { return TimeInterval(dur) }
                if parts.count >= 2, let dur = Int(parts[0]) { return TimeInterval(dur) }
                if let dur = Int(parts[0]) { return TimeInterval(dur) }
                return 0
            }()
            let hasReply = message.attachments.contains { $0.type == .messages }
            let isForward = hasReply && displayText.isEmpty

            // Parse forward content from .messages blob
            var forwardCaption: String?
            var forwardInnerImageCount = 0
            var forwardInnerFileCount = 0
            var forwardInnerVoiceCount = 0
            var forwardVoiceDuration: TimeInterval = 0
            var forwardSenderName = ""
            var forwardChachaKeyPlain = ""
            var forwardAttachments: [ReplyAttachmentData] = []
            var allForwardItems: [ForwardItemInfo] = []
            if isForward,
               let att = message.attachments.first(where: { $0.type == .messages }),
               let data = att.blob.data(using: .utf8),
               let replies = try? forwardDecoder.decode([ReplyMessageData].self, from: data),
               let first = replies.first {
                let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines)
                if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) {
                    forwardCaption = EmojiParser.replaceShortcodes(in: fwdText)
                }
                forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
                forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count
                forwardInnerVoiceCount = first.attachments.filter { $0.type == 5 }.count
                // Suppress caption for voice-only forwards (Telegram parity)
                if forwardInnerVoiceCount > 0 && forwardInnerImageCount == 0 && forwardInnerFileCount == 0 {
                    forwardCaption = nil
                }
                // Parse forward voice duration for bubble width scaling
                if forwardInnerVoiceCount > 0, voiceDuration == 0,
                   let fwdVoicePreview = first.attachments.first(where: { $0.type == 5 })?.preview {
                    let parts = fwdVoicePreview.components(separatedBy: "::")
                    if parts.count >= 3, let dur = Int(parts[1]) { forwardVoiceDuration = TimeInterval(dur) }
                    else if parts.count >= 2, let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) }
                    else if let dur = Int(parts[0]) { forwardVoiceDuration = TimeInterval(dur) }
                }
                forwardChachaKeyPlain = first.chacha_key_plain
                forwardAttachments = first.attachments
                // Resolve forward sender name for dynamic bubble width
                let senderKey = first.publicKey
                if senderKey == currentPublicKey {
                    forwardSenderName = "You"
                } else if senderKey == opponentPublicKey {
                    forwardSenderName = opponentTitle.isEmpty ? String(senderKey.prefix(8)) + "…" : opponentTitle
                } else {
                    forwardSenderName = DialogRepository.shared.dialogs[senderKey]?.opponentTitle ?? String(senderKey.prefix(8)) + "…"
                }

                // Build ForwardItemInfo for ALL items (multi-forward Android parity)
                for reply in replies {
                    let rKey = reply.publicKey
                    let rName: String
                    if rKey == currentPublicKey {
                        rName = "You"
                    } else if rKey == opponentPublicKey {
                        rName = opponentTitle.isEmpty ? String(rKey.prefix(8)) + "…" : opponentTitle
                    } else {
                        rName = DialogRepository.shared.dialogs[rKey]?.opponentTitle ?? String(rKey.prefix(8)) + "…"
                    }
                    let rText = reply.message.trimmingCharacters(in: .whitespacesAndNewlines)
                    var rCaption = (!rText.isEmpty && !isGarbageOrEncrypted(rText))
                        ? EmojiParser.replaceShortcodes(in: rText) : ""
                    let rVoiceCount = reply.attachments.filter { $0.type == 5 }.count
                    let rImageCount = reply.attachments.filter { $0.type == 0 }.count
                    let rFileCount = reply.attachments.filter { $0.type == 2 }.count
                    // Suppress caption for voice-only forwards (Telegram parity)
                    if rVoiceCount > 0 && rImageCount == 0 && rFileCount == 0 {
                        rCaption = ""
                    }
                    allForwardItems.append(ForwardItemInfo(
                        senderName: rName,
                        senderKey: rKey,
                        caption: rCaption,
                        chachaKeyPlain: reply.chacha_key_plain,
                        attachments: reply.attachments,
                        imageCount: reply.attachments.filter { $0.type == 0 }.count,
                        fileCount: reply.attachments.filter { $0.type == 2 }.count,
                        voiceCount: reply.attachments.filter { $0.type == 5 }.count
                    ))
                }
            }

            // Parse image dimensions from preview field (format: "tag::blurhash::WxH")
            let imageDims: CGSize? = images.first.flatMap {
                AttachmentPreviewCodec.imageDimensions(from: $0.preview)
            }

            // Detect group invite (text-only messages, or forwarded caption with #group:)
            let groupInviteText: String? = {
                if !isForward && displayText.hasPrefix("#group:")
                    && images.isEmpty && files.isEmpty && avatars.isEmpty && calls.isEmpty {
                    return displayText
                }
                if isForward, let caption = forwardCaption, caption.hasPrefix("#group:") {
                    return caption
                }
                return nil
            }()
            var groupInviteTitle = ""
            var groupInviteGroupId = ""
            if let inviteText = groupInviteText, let parsed = GroupRepository.parseInviteStringPure(inviteText) {
                groupInviteTitle = parsed.title
                groupInviteGroupId = parsed.groupId
            }
            let groupInviteCount = (!groupInviteTitle.isEmpty) ? 1 : 0

            let config = Config(
                maxBubbleWidth: maxBubbleWidth,
                isOutgoing: isOutgoing,
                isDarkMode: isDarkMode,
                position: position,
                deliveryStatus: message.deliveryStatus,
                text: groupInviteCount > 0 ? "" : (isForward ? (forwardCaption ?? "") : displayText),
                timestampText: timestampText,
                hasReplyQuote: hasReply && !displayText.isEmpty,
                replyName: nil,
                replyText: nil,
                imageCount: images.count,
                imageDimensions: imageDims,
                fileCount: files.count,
                avatarCount: avatars.count,
                callCount: calls.count,
                voiceCount: voices.count,
                voiceDuration: voiceDuration > 0 ? voiceDuration : forwardVoiceDuration,
                isForward: isForward,
                forwardImageCount: forwardInnerImageCount,
                forwardFileCount: forwardInnerFileCount,
                forwardVoiceCount: forwardInnerVoiceCount,
                forwardCaption: forwardCaption,
                showsDateHeader: showsDateHeader,
                dateHeaderText: dateHeaderText,
                groupInviteCount: groupInviteCount,
                groupInviteTitle: groupInviteTitle,
                groupInviteGroupId: groupInviteGroupId,
                forwardSenderName: forwardSenderName,
                forwardChachaKeyPlain: forwardChachaKeyPlain,
                forwardAttachments: forwardAttachments,
                allForwardItems: allForwardItems,
                senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "",
                isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey)
            )

            var (layout, textLayout) = calculate(config: config)

            // Group sender info: name on first in run, avatar on last in run.
            if isGroupChat && !isOutgoing {
                layout.senderKey = message.fromPublicKey
                let resolvedName = DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle
                    ?? String(message.fromPublicKey.prefix(8))
                layout.senderName = resolvedName
                // .top or .single = first message in sender run → show name
                layout.showsSenderName = (position == .top || position == .single)
                // .bottom or .single = last message in sender run → show avatar
                layout.showsSenderAvatar = (position == .bottom || position == .single)
                // Desktop parity: members[0] is the group owner/admin.
                if !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey {
                    layout.isGroupAdmin = true
                }
                // Add height for sender name so cells don't overlap.
                if layout.showsSenderName {
                    layout.totalHeight += 20
                }
            }

            result[message.id] = layout
            if let textLayout { textResult[message.id] = textLayout }
        }

        return (result, textResult)
    }
}

// MARK: - Date Header Formatting (Thread-Safe)

extension MessageCellLayout {
    /// Telegram-style date header text: Today / Yesterday / March 8 / March 8, 2025
    static func formatDateHeader(
        _ date: Date,
        now: Date,
        calendar: Calendar,
        sameYearFormatter: DateFormatter,
        diffYearFormatter: DateFormatter
    ) -> String {
        if calendar.isDateInToday(date) {
            return "Today"
        }
        if calendar.isDateInYesterday(date) {
            return "Yesterday"
        }
        if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
            return sameYearFormatter.string(from: date)
        }
        return diffYearFormatter.string(from: date)
    }
}

// MARK: - Geometry Helpers

private extension CGSize {
    /// Scale to fit inside `boundingSize` preserving aspect ratio (Telegram-exact sizing).
    func aspectFitted(_ boundingSize: CGSize) -> CGSize {
        guard width > 0, height > 0 else { return boundingSize }
        let scale = min(boundingSize.width / width, boundingSize.height / height)
        return CGSize(width: floor(width * scale), height: floor(height * scale))
    }
}
