import UIKit
import SwiftUI

/// Universal pure UIKit message cell — handles ALL message types.
/// Rosetta equivalent of Telegram's ChatMessageBubbleItemNode.
///
/// Architecture (Telegram pattern):
///   1. `MessageCellLayout.calculate()` — runs on ANY thread (background-safe)
///   2. `NativeMessageCell.apply(layout:)` — runs on main thread, just sets frames
///   3. No SwiftUI, no UIHostingConfiguration, no self-sizing
///
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
/// Base class: UIView (not UICollectionViewCell) — works in both RosettaListView and UICollectionView wrapper.
final class NativeMessageCell: UIView {

    /// Content container. UICollectionViewCell provides this automatically;
    /// as a plain UIView we create our own for API compatibility.
    let contentView = UIView()

    // MARK: - Constants

    private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
    private static let incomingColor = UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1)  // #2B2B2E
            : UIColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1)  // #F2F2F7
    }
    private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
    private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
    private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
    private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
    private static let forwardLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
    private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
    private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular)
    private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
    private static let bubbleMetrics = BubbleMetrics.telegram()
    private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets
    private static let sendingClockAnimationKey = "clockFrameAnimation"

    // Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics)
    private static let outgoingCheckColor = UIColor.white
    private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
    private static let mediaMetaColor = UIColor.white
    private static let readCheckColor = UIColor(red: 0xA4/255, green: 0xE2/255, blue: 0xFF/255, alpha: 1) // #A4E2FF
    private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
    private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
    private static let fullCheckReadImage = StatusIconRenderer.makeCheckImage(partial: false, color: readCheckColor)
    private static let partialCheckReadImage = StatusIconRenderer.makeCheckImage(partial: true, color: readCheckColor)
    private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor)
    private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor)
    private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor)
    private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor)
    private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor)
    private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
    private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
    private static let maxVisiblePhotoTiles = 5
    // Telegram-exact reply arrow rendered from SVG path (matches SwiftUI TelegramIconPath.replyArrow).
    // Rendered at DISPLAY size (20×20) so UIImageView never upscales the raster.
    private static let telegramReplyArrowImage: UIImage = {
        let viewBox = CGSize(width: 16, height: 13)
        let canvasSize = CGSize(width: 20, height: 20) // match replyIconView frame
        let scale = UIScreen.main.scale
        UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
        guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
        var parser = SVGPathParser(pathData: TelegramIconPath.replyArrow)
        let cgPath = parser.parse()
        // Aspect-fit path into canvas, centered
        let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
        let scaledW = viewBox.width * fitScale
        let scaledH = viewBox.height * fitScale
        ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
        ctx.scaleBy(x: fitScale, y: fitScale)
        ctx.addPath(cgPath)
        ctx.setFillColor(UIColor.white.cgColor)
        ctx.fillPath()
        let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
        UIGraphicsEndImageContext()
        return image.withRenderingMode(.alwaysTemplate)
    }()
    /// Gold admin badge (Desktop parity: IconArrowBadgeDownFilled, gold #FFD700).
    private static let goldAdminBadgeImage: UIImage = {
        let viewBox = CGSize(width: 24, height: 24)
        let canvasSize = CGSize(width: 16, height: 16)
        let scale = UIScreen.main.scale
        UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
        guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
        var parser = SVGPathParser(pathData: TablerIconPath.arrowBadgeDownFilled)
        let cgPath = parser.parse()
        let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
        let scaledW = viewBox.width * fitScale
        let scaledH = viewBox.height * fitScale
        ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
        ctx.scaleBy(x: fitScale, y: fitScale)
        ctx.addPath(cgPath)
        ctx.setFillColor(UIColor(red: 1.0, green: 0.843, blue: 0.0, alpha: 1.0).cgColor)
        ctx.fillPath()
        let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
        UIGraphicsEndImageContext()
        return image
    }()

    // Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail).
    // `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
    private(set) static var bubbleImages = BubbleImageFactory.generate(
        outgoingColor: outgoingColor,
        incomingColor: incomingColor
    )
    private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified

    /// Exposed for skeleton loading — same stretchable images used in real cells.
    static var currentBubbleImages: BubbleImageFactory.ImageSet? { bubbleImages }

    /// Regenerate cached bubble images after theme change.
    /// Must be called on main thread. `performAsCurrent` ensures dynamic
    /// `incomingColor` resolves with the correct light/dark traits.
    static func regenerateBubbleImages(with traitCollection: UITraitCollection) {
        traitCollection.performAsCurrent {
            bubbleImages = BubbleImageFactory.generate(
                outgoingColor: outgoingColor,
                incomingColor: incomingColor
            )
        }
        bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection)
    }

    /// Ensure bubble image cache matches the current interface style.
    /// Covers cases where the theme was changed while chat screen was not mounted.
    static func ensureBubbleImages(for traitCollection: UITraitCollection) {
        let style = normalizedInterfaceStyle(from: traitCollection)
        guard bubbleImagesStyle != style else { return }
        regenerateBubbleImages(with: traitCollection)
    }

    private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle {
        switch traitCollection.userInterfaceStyle {
        case .dark, .light:
            return traitCollection.userInterfaceStyle
        default:
            let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "system"
            return themeMode == "light" ? .light : .dark
        }
    }
    private static let blurHashCache: NSCache<NSString, UIImage> = {
        let cache = NSCache<NSString, UIImage>()
        cache.countLimit = 200
        return cache
    }()

    // MARK: - Subviews (always present, hidden when unused)

    // Date separator header (Telegram-style centered pill)
    private let dateHeaderContainer = UIView()
    private let dateHeaderLabel = UILabel()

    // Bubble — uses Telegram-exact stretchable image for the fill (raster tail)
    // + CAShapeLayer for shadow path (approximate, vector)
    private let bubbleView = UIView()
    private let bubbleImageView = UIImageView()
    private let bubbleLayer = CAShapeLayer() // shadow only, fillColor = clear
    private let bubbleOutlineLayer = CAShapeLayer()

    // Text (CoreText rendering — matches Telegram's CTTypesetter + CTRunDraw pipeline)
    private let textLabel = CoreTextLabel()

    // Timestamp + delivery
    private let statusBackgroundView = UIView()
    private let timestampLabel = UILabel()
    private let checkSentView = UIImageView()
    private let checkReadView = UIImageView()
    private let clockFrameView = UIImageView()
    private let clockMinView = UIImageView()

    // Reply quote
    private let replyContainer = UIView()
    private let replyBar = UIView()
    private let replyNameLabel = UILabel()
    private let replyTextLabel = UILabel()
    private var replyMessageId: String?

    // Photo collage (up to 5 tiles)
    private let photoContainer = UIView()
    private var photoTileImageViews: [UIImageView] = []
    private var photoTilePlaceholderViews: [UIView] = []
    private var photoTileProgressRings: [VoiceDownloadRingView] = []
    private var photoTileErrorViews: [UIImageView] = []
    private var photoTileDownloadArrows: [UIView] = []
    private var photoTileButtons: [UIButton] = []
    private let photoUploadingOverlayView = UIView()
    private let photoUploadingRing = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
    private let photoOverflowOverlayView = UIView()
    private let photoOverflowLabel = UILabel()

    // File / Call / Avatar (shared container)
    private let fileContainer = UIView()
    private let fileIconView = UIView()
    private let fileIconSymbolView = UIImageView()
    private let fileProgressRing = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
    private let fileNameLabel = UILabel()
    private let fileSizeLabel = UILabel()
    private var fileDownloadTask: Task<Void, Never>?
    private var fileUploadProgressObserver: NSObjectProtocol?

    // Call-specific
    private let callArrowView = UIImageView()
    private let callBackButton = UIButton(type: .custom)

    // Voice message
    private let voiceView = MessageVoiceView()
    private var voiceBlobView: VoiceBlobView?
    private var activeVoiceDownloadTask: Task<Void, Never>?

    // Avatar-specific
    private let avatarImageView = UIImageView()

    // Forward header (item 0)
    private let forwardLabel = UILabel()
    private let forwardAvatarView = UIView()
    private let forwardAvatarInitialLabel = UILabel()
    private let forwardAvatarImageView = UIImageView()
    private let forwardNameLabel = UILabel()

    // Additional forward items (items 1+, Android parity)
    private var additionalForwardViews: [ForwardItemSubview] = []

    // Group sender info (Telegram parity)
    private let senderNameLabel = UILabel()
    private let senderAdminIconView = UIImageView()
    private let senderAvatarContainer = UIView()
    private let senderAvatarImageView = UIImageView()
    private let senderAvatarInitialLabel = UILabel()

    // Group Invite Card
    private let groupInviteContainer = UIView()
    private let groupInviteIconBg = UIView()
    private let groupInviteInitialLabel = UILabel()
    private let groupInviteTitleLabel = UILabel()
    private let groupInviteStatusLabel = UILabel()
    private let groupInviteButton = UIButton(type: .custom)
    private var groupInviteString: String?
    private var currentInviteStatus: InviteCardStatus = .notJoined
    private var inviteStatusTask: Task<Void, Never>?

    enum InviteCardStatus {
        case notJoined, joined, invalid, banned
    }

    // Service message (centered pill — Telegram parity for $a= messages)
    private let serviceLabel = UILabel()
    private let servicePillView = UIView()

    // Highlight overlay (scroll-to-message flash)
    private let highlightOverlay = UIView()

    // Multi-select (Telegram parity: 28×28 checkbox, 42pt content shift)
    private let selectionCheckContainer = UIView()
    private let selectionCheckBorder = CAShapeLayer()
    private let selectionCheckFill = CAShapeLayer()
    private let selectionCheckmarkLayer = CAShapeLayer()

    // Swipe-to-reply
    private let replyCircleView = UIView()
    private let replyIconView = UIImageView()
    private var hasTriggeredSwipeHaptic = false
    private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy)
    /// Global X of the first touch — reject if near left screen edge (back gesture zone).
    private var swipeStartX: CGFloat?
    private let deliveryFailedButton = UIButton(type: .custom)

    // MARK: - State

    private var message: ChatMessage?
    private var actions: MessageCellActions?
    private(set) var currentLayout: MessageCellLayout?

    /// Exposed for voice playback live updates (NativeMessageList matches against VoiceMessagePlayer.currentMessageId).
    var currentMessageId: String? { message?.id }

    /// Check if any forward item voice view has a matching playback ID.
    func hasForwardVoiceWithPlaybackId(_ playbackId: String?) -> Bool {
        guard let playbackId else { return false }
        return additionalForwardViews.contains { !$0.isHidden && $0.voicePlaybackMessageId == playbackId }
    }
    var isSavedMessages = false
    var isSystemAccount = false
    /// When true, the inline date header pill is hidden (floating sticky one covers it).
    var isInlineDateHeaderHidden = false
    private var isDeliveryFailedVisible = false
    private var wasSentCheckVisible = false
    private var wasReadCheckVisible = false
    private var photoAttachments: [MessageAttachment] = []
    private var totalPhotoAttachmentCount = 0
    private var photoLoadTasks: [String: Task<Void, Never>] = [:]
    private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
    private var photoBlurHashTasks: [String: Task<Void, Never>] = [:]
    private var downloadingAttachmentIds: Set<String> = []
    private var failedAttachmentIds: Set<String> = []

    // Multi-select state
    private(set) var isInSelectionMode = false
    private(set) var isMessageSelected = false
    private var selectionOffset: CGFloat = 0  // 0 or 42 (Telegram: 42pt shift)

    // MARK: - Init

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.frame = bounds
        contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        addSubview(contentView)
        setupViews()
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError() }

    // MARK: - Setup

    private func setupViews() {
        contentView.backgroundColor = .clear
        backgroundColor = .clear
        // Allow reply swipe icon to extend beyond cell bounds
        contentView.clipsToBounds = false
        clipsToBounds = false
        contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip

        // Date header pill (Telegram-style centered date separator with glass)
        dateHeaderContainer.clipsToBounds = false
        dateHeaderContainer.isHidden = true
        let inlineGlass = TelegramGlassUIView(frame: .zero)
        inlineGlass.isUserInteractionEnabled = false
        inlineGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        dateHeaderContainer.addSubview(inlineGlass)
        dateHeaderLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium)
        dateHeaderLabel.textColor = .label
        dateHeaderLabel.textAlignment = .center
        dateHeaderContainer.addSubview(dateHeaderLabel)
        contentView.addSubview(dateHeaderContainer)

        // Service message pill (centered, no bubble — like Telegram "Group created")
        servicePillView.backgroundColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(white: 1.0, alpha: 0.08)
            : UIColor(white: 0.0, alpha: 0.08)
        }
        servicePillView.layer.cornerRadius = 12
        servicePillView.layer.cornerCurve = .continuous
        servicePillView.isHidden = true
        contentView.addSubview(servicePillView)

        serviceLabel.font = .systemFont(ofSize: 14, weight: .regular)
        serviceLabel.textColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor.white.withAlphaComponent(0.6)
            : UIColor.black.withAlphaComponent(0.5)
        }
        serviceLabel.textAlignment = .center
        servicePillView.addSubview(serviceLabel)

        // Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top
        bubbleLayer.fillColor = UIColor.clear.cgColor
        bubbleLayer.fillRule = .nonZero
        bubbleLayer.shadowColor = UIColor.black.cgColor
        bubbleLayer.shadowOpacity = 0
        bubbleLayer.shadowRadius = 0
        bubbleLayer.shadowOffset = .zero
        bubbleView.layer.insertSublayer(bubbleLayer, at: 0)
        bubbleOutlineLayer.fillColor = UIColor.clear.cgColor
        bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1)
        bubbleView.layer.insertSublayer(bubbleOutlineLayer, above: bubbleLayer)
        // Raster bubble image (Telegram-exact tail) — added last so it renders above outline
        bubbleImageView.contentMode = .scaleToFill
        bubbleView.addSubview(bubbleImageView)
        contentView.addSubview(bubbleView)

        // Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout)
        bubbleView.addSubview(textLabel)

        // Timestamp
        // Telegram: solid pill UIColor(white:0, alpha:0.3), diameter 18 → radius 9
        statusBackgroundView.backgroundColor = UIColor(white: 0.0, alpha: 0.3)
        statusBackgroundView.layer.cornerRadius = 9
        statusBackgroundView.layer.cornerCurve = .continuous
        statusBackgroundView.clipsToBounds = true
        statusBackgroundView.isHidden = true
        bubbleView.addSubview(statusBackgroundView)

        timestampLabel.font = Self.timestampFont
        bubbleView.addSubview(timestampLabel)

        // Checkmarks (Telegram two-node overlay: sent ✓ + read /)
        checkSentView.contentMode = .scaleAspectFit
        bubbleView.addSubview(checkSentView)
        checkReadView.contentMode = .scaleAspectFit
        bubbleView.addSubview(checkReadView)
        clockFrameView.contentMode = .scaleAspectFit
        clockMinView.contentMode = .scaleAspectFit
        bubbleView.addSubview(clockFrameView)
        bubbleView.addSubview(clockMinView)

        // Reply quote — Telegram parity: accent-tinted bg + 4pt radius bar
        replyContainer.layer.cornerRadius = 4.0
        replyContainer.clipsToBounds = true
        replyBar.layer.cornerRadius = 4.0
        replyContainer.addSubview(replyBar)
        replyNameLabel.font = Self.replyNameFont
        replyContainer.addSubview(replyNameLabel)
        replyTextLabel.font = Self.replyTextFont
        replyTextLabel.lineBreakMode = .byTruncatingTail
        replyContainer.addSubview(replyTextLabel)
        let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped))
        replyContainer.addGestureRecognizer(replyTap)
        replyContainer.isUserInteractionEnabled = true
        bubbleView.addSubview(replyContainer)

        // Photo collage
        photoContainer.backgroundColor = .clear
        photoContainer.clipsToBounds = true
        bubbleView.addSubview(photoContainer)

        for index in 0..<Self.maxVisiblePhotoTiles {
            let imageView = UIImageView()
            imageView.contentMode = .scaleAspectFill
            imageView.clipsToBounds = true
            imageView.isHidden = true
            photoContainer.addSubview(imageView)

            let placeholderView = UIView()
            placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
            placeholderView.isHidden = true
            photoContainer.addSubview(placeholderView)

            let ring = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
            ring.setRingColor(.white)
            ring.isHidden = true
            photoContainer.addSubview(ring)

            let errorView = UIImageView(image: Self.errorIcon)
            errorView.contentMode = .center
            errorView.isHidden = true
            photoContainer.addSubview(errorView)

            // Download arrow overlay — shown when photo not yet downloaded
            let downloadArrow = UIView()
            downloadArrow.isHidden = true
            downloadArrow.isUserInteractionEnabled = false
            let arrowCircle = UIView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
            arrowCircle.backgroundColor = UIColor.black.withAlphaComponent(0.5)
            arrowCircle.layer.cornerRadius = 24
            arrowCircle.tag = 1001
            downloadArrow.addSubview(arrowCircle)
            let arrowImage = UIImageView(
                image: UIImage(systemName: "arrow.down",
                               withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
            )
            arrowImage.tintColor = .white
            arrowImage.contentMode = .center
            arrowImage.frame = arrowCircle.bounds
            arrowCircle.addSubview(arrowImage)
            photoContainer.addSubview(downloadArrow)

            let button = UIButton(type: .custom)
            button.tag = index
            button.isHidden = true
            button.addTarget(self, action: #selector(handlePhotoTileTap(_:)), for: .touchUpInside)
            photoContainer.addSubview(button)

            photoTileImageViews.append(imageView)
            photoTilePlaceholderViews.append(placeholderView)
            photoTileProgressRings.append(ring)
            photoTileErrorViews.append(errorView)
            photoTileDownloadArrows.append(downloadArrow)
            photoTileButtons.append(button)
        }

        photoUploadingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.22)
        photoUploadingOverlayView.isHidden = true
        photoUploadingOverlayView.isUserInteractionEnabled = false
        photoContainer.addSubview(photoUploadingOverlayView)

        photoUploadingRing.setRingColor(.white)
        photoUploadingRing.isHidden = true
        photoContainer.addSubview(photoUploadingRing)

        photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
        photoOverflowOverlayView.layer.cornerCurve = .continuous
        photoOverflowOverlayView.layer.cornerRadius = 0
        photoOverflowOverlayView.isHidden = true
        photoOverflowOverlayView.isUserInteractionEnabled = false
        photoContainer.addSubview(photoOverflowOverlayView)

        photoOverflowLabel.font = UIFont.systemFont(ofSize: 26, weight: .semibold)
        photoOverflowLabel.textColor = .white
        photoOverflowLabel.textAlignment = .center
        photoOverflowLabel.isHidden = true
        photoOverflowOverlayView.addSubview(photoOverflowLabel)

        // File
        fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
        fileIconView.layer.cornerRadius = 22
        fileIconSymbolView.tintColor = .white
        fileIconSymbolView.contentMode = .scaleAspectFit
        fileIconView.addSubview(fileIconSymbolView)
        fileContainer.addSubview(fileIconView)
        fileProgressRing.setRingColor(.white)
        fileProgressRing.isHidden = true
        fileContainer.addSubview(fileProgressRing)
        fileNameLabel.font = Self.fileNameFont
        fileNameLabel.textColor = .label
        fileContainer.addSubview(fileNameLabel)
        fileSizeLabel.font = Self.fileSizeFont
        fileSizeLabel.textColor = .secondaryLabel
        fileContainer.addSubview(fileSizeLabel)

        // Call arrow (small directional arrow left of duration)
        callArrowView.contentMode = .scaleAspectFit
        callArrowView.isHidden = true
        fileContainer.addSubview(callArrowView)

        // Call-back button — Telegram: just a phone icon, NO circle background
        let callPhoneIcon = UIImageView(
            image: UIImage(systemName: "phone.fill",
                           withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
        )
        callPhoneIcon.tintColor = .white
        callPhoneIcon.contentMode = .center
        callPhoneIcon.tag = 2002
        callBackButton.addSubview(callPhoneIcon)
        callBackButton.addTarget(self, action: #selector(callBackTapped), for: .touchUpInside)
        callBackButton.isHidden = true
        fileContainer.addSubview(callBackButton)

        // Tap on entire file container — triggers call-back for call bubbles
        let fileTap = UITapGestureRecognizer(target: self, action: #selector(fileContainerTapped))
        fileContainer.addGestureRecognizer(fileTap)
        fileContainer.isUserInteractionEnabled = true

        // Avatar image (circular, replaces icon for avatar type)
        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.clipsToBounds = true
        avatarImageView.layer.cornerRadius = 22
        avatarImageView.isHidden = true
        fileContainer.addSubview(avatarImageView)

        voiceView.isHidden = true
        fileContainer.addSubview(voiceView)

        bubbleView.addSubview(fileContainer)

        // Group Invite Card
        groupInviteIconBg.layer.cornerRadius = 22
        groupInviteIconBg.clipsToBounds = true
        let boldFont = UIFont.systemFont(ofSize: 17, weight: .bold)
        groupInviteInitialLabel.font = boldFont.fontDescriptor.withDesign(.rounded)
            .map { UIFont(descriptor: $0, size: 0) } ?? boldFont
        groupInviteInitialLabel.textColor = .white
        groupInviteInitialLabel.textAlignment = .center
        groupInviteIconBg.addSubview(groupInviteInitialLabel)
        groupInviteContainer.addSubview(groupInviteIconBg)

        groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
        groupInviteTitleLabel.lineBreakMode = .byTruncatingTail
        groupInviteContainer.addSubview(groupInviteTitleLabel)

        groupInviteStatusLabel.font = .systemFont(ofSize: 12, weight: .regular)
        groupInviteContainer.addSubview(groupInviteStatusLabel)

        groupInviteButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
        groupInviteButton.layer.cornerRadius = 14
        groupInviteButton.clipsToBounds = true
        groupInviteButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 14, bottom: 5, right: 14)
        groupInviteButton.addTarget(self, action: #selector(groupInviteCardTapped), for: .touchUpInside)
        groupInviteContainer.addSubview(groupInviteButton)

        groupInviteContainer.isHidden = true
        bubbleView.addSubview(groupInviteContainer)

        // Listen for avatar download trigger (tap-to-download, Android parity)
        NotificationCenter.default.addObserver(
            self, selector: #selector(handleAttachmentDownload(_:)),
            name: .triggerAttachmentDownload, object: nil
        )

        // Forward header
        forwardLabel.font = Self.forwardLabelFont
        forwardLabel.text = "Forwarded from"
        bubbleView.addSubview(forwardLabel)

        forwardAvatarView.layer.cornerRadius = 8
        forwardAvatarView.clipsToBounds = true
        bubbleView.addSubview(forwardAvatarView)

        forwardAvatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
        forwardAvatarInitialLabel.textColor = .white
        forwardAvatarInitialLabel.textAlignment = .center
        forwardAvatarView.addSubview(forwardAvatarInitialLabel)

        forwardAvatarImageView.contentMode = .scaleAspectFill
        forwardAvatarImageView.clipsToBounds = true
        forwardAvatarView.addSubview(forwardAvatarImageView)

        forwardNameLabel.font = Self.forwardNameFont
        forwardNameLabel.textColor = .white // default, overridden in configure()
        bubbleView.addSubview(forwardNameLabel)

        // Group sender info (Telegram parity: name INSIDE bubble as first line, avatar left)
        senderNameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
        senderNameLabel.isHidden = true
        bubbleView.addSubview(senderNameLabel) // INSIDE bubble (Telegram: name is first line in bubble)

        senderAdminIconView.contentMode = .scaleAspectFit
        senderAdminIconView.isHidden = true
        bubbleView.addSubview(senderAdminIconView)

        senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle
        senderAvatarContainer.clipsToBounds = true
        senderAvatarContainer.isHidden = true
        senderAvatarContainer.isUserInteractionEnabled = true
        let avatarTap = UITapGestureRecognizer(target: self, action: #selector(handleAvatarTap))
        senderAvatarContainer.addGestureRecognizer(avatarTap)
        contentView.addSubview(senderAvatarContainer)

        // Match AvatarView: size * 0.38, bold, rounded design
        let avatarFontSize: CGFloat = 36 * 0.38
        let descriptor = UIFont.systemFont(ofSize: avatarFontSize, weight: .bold)
            .fontDescriptor.withDesign(.rounded) ?? UIFont.systemFont(ofSize: avatarFontSize, weight: .bold).fontDescriptor
        senderAvatarInitialLabel.font = UIFont(descriptor: descriptor, size: avatarFontSize)
        senderAvatarInitialLabel.textColor = .white
        senderAvatarInitialLabel.textAlignment = .center
        senderAvatarContainer.addSubview(senderAvatarInitialLabel)

        senderAvatarImageView.contentMode = .scaleAspectFill
        senderAvatarImageView.clipsToBounds = true
        senderAvatarContainer.addSubview(senderAvatarImageView)

        // Highlight overlay — on top of all bubble content
        highlightOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.12)
        highlightOverlay.isUserInteractionEnabled = false
        highlightOverlay.alpha = 0
        highlightOverlay.layer.cornerCurve = .continuous
        bubbleView.addSubview(highlightOverlay)

        // Swipe reply icon — circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
        replyCircleView.backgroundColor = UIColor { traits in
            traits.userInterfaceStyle == .dark
                ? UIColor.white.withAlphaComponent(0.12)
                : UIColor.black.withAlphaComponent(0.12)
        }
        replyCircleView.layer.cornerRadius = 17 // 34pt / 2
        replyCircleView.alpha = 0
        contentView.addSubview(replyCircleView)

        replyIconView.image = Self.telegramReplyArrowImage
        replyIconView.contentMode = .scaleAspectFit
        replyIconView.tintColor = UIColor { traits in
            traits.userInterfaceStyle == .dark ? .white : .black
        }
        replyIconView.alpha = 0
        contentView.addSubview(replyIconView)

        // Delivery failed node (Telegram-style external badge)
        deliveryFailedButton.setImage(Self.errorIcon, for: .normal)
        deliveryFailedButton.imageView?.contentMode = .scaleAspectFit
        deliveryFailedButton.isHidden = true
        deliveryFailedButton.accessibilityLabel = "Retry sending"
        deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
        contentView.addSubview(deliveryFailedButton)

        // Multi-select checkbox (Telegram: 28×28pt circle, position at x:6)
        selectionCheckContainer.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
        selectionCheckContainer.isHidden = true
        selectionCheckContainer.isUserInteractionEnabled = false

        // Telegram CheckNode: overlay style — white border, shadow, CG checkmark
        let inset: CGFloat = 2.0 - (1.0 / UIScreen.main.scale) // Telegram: 2.0 - UIScreenPixel
        let borderWidth: CGFloat = 1.0 + (1.0 / UIScreen.main.scale) // Telegram: 1.0 + UIScreenPixel
        let borderRect = CGRect(x: 0, y: 0, width: 28, height: 28).insetBy(dx: inset, dy: inset)
        let checkCirclePath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28))

        selectionCheckBorder.path = UIBezierPath(ovalIn: borderRect).cgPath
        selectionCheckBorder.fillColor = UIColor.clear.cgColor
        selectionCheckBorder.strokeColor = UIColor.white.cgColor // Telegram: pure white for overlay
        selectionCheckBorder.lineWidth = borderWidth
        selectionCheckContainer.layer.addSublayer(selectionCheckBorder)

        // Telegram CheckNode overlay shadow
        selectionCheckContainer.layer.shadowColor = UIColor.black.cgColor
        selectionCheckContainer.layer.shadowOpacity = 0.22
        selectionCheckContainer.layer.shadowRadius = 2.5
        selectionCheckContainer.layer.shadowOffset = .zero

        selectionCheckFill.path = checkCirclePath.cgPath
        selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 — Rosetta primary blue
        selectionCheckFill.isHidden = true
        selectionCheckContainer.layer.addSublayer(selectionCheckFill)

        // Telegram CheckNode: CG-drawn checkmark (1.5pt, round cap/join)
        let s = (28.0 - inset * 2) / 18.0 // Telegram scale factor
        let cx = 14.0, cy = 14.0
        let startX = cx - (4.0 - 0.3333) * s
        let startY = cy + 0.5 * s
        let checkmarkPath = UIBezierPath()
        checkmarkPath.move(to: CGPoint(x: startX, y: startY))
        checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s, y: startY + 3.0 * s))
        checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s + 4.6667 * s, y: startY + 3.0 * s - 6.0 * s))

        selectionCheckmarkLayer.path = checkmarkPath.cgPath
        selectionCheckmarkLayer.strokeColor = UIColor.white.cgColor
        selectionCheckmarkLayer.fillColor = UIColor.clear.cgColor
        selectionCheckmarkLayer.lineWidth = 1.5
        selectionCheckmarkLayer.lineCap = .round
        selectionCheckmarkLayer.lineJoin = .round
        selectionCheckmarkLayer.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
        selectionCheckmarkLayer.isHidden = true
        selectionCheckContainer.layer.addSublayer(selectionCheckmarkLayer)

        contentView.addSubview(selectionCheckContainer)

        // Long-press → Telegram context menu
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
        longPress.minimumPressDuration = 0.35
        bubbleView.addGestureRecognizer(longPress)

        // Single tap → open link if tapped on a URL
        let linkTap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:)))
        bubbleView.addGestureRecognizer(linkTap)

        let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
        pan.delegate = self
        contentView.addGestureRecognizer(pan)
    }

    // MARK: - Configure + Apply Layout

    /// Human-readable label for service messages (strips $a= prefix).
    private static func serviceMessageDisplayText(_ text: String) -> String {
        guard text.hasPrefix("$a=") else { return text }
        let action = String(text.dropFirst(3))
        switch action {
        case "Group created": return "Group created"
        case "Group joined": return "You joined the group"
        default: return action
        }
    }

    /// Configure cell data (content). Does NOT trigger layout.
    /// `textLayout` is pre-computed during `calculateLayouts()` — no double CoreText work.
    func configure(
        message: ChatMessage,
        timestamp: String,
        textLayout: CoreTextTextLayout? = nil,
        actions: MessageCellActions,
        replyName: String? = nil,
        replyText: String? = nil,
        replyMessageId: String? = nil,
        forwardSenderName: String? = nil,
        forwardSenderKey: String? = nil
    ) {
        self.message = message
        self.actions = actions
        self.replyMessageId = replyMessageId

        // Service messages: show centered pill, hide everything else
        let isService = message.text.hasPrefix("$a=")
        servicePillView.isHidden = !isService
        if isService {
            serviceLabel.text = Self.serviceMessageDisplayText(message.text)
            bubbleView.isHidden = true
            return
        }
        bubbleView.isHidden = false

        let isOutgoing = currentLayout?.isOutgoing ?? false
        let isMediaStatus: Bool = {
            guard let layout = currentLayout else { return false }
            return layout.messageType == .photo || layout.messageType == .emojiOnly
                || layout.isForwardPhotoOnly
        }()

        // Text — use cached CoreTextTextLayout from measurement phase.
        // Same CTTypesetter pipeline → identical line breaks, zero recomputation.
        textLabel.textLayout = textLayout

        // Timestamp — dynamic UIColor for incoming so theme changes resolve instantly
        timestampLabel.text = timestamp
        if isMediaStatus {
            timestampLabel.textColor = .white
        } else if isOutgoing {
            timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
        } else {
            timestampLabel.textColor = UIColor { traits in
                traits.userInterfaceStyle == .dark
                    ? UIColor.white.withAlphaComponent(0.6)
                    : UIColor.black.withAlphaComponent(0.45)
            }
        }

        // Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead)
        stopSendingClockAnimation()
        var shouldShowSentCheck = false
        var shouldShowReadCheck = false
        var shouldShowClock = false
        checkSentView.image = nil
        checkReadView.image = nil
        clockFrameView.image = nil
        clockMinView.image = nil
        if isOutgoing {
            switch message.deliveryStatus {
            case .delivered:
                shouldShowSentCheck = true
                if message.isRead {
                    checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckReadImage
                    checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckReadImage
                    shouldShowReadCheck = true
                } else {
                    checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage
                }
            case .waiting:
                shouldShowClock = true
                clockFrameView.image = isMediaStatus ? Self.mediaClockFrameImage : Self.clockFrameImage
                clockMinView.image = isMediaStatus ? Self.mediaClockMinImage : Self.clockMinImage
                startSendingClockAnimation()
            case .error:
                break
            }
        }
        checkSentView.isHidden = !shouldShowSentCheck
        checkReadView.isHidden = !shouldShowReadCheck
        clockFrameView.isHidden = !shouldShowClock
        clockMinView.isHidden = !shouldShowClock
        animateCheckAppearanceIfNeeded(isSentVisible: shouldShowSentCheck, isReadVisible: shouldShowReadCheck)
        deliveryFailedButton.isHidden = !(isOutgoing && message.deliveryStatus == .error)
        updateStatusBackgroundVisibility()

        // Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView)
        photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor

        // Reply quote — Telegram parity colors
        if let replyName {
            replyContainer.isHidden = false
            replyContainer.backgroundColor = isOutgoing
                ? UIColor.white.withAlphaComponent(0.12)
                : Self.outgoingColor.withAlphaComponent(0.12)
            replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
            replyNameLabel.text = replyName
            replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
            replyTextLabel.text = replyText ?? ""
            replyTextLabel.textColor = isOutgoing
                ? .white
                : UIColor { traits in
                    traits.userInterfaceStyle == .dark ? .white : .darkGray
                }
        } else {
            replyContainer.isHidden = true
        }

        // Forward — two-row: "Forwarded from" + [avatar] **Name**
        if let forwardSenderName {
            forwardLabel.isHidden = false
            forwardAvatarView.isHidden = false
            forwardNameLabel.isHidden = false
            let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
            forwardLabel.text = "Forwarded from"
            forwardLabel.textColor = accent
            forwardNameLabel.text = forwardSenderName
            forwardNameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
            forwardNameLabel.textColor = accent
            // Avatar: real photo if available, otherwise initial + color
            if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
                forwardAvatarImageView.image = avatarImage
                forwardAvatarImageView.isHidden = false
                forwardAvatarInitialLabel.isHidden = true
                forwardAvatarView.backgroundColor = .clear
            } else {
                forwardAvatarImageView.image = nil
                forwardAvatarImageView.isHidden = true
                forwardAvatarInitialLabel.isHidden = false
                let initial = String(forwardSenderName.prefix(1)).uppercased()
                forwardAvatarInitialLabel.text = initial
                let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "")
                // Mantine "light" variant: base + tint at low opacity (AvatarView parity)
                let tint = RosettaColors.avatarColor(for: colorIndex)
                forwardAvatarView.backgroundColor = UIColor { traits in
                    let isDark = traits.userInterfaceStyle == .dark
                    let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark
                        ? (0x1A / 255.0, 0x1B / 255.0, 0x1E / 255.0)
                        : (1.0, 1.0, 1.0)
                    let a: CGFloat = isDark ? 0.15 : 0.10
                    var tR: CGFloat = 0, tG: CGFloat = 0, tB: CGFloat = 0, tA: CGFloat = 0
                    tint.getRed(&tR, green: &tG, blue: &tB, alpha: &tA)
                    return UIColor(
                        red: base.r * (1 - a) + tR * a,
                        green: base.g * (1 - a) + tG * a,
                        blue: base.b * (1 - a) + tB * a,
                        alpha: 1
                    )
                }
                forwardAvatarInitialLabel.textColor = UIColor { traits in
                    traits.userInterfaceStyle == .dark
                        ? RosettaColors.avatarTextColor(for: colorIndex)
                        : RosettaColors.avatarColor(for: colorIndex)
                }
            }
        } else {
            forwardLabel.isHidden = true
            forwardAvatarView.isHidden = true
            forwardNameLabel.isHidden = true
        }

        // Additional forward items (items 2+, Android parity)
        if let layout = currentLayout {
            let items = layout.additionalForwardItems
            // Ensure enough subviews exist
            while additionalForwardViews.count < items.count {
                let sv = ForwardItemSubview()
                bubbleView.insertSubview(sv, belowSubview: highlightOverlay)
                additionalForwardViews.append(sv)
            }
            let isOut = layout.isOutgoing
            for (i, itemFrame) in items.enumerated() {
                let sv = additionalForwardViews[i]
                sv.isHidden = false
                sv.configure(info: itemFrame.info, isOutgoing: isOut)
                // Configure photos for forward items with images
                if itemFrame.info.imageCount > 0 {
                    configureForwardItemPhotos(subview: sv, info: itemFrame.info, photoFrame: itemFrame.photoFrame)
                } else {
                    sv.resetPhotos()
                }
                // Configure voice for forward items with voice
                if itemFrame.info.voiceCount > 0 {
                    sv.configureVoice(info: itemFrame.info, messageId: message.id, isOutgoing: isOut)
                } else {
                    sv.resetVoice()
                }
            }
            for i in items.count..<additionalForwardViews.count {
                additionalForwardViews[i].isHidden = true
                additionalForwardViews[i].resetPhotos()
                additionalForwardViews[i].resetVoice()
            }
        }

        // Photo (regular or forwarded)
        if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty {
            configureForwardedPhotos()
        } else {
            configurePhoto(for: message)
        }

        // File (forwarded voice/file: convert ReplyAttachmentData → MessageAttachment, reuse same rendering)
        if let layout = currentLayout, layout.hasFile, layout.isForward, !layout.forwardAttachments.isEmpty {
            // Build a synthetic message with converted forward attachments + chacha password
            var syntheticMessage = message
            let convertedAtts: [MessageAttachment] = layout.forwardAttachments.compactMap { reply in
                guard let attType = reply.attachmentType else { return nil }
                return MessageAttachment(
                    id: reply.id, preview: reply.preview, blob: "",
                    type: attType,
                    transportTag: reply.transport.transport_tag,
                    transportServer: reply.transport.transport_server
                )
            }
            syntheticMessage.attachments = convertedAtts
            if !layout.forwardChachaKeyPlain.isEmpty {
                syntheticMessage.attachmentPassword = "rawkey:\(layout.forwardChachaKeyPlain)"
            }
            // Fall through to regular file rendering with synthetic message
            fileContainer.isHidden = false
            if let voiceAtt = syntheticMessage.attachments.first(where: { $0.type == .voice }) {
                let previewParts = Self.parseVoicePreview(voiceAtt.preview)
                voiceView.isHidden = false
                voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38)
                voiceView.configure(
                    messageId: syntheticMessage.id,
                    attachmentId: voiceAtt.id,
                    preview: previewParts.waveform,
                    duration: previewParts.duration,
                    isOutgoing: layout.isOutgoing
                )
                let voiceAttachment = voiceAtt
                let storedPassword = syntheticMessage.attachmentPassword
                let playbackDuration = previewParts.duration
                let playbackMessageId = syntheticMessage.id

                let voiceFileName = "voice_\(Int(playbackDuration))s.m4a"
                let isCached = Self.playableVoiceURLFromCache(
                    attachmentId: voiceAttachment.id, fileName: voiceFileName
                ) != nil
                voiceView.setDownloaded(isCached)

                let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == syntheticMessage.id
                voiceView.updatePlaybackState(
                    isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
                    progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
                )

                voiceView.onPlayTapped = { [weak self] in
                    guard let self else { return }
                    if let cached = Self.playableVoiceURLFromCache(
                        attachmentId: voiceAttachment.id, fileName: voiceFileName
                    ) {
                        self.voiceView.setDownloaded(true)
                        VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: cached)
                        return
                    }
                    self.voiceView.showDownloadProgress(0.027)
                    let downloadTask = Task {
                        let playableURL = await Self.resolvePlayableVoiceURL(
                            attachment: voiceAttachment,
                            duration: playbackDuration,
                            storedPassword: storedPassword,
                            onProgress: { [weak self] progress in
                                guard let self, self.activeVoiceDownloadTask != nil else { return }
                                self.voiceView.showDownloadProgress(CGFloat(progress))
                            }
                        )
                        guard !Task.isCancelled else { return }
                        if playableURL != nil {
                            self.voiceView.hideDownloadProgress()
                        } else {
                            self.voiceView.cancelDownloadProgress()
                        }
                    }
                    self.activeVoiceDownloadTask = downloadTask
                }
                voiceView.onDownloadCancel = { [weak self] in
                    self?.activeVoiceDownloadTask?.cancel()
                    self?.voiceView.cancelDownloadProgress()
                    self?.activeVoiceDownloadTask = nil
                }
                fileIconView.isHidden = true
                fileNameLabel.isHidden = true
                fileSizeLabel.isHidden = true
                callArrowView.isHidden = true
                callBackButton.isHidden = true
                avatarImageView.isHidden = true
            } else if let fileAtt = syntheticMessage.attachments.first(where: { $0.type == .file }) {
                let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
                let isFileOutgoing = layout.isOutgoing
                avatarImageView.isHidden = true
                fileIconView.isHidden = false
                fileIconView.backgroundColor = isFileOutgoing
                    ? UIColor.white.withAlphaComponent(0.2)
                    : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
                let isCached = AttachmentCache.shared.fileURL(
                    forAttachmentId: fileAtt.id, fileName: parsed.fileName
                ) != nil
                let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down"
                fileIconSymbolView.image = UIImage(systemName: iconName)
                fileIconSymbolView.tintColor = .white
                fileNameLabel.font = Self.fileNameFont
                fileNameLabel.text = parsed.fileName.isEmpty ? "File" : parsed.fileName
                fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
                fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
                fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                callArrowView.isHidden = true
                callBackButton.isHidden = true
                voiceView.isHidden = true
            }
        } else if let layout = currentLayout, layout.hasFile {
            fileContainer.isHidden = false
            if let callAtt = message.attachments.first(where: { $0.type == .call }) {
                let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview)
                let isOutgoing = currentLayout?.isOutgoing ?? false
                let isMissed = durationSec == 0
                let isIncoming = !isOutgoing

                // Telegram: call bubbles have NO icon circle on the left
                avatarImageView.isHidden = true
                fileIconView.isHidden = true

                // Title (16pt medium — Telegram parity)
                fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
                fileNameLabel.textColor = isOutgoing ? .white : .label
                if isMissed {
                    fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
                } else {
                    fileNameLabel.text = isIncoming ? "Incoming Call" : "Outgoing Call"
                }

                // Duration with arrow
                if isMissed {
                    fileSizeLabel.text = "Declined"
                    fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95)
                } else {
                    fileSizeLabel.text = Self.formattedDuration(seconds: durationSec)
                    fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.6) : .secondaryLabel
                }

                // Directional arrow (green/red)
                let arrowName = isIncoming ? "arrow.down.left" : "arrow.up.right"
                callArrowView.image = UIImage(
                    systemName: arrowName,
                    withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
                )
                callArrowView.tintColor = isMissed
                    ? UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 1)
                    : UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033
                callArrowView.isHidden = false
                callBackButton.isHidden = false

                // Call button color: outgoing = white, incoming = blue (Telegram accentControlColor)
                let callPhoneView = callBackButton.viewWithTag(2002) as? UIImageView
                if isOutgoing {
                    callPhoneView?.tintColor = .white
                } else {
                    callPhoneView?.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1)
                }
            } else if let voiceAtt = message.attachments.first(where: { $0.type == .voice }) {
                // Voice message: play button + waveform + duration
                // Preview format: "tag::duration::waveform_base64" or "duration::waveform_base64"
                let previewParts = Self.parseVoicePreview(voiceAtt.preview)
                voiceView.isHidden = false
                voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38)
                voiceView.configure(
                    messageId: message.id,
                    attachmentId: voiceAtt.id,
                    preview: previewParts.waveform,
                    duration: previewParts.duration,
                    isOutgoing: layout.isOutgoing
                )
                let voiceAttachment = voiceAtt
                let storedPassword = message.attachmentPassword
                let playbackDuration = previewParts.duration
                let playbackMessageId = message.id

                // Check cache: ↓ arrow if not downloaded, play icon if cached
                let voiceFileName = "voice_\(Int(playbackDuration))s.m4a"
                let isCached = Self.playableVoiceURLFromCache(
                    attachmentId: voiceAttachment.id, fileName: voiceFileName
                ) != nil
                // Own outgoing voice = always "downloaded" (data came from local recording)
                let isOwnVoice = layout.isOutgoing
                voiceView.setDownloaded(isCached || isOwnVoice)

                let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == message.id
                voiceView.updatePlaybackState(
                    isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
                    progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
                )

                voiceView.onPlayTapped = { [weak self] in
                    guard let self else { return }

                    // If already cached — play immediately
                    if let cached = Self.playableVoiceURLFromCache(
                        attachmentId: voiceAttachment.id, fileName: voiceFileName
                    ) {
                        self.voiceView.setDownloaded(true)
                        VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: cached)
                        return
                    }

                    // Show progress ring and start download
                    self.voiceView.showDownloadProgress(0.027)
                    let downloadTask = Task {
                        let playableURL = await Self.resolvePlayableVoiceURL(
                            attachment: voiceAttachment,
                            duration: playbackDuration,
                            storedPassword: storedPassword,
                            onProgress: { [weak self] progress in
                                guard let self, self.activeVoiceDownloadTask != nil else { return }
                                self.voiceView.showDownloadProgress(CGFloat(progress))
                            }
                        )
                        guard !Task.isCancelled else { return }
                        if playableURL != nil {
                            self.voiceView.hideDownloadProgress()
                        } else {
                            self.voiceView.cancelDownloadProgress()
                        }
                    }
                    self.activeVoiceDownloadTask = downloadTask
                }
                voiceView.onDownloadCancel = { [weak self] in
                    self?.activeVoiceDownloadTask?.cancel()
                    self?.voiceView.cancelDownloadProgress()
                    self?.activeVoiceDownloadTask = nil
                }
                fileIconView.isHidden = true
                fileNameLabel.isHidden = true
                fileSizeLabel.isHidden = true
                callArrowView.isHidden = true
                callBackButton.isHidden = true
                avatarImageView.isHidden = true
            } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
                let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
                let isFileOutgoing = layout.isOutgoing
                avatarImageView.isHidden = true
                fileIconView.isHidden = false
                // Telegram parity: solid accent circle (incoming) or semi-transparent (outgoing)
                fileIconView.backgroundColor = isFileOutgoing
                    ? UIColor.white.withAlphaComponent(0.2)
                    : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
                let isCached = AttachmentCache.shared.fileURL(
                    forAttachmentId: fileAtt.id, fileName: parsed.fileName
                ) != nil
                let isFileUploading = isFileOutgoing && message.deliveryStatus == .waiting

                if isFileUploading {
                    // Upload in progress — show ring with real progress
                    fileIconSymbolView.isHidden = true
                    fileProgressRing.show()
                    fileProgressRing.setRingColor(.white)
                    subscribeFileUploadProgress()
                    fileSizeLabel.text = "Uploading..."
                } else if isCached {
                    fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: parsed.fileName))
                    fileIconSymbolView.isHidden = false
                    fileProgressRing.hide()
                    fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
                    unsubscribeFileUploadProgress()
                } else {
                    fileIconSymbolView.image = UIImage(systemName: "arrow.down")
                    fileIconSymbolView.isHidden = false
                    fileProgressRing.hide()
                    fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
                    unsubscribeFileUploadProgress()
                }
                fileIconSymbolView.tintColor = .white
                fileNameLabel.font = Self.fileNameFont
                fileNameLabel.text = parsed.fileName
                // Telegram parity: accent blue filename (incoming) or white (outgoing)
                fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
                if !isFileUploading {
                    fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                } else {
                    fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                }
                callArrowView.isHidden = true
                callBackButton.isHidden = true
            } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
                let isOutgoing = currentLayout?.isOutgoing ?? false
                fileNameLabel.font = Self.fileNameFont
                fileNameLabel.text = "Avatar"
                fileNameLabel.textColor = isOutgoing ? .white : .label
                callArrowView.isHidden = true
                callBackButton.isHidden = true

                // Android parity: show cached image OR blurhash placeholder.
                // NO auto-download — user must tap to download (via .triggerAttachmentDownload).
                if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) {
                    avatarImageView.image = cached
                    avatarImageView.isHidden = false
                    fileIconView.isHidden = true
                    fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
                    fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                } else {
                    if isOutgoing {
                        // Own avatar — already uploaded, just loading from disk
                        fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
                    } else {
                        // Incoming avatar — needs download on tap (Android parity)
                        fileSizeLabel.text = "Tap to download"
                    }
                    fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                    // Show blurhash placeholder (decode async if not cached)
                    let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview)
                    if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) {
                        avatarImageView.image = blurImg
                        avatarImageView.isHidden = false
                        fileIconView.isHidden = true
                    } else {
                        avatarImageView.isHidden = true
                        fileIconView.isHidden = false
                        fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
                        fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
                    }

                    // Async: decode blurhash + try disk cache (NO CDN download — tap required)
                    let messageId = message.id
                    let attId = avatarAtt.id
                    Task.detached(priority: .userInitiated) {
                        // 1. Decode blurhash immediately (~2ms)
                        if !hash.isEmpty {
                            if let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) {
                                await MainActor.run { [weak self] in
                                    guard let self, self.message?.id == messageId else { return }
                                    Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
                                    self.avatarImageView.image = decoded
                                    self.avatarImageView.isHidden = false
                                    self.fileIconView.isHidden = true
                                }
                            }
                        }

                        // 2. Try disk cache only (previously downloaded)
                        if let diskImage = AttachmentCache.shared.loadImage(forAttachmentId: attId) {
                            await MainActor.run { [weak self] in
                                guard let self, self.message?.id == messageId else { return }
                                self.avatarImageView.image = diskImage
                                self.avatarImageView.isHidden = false
                                self.fileIconView.isHidden = true
                                let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
                                self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
                            }
                        }
                        // CDN download is triggered by user tap via .triggerAttachmentDownload
                    }
                }
            } else {
                let isOutgoing = currentLayout?.isOutgoing ?? false
                avatarImageView.isHidden = true
                fileIconView.isHidden = false
                fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
                fileIconSymbolView.image = UIImage(systemName: "doc.fill")
                fileNameLabel.font = Self.fileNameFont
                fileNameLabel.text = "File"
                fileNameLabel.textColor = isOutgoing ? .white : .label
                fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
                callArrowView.isHidden = true
                callBackButton.isHidden = true
            }
        } else {
            fileContainer.isHidden = true
        }

        // Group Invite Card
        if let layout = currentLayout, layout.hasGroupInvite {
            groupInviteContainer.isHidden = false
            textLabel.isHidden = true
            groupInviteString = message.text

            let inviteTitle = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle
            groupInviteTitleLabel.text = inviteTitle
            groupInviteInitialLabel.text = RosettaColors.groupInitial(name: inviteTitle, publicKey: layout.groupInviteGroupId)

            // Mantine light variant for invite avatar
            let colorIdx = RosettaColors.avatarColorIndex(for: inviteTitle, publicKey: layout.groupInviteGroupId)
            let isDark = traitCollection.userInterfaceStyle == .dark
            let tintColor = RosettaColors.avatarColor(for: colorIdx)
            let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
            var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0
            var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0
            let base: UIColor = isDark
                ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
                : .white
            base.getRed(&br, green: &bg, blue: &bb, alpha: &ba)
            tintColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta)
            groupInviteIconBg.backgroundColor = UIColor(
                red: br + (tr - br) * tintAlpha,
                green: bg + (tg - bg) * tintAlpha,
                blue: bb + (tb - bb) * tintAlpha,
                alpha: 1
            )
            groupInviteInitialLabel.textColor = isDark
                ? RosettaColors.avatarTextColor(for: colorIdx)
                : tintColor

            // Local membership check (fast, MainActor)
            let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId)
            applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing)

            // Async server check for authoritative status
            let msgId = message.id, groupId = layout.groupInviteGroupId
            inviteStatusTask?.cancel()
            inviteStatusTask = Task { @MainActor [weak self] in
                guard !Task.isCancelled else { return }
                guard let self, self.message?.id == msgId else { return }
                if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId),
                   !Task.isCancelled,
                   self.message?.id == msgId {
                    let cardStatus: InviteCardStatus = switch status {
                    case .joined: .joined
                    case .notJoined: .notJoined
                    case .invalid: .invalid
                    case .banned: .banned
                    }
                    self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing)
                }
            }
        } else {
            groupInviteContainer.isHidden = true
        }
    }

    private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) {
        currentInviteStatus = status
        let color: UIColor
        switch status {
        case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
        case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1)    // #34C759
        case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1)
        }
        // Avatar bg is set by Mantine colors in configure(), not by status
        groupInviteTitleLabel.textColor = isOutgoing ? .white : color

        let statusText: String
        switch status {
        case .notJoined: statusText = "Invite to join this group"
        case .joined: statusText = "You are a member"
        case .invalid: statusText = "This invite is invalid"
        case .banned: statusText = "You are banned"
        }
        groupInviteStatusLabel.text = statusText
        groupInviteStatusLabel.textColor = isOutgoing
            ? UIColor.white.withAlphaComponent(0.7)
            : .secondaryLabel

        let buttonTitle: String
        switch status {
        case .notJoined: buttonTitle = "Join Group"
        case .joined: buttonTitle = "Open Group"
        case .invalid: buttonTitle = "Invalid"
        case .banned: buttonTitle = "Banned"
        }
        groupInviteButton.setTitle(buttonTitle, for: .normal)
        groupInviteButton.setTitleColor(.white, for: .normal)
        groupInviteButton.backgroundColor = color
        groupInviteButton.isEnabled = (status == .notJoined || status == .joined)
        groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5
    }

    @objc private func groupInviteCardTapped() {
        guard let inviteStr = groupInviteString, let actions else { return }
        if currentInviteStatus == .joined {
            if let parsed = GroupRepository.parseInviteStringPure(inviteStr) {
                let dialogKey = "#group:\(parsed.groupId)"
                actions.onGroupInviteOpen(dialogKey)
            }
        } else if currentInviteStatus == .notJoined {
            actions.onGroupInviteTap(inviteStr)
        }
    }

    /// Apply pre-calculated layout (main thread only — just sets frames).
    /// This is the "apply" part of Telegram's asyncLayout pattern.
    /// NOTE: Bubble X-position is recalculated in layoutSubviews() based on actual cell width.
    func apply(layout: MessageCellLayout) {
        currentLayout = layout
        setNeedsLayout() // trigger layoutSubviews for correct X positioning
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        guard let layout = currentLayout else { return }

        // Service message: centered pill, skip all bubble layout
        if layout.messageType == .service {
            serviceLabel.sizeToFit()
            let labelW = serviceLabel.bounds.width
            let labelH = serviceLabel.bounds.height
            let pillW = labelW + 24
            let pillH = labelH + 8
            let cellW = contentView.bounds.width
            servicePillView.frame = CGRect(
                x: (cellW - pillW) / 2,
                y: (layout.bubbleSize.height - pillH) / 2,
                width: pillW,
                height: pillH
            )
            serviceLabel.frame = CGRect(x: 12, y: 4, width: labelW, height: labelH)
            return
        }

        Self.ensureBubbleImages(for: traitCollection)

        let cellW = contentView.bounds.width
        let tailProtrusion = Self.bubbleMetrics.tailProtrusion

        // Inline date pills are always hidden — floating overlay pills in
        // NativeMessageListController handle all date display + push mechanics.
        // Cell still reserves dateHeaderHeight for visual spacing between sections.
        dateHeaderContainer.isHidden = true

        // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
        // Group incoming: offset right by 40pt for avatar lane (Telegram parity).
        let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0
        let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0
        let baseBubbleX: CGFloat
        if layout.isOutgoing {
            baseBubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
        } else if isGroupIncoming {
            // Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane.
            baseBubbleX = 4 + groupAvatarLane
        } else {
            baseBubbleX = tailProtrusion + 2
        }
        // Telegram parity: only incoming messages shift right in selection mode
        let bubbleX: CGFloat
        if layout.isOutgoing {
            bubbleX = baseBubbleX
        } else {
            bubbleX = baseBubbleX + selectionOffset
        }

        bubbleView.frame = CGRect(
            x: bubbleX, y: layout.bubbleFrame.minY,
            width: layout.bubbleSize.width, height: layout.bubbleSize.height
        )

        // ── Selection checkbox (Telegram: 28×28, x:6, vertically centered with bubble) ──
        if isInSelectionMode {
            let checkY = layout.bubbleFrame.minY + (layout.bubbleSize.height - 28) / 2
            selectionCheckContainer.frame = CGRect(x: 6, y: checkY, width: 28, height: 28)
        }

        // ── Raster bubble image (Telegram-exact tail via stretchable image) ──
        // Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles,
        // not just tailed ones. This keeps right edges aligned in a group.
        // For non-tailed, the tail area is transparent in the stretchable image.
        let imageFrame: CGRect
        if layout.isOutgoing {
            imageFrame = CGRect(x: 0, y: 0,
                                width: layout.bubbleSize.width + tailProtrusion,
                                height: layout.bubbleSize.height)
        } else {
            imageFrame = CGRect(x: -tailProtrusion, y: 0,
                                width: layout.bubbleSize.width + tailProtrusion,
                                height: layout.bubbleSize.height)
        }
        // Telegram extends bubble image by 1pt on each side (ChatMessageBackground.swift line 115:
        // `let imageFrame = CGRect(...).insetBy(dx: -1.0, dy: -1.0)`).
        // This makes adjacent bubbles overlap by 2pt vertically, reducing perceived gap.
        bubbleImageView.frame = imageFrame.insetBy(dx: -1, dy: -1)
        bubbleImageView.image = Self.bubbleImages.image(
            outgoing: layout.isOutgoing, mergeType: layout.mergeType
        )

        // Highlight overlay — matches bubble bounds with inner corner radius
        highlightOverlay.frame = bubbleView.bounds
        highlightOverlay.layer.cornerRadius = 16

        // ── Vector shadow path (approximate shape, used only for shadow) ──
        bubbleLayer.frame = bubbleView.bounds
        let shapeRect = imageFrame
        bubbleLayer.path = BubblePathCache.shared.path(
            size: shapeRect.size, origin: shapeRect.origin,
            mergeType: layout.mergeType,
            isOutgoing: layout.isOutgoing,
            metrics: Self.bubbleMetrics
        )
        bubbleLayer.shadowPath = bubbleLayer.path
        bubbleOutlineLayer.frame = bubbleView.bounds
        bubbleOutlineLayer.path = bubbleLayer.path
        let hasPhotoContent = layout.hasPhoto
        if hasPhotoContent {
            bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
        } else if layout.hasTail {
            bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
        } else {
            bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
        }

        // Emoji-only: hide bubble visuals (no background, just floating emoji)
        let isEmojiOnly = layout.messageType == .emojiOnly
        bubbleImageView.isHidden = isEmojiOnly
        bubbleLayer.isHidden = isEmojiOnly
        bubbleOutlineLayer.isHidden = isEmojiOnly

        // Text
        textLabel.isHidden = layout.textSize == .zero
        textLabel.frame = layout.textFrame

        // Timestamp + checkmarks (two-node overlay)
        timestampLabel.frame = layout.timestampFrame
        checkSentView.frame = layout.checkSentFrame
        checkReadView.frame = layout.checkReadFrame
        clockFrameView.frame = layout.clockFrame
        clockMinView.frame = layout.clockFrame
        #if DEBUG
        assertStatusLaneFramesValid(layout: layout)
        #endif

        // Telegram-style date/status pill on media-only bubbles.
        updateStatusBackgroundVisibility()
        updateStatusBackgroundFrame()

        // Reply
        replyContainer.isHidden = !layout.hasReplyQuote
        if layout.hasReplyQuote {
            replyContainer.frame = layout.replyContainerFrame
            replyBar.frame = layout.replyBarFrame
            replyNameLabel.frame = layout.replyNameFrame
            replyTextLabel.frame = layout.replyTextFrame
        }

        // Photo
        photoContainer.isHidden = !layout.hasPhoto
        if layout.hasPhoto {
            photoContainer.frame = layout.photoFrame
            layoutPhotoTiles()
        }
        bringStatusOverlayToFront()

        // File / Call / Avatar
        fileContainer.isHidden = !layout.hasFile
        if layout.hasFile {
            fileContainer.frame = layout.fileFrame
            let isCallType = message?.attachments.contains(where: { $0.type == .call }) ?? false
            let fileW = layout.fileFrame.width

            let isAvatarType = message?.attachments.contains(where: { $0.type == .avatar }) ?? false
            let fileContainerH = layout.fileFrame.height

            // For file-only messages, fileContainer spans the ENTIRE bubble (fileH = bubbleH).
            // Centering in fileContainerH gives visually perfect centering within the bubble.
            // The timestamp is a separate view positioned at the bottom — no collision risk
            // because content is left-aligned and timestamp is right-aligned.
            let centerableH = fileContainerH

            if isCallType {
                // Telegram-exact call layout: NO icon circle, text at left edge
                // Source: ChatMessageCallBubbleContentNode.swift
                fileIconView.isHidden = true
                let callBtnSize: CGFloat = 36
                let callBtnRight: CGFloat = 10
                let textRight = callBtnRight + callBtnSize + 8
                // Vertically center content above timestamp
                let contentH: CGFloat = 36 // title(20) + gap(2) + subtitle(14)
                let topY = max(0, (centerableH - contentH) / 2)
                fileNameLabel.frame = CGRect(x: 11, y: topY, width: fileW - 11 - textRight, height: 20)
                fileSizeLabel.frame = CGRect(x: 25, y: topY + 22, width: fileW - 25 - textRight, height: 14)
                callArrowView.frame = CGRect(x: 12, y: topY + 25, width: 10, height: 10)
                // Call button: vertically centered in same area
                let btnY = max(0, (centerableH - callBtnSize) / 2)
                callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: btnY, width: callBtnSize, height: callBtnSize)
                callBackButton.viewWithTag(2002)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize)
                avatarImageView.isHidden = true
            } else if isAvatarType {
                // Avatar layout: vertically centered icon (44pt) + title + description
                let contentH: CGFloat = 44 // icon height dominates
                let topY = max(0, (centerableH - contentH) / 2)
                fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
                fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
                avatarImageView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
                let textTopY = topY + 4
                fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
                fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
            } else if !voiceView.isHidden {
                // Voice layout: position in upper area with padding (Telegram: bubble insets top=15, left=9)
                let contentH: CGFloat = 38
                let voiceLeftPad: CGFloat = 6   // left padding inside fileContainer
                let voiceTopPad: CGFloat = 8    // top padding — positions content in upper portion
                voiceView.frame = CGRect(x: voiceLeftPad, y: voiceTopPad,
                                         width: fileW - voiceLeftPad * 2, height: contentH)
                voiceView.layoutIfNeeded()  // ensure playButton.frame is set before blob positioning
                fileIconView.isHidden = true
                fileNameLabel.isHidden = true
                fileSizeLabel.isHidden = true
                avatarImageView.isHidden = true
                // Reposition blob if it exists (keeps in sync on relayout)
                if let blob = voiceBlobView {
                    let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
                    let blobSize: CGFloat = 56
                    blob.frame = CGRect(x: playCenter.x - blobSize / 2,
                                        y: playCenter.y - blobSize / 2,
                                        width: blobSize, height: blobSize)
                }
            } else {
                // File layout: vertically centered icon + title + size
                // For forwarded files, don't center — place at top (forward header above handles offset)
                let contentH: CGFloat = 44 // icon height dominates
                let topY: CGFloat = layout.isForward ? 5 : max(0, (centerableH - contentH) / 2)
                fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
                fileProgressRing.frame = fileIconView.frame
                fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
                let textTopY = topY + 4
                fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
                fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
                avatarImageView.isHidden = true
            }
        }

        // Group Invite Card
        groupInviteContainer.isHidden = !layout.hasGroupInvite
        if layout.hasGroupInvite {
            groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height)
            let cW = layout.bubbleSize.width
            // Offset for forward header so group invite card doesn't overlap
            let topY: CGFloat = layout.isForward ? (41 + 3) : 10
            groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44)
            groupInviteInitialLabel.frame = groupInviteIconBg.bounds
            let textX: CGFloat = 64
            let textW = cW - textX - 10
            groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19)
            groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16)
            let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28))
            let btnW = min(btnSize.width + 28, textW)
            groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
        }

        // Forward — two-row: "Forwarded from" + [avatar] Name
        if layout.isForward {
            forwardLabel.frame = layout.forwardHeaderFrame
            forwardAvatarView.frame = layout.forwardAvatarFrame
            let avatarBounds = forwardAvatarView.bounds
            forwardAvatarInitialLabel.frame = avatarBounds
            forwardAvatarImageView.frame = avatarBounds
            forwardNameLabel.frame = layout.forwardNameFrame

            // Additional forward items (items 2+)
            for (i, itemFrame) in layout.additionalForwardItems.enumerated() {
                if i < additionalForwardViews.count {
                    let subview = additionalForwardViews[i]
                    subview.isHidden = false
                    subview.applyFrames(itemFrame: itemFrame, bubbleWidth: layout.bubbleSize.width)
                }
            }
            // Hide excess subviews
            for i in layout.additionalForwardItems.count..<additionalForwardViews.count {
                additionalForwardViews[i].isHidden = true
            }
        } else {
            for sv in additionalForwardViews { sv.isHidden = true }
        }

        // Telegram-style failed delivery badge outside bubble (slide + fade).
        let failedSize = CGSize(width: 20, height: 20)
        let targetFailedFrame = CGRect(
            x: bubbleView.frame.maxX + layout.deliveryFailedInset - failedSize.width,
            y: bubbleView.frame.maxY - failedSize.height,
            width: failedSize.width,
            height: failedSize.height
        )
        if layout.showsDeliveryFailedIndicator {
            if !isDeliveryFailedVisible {
                isDeliveryFailedVisible = true
                deliveryFailedButton.isHidden = false
                deliveryFailedButton.alpha = 0
                deliveryFailedButton.frame = targetFailedFrame.offsetBy(dx: layout.deliveryFailedInset, dy: 0)
                UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .beginFromCurrentState]) {
                    self.deliveryFailedButton.alpha = 1
                    self.deliveryFailedButton.frame = targetFailedFrame
                }
            } else {
                deliveryFailedButton.isHidden = false
                deliveryFailedButton.alpha = 1
                deliveryFailedButton.frame = targetFailedFrame
            }
        } else if isDeliveryFailedVisible {
            isDeliveryFailedVisible = false
            let hideFrame = deliveryFailedButton.frame.offsetBy(dx: layout.deliveryFailedInset, dy: 0)
            UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn, .beginFromCurrentState]) {
                self.deliveryFailedButton.alpha = 0
                self.deliveryFailedButton.frame = hideFrame
            } completion: { _ in
                self.deliveryFailedButton.isHidden = true
            }
        } else {
            deliveryFailedButton.isHidden = true
            deliveryFailedButton.alpha = 0
        }

        // Reply icon position is set AFTER sender name expansion (see below).

        // Group sender name — INSIDE bubble as first line (Telegram parity).
        // When shown, shift all bubble content (text, reply, etc.) down by senderNameShift.
        let senderNameShift: CGFloat = layout.showsSenderName ? 20 : 0
        if layout.showsSenderName {
            senderNameLabel.isHidden = false
            senderNameLabel.text = layout.senderName
            let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
            senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx)
            senderNameLabel.sizeToFit()
            // Reserve space for admin badge icon if sender is group owner
            let adminIconSpace: CGFloat = layout.isGroupAdmin ? 20 : 0
            // Position inside bubble: top-left, with standard bubble padding
            senderNameLabel.frame = CGRect(
                x: 10,
                y: 6,
                width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24 - adminIconSpace),
                height: 16
            )
            // Desktop parity: gold admin badge next to sender name
            if layout.isGroupAdmin {
                senderAdminIconView.isHidden = false
                senderAdminIconView.image = Self.goldAdminBadgeImage
                senderAdminIconView.frame = CGRect(
                    x: senderNameLabel.frame.maxX + 3,
                    y: 5,
                    width: 16,
                    height: 16
                )
            } else {
                senderAdminIconView.isHidden = true
            }
            // Expand bubble to fit the name
            bubbleView.frame.size.height += senderNameShift
            // Shift text and other content down to make room for the name
            textLabel.frame.origin.y += senderNameShift
            timestampLabel.frame.origin.y += senderNameShift
            checkSentView.frame.origin.y += senderNameShift
            checkReadView.frame.origin.y += senderNameShift
            clockFrameView.frame.origin.y += senderNameShift
            clockMinView.frame.origin.y += senderNameShift
            statusBackgroundView.frame.origin.y += senderNameShift
            if layout.hasReplyQuote {
                replyContainer.frame.origin.y += senderNameShift
            }
            if layout.hasPhoto {
                photoContainer.frame.origin.y += senderNameShift
            }
            if layout.hasFile {
                fileContainer.frame.origin.y += senderNameShift
            }
            if layout.hasGroupInvite {
                groupInviteContainer.frame.origin.y += senderNameShift
            }
            if layout.isForward {
                forwardLabel.frame.origin.y += senderNameShift
                forwardAvatarView.frame.origin.y += senderNameShift
                forwardNameLabel.frame.origin.y += senderNameShift
                for sv in additionalForwardViews where !sv.isHidden {
                    sv.frame.origin.y += senderNameShift
                }
            }
            // Re-apply bubble image with tail protrusion after height expansion
            let expandedImageFrame: CGRect
            if layout.isOutgoing {
                expandedImageFrame = CGRect(x: 0, y: 0,
                                            width: bubbleView.bounds.width + tailProtrusion,
                                            height: bubbleView.bounds.height)
            } else {
                expandedImageFrame = CGRect(x: -tailProtrusion, y: 0,
                                            width: bubbleView.bounds.width + tailProtrusion,
                                            height: bubbleView.bounds.height)
            }
            bubbleImageView.frame = expandedImageFrame.insetBy(dx: -1, dy: -1)
            highlightOverlay.frame = bubbleView.bounds
            // Update shadow/outline layers for expanded height
            bubbleLayer.frame = bubbleView.bounds
            bubbleLayer.path = BubblePathCache.shared.path(
                size: expandedImageFrame.size, origin: expandedImageFrame.origin,
                mergeType: layout.mergeType,
                isOutgoing: layout.isOutgoing,
                metrics: Self.bubbleMetrics
            )
            bubbleLayer.shadowPath = bubbleLayer.path
            bubbleOutlineLayer.frame = bubbleView.bounds
            bubbleOutlineLayer.path = bubbleLayer.path
        } else {
            senderNameLabel.isHidden = true
            senderAdminIconView.isHidden = true
        }

        // Group sender avatar (left of bubble, last in run, Telegram parity)
        // Size: 36pt (Android 42dp, Desktop 40px — average ~36pt on iOS)
        if layout.showsSenderAvatar {
            let avatarSize: CGFloat = 36
            senderAvatarContainer.isHidden = false
            senderAvatarContainer.frame = CGRect(
                x: 4 + selectionOffset,
                y: bubbleView.frame.maxY - avatarSize,
                width: avatarSize,
                height: avatarSize
            )
            senderAvatarInitialLabel.frame = senderAvatarContainer.bounds
            senderAvatarImageView.frame = senderAvatarContainer.bounds
            senderAvatarImageView.layer.cornerRadius = avatarSize / 2

            let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
            // Mantine "light" variant: base + tint overlay (matches AvatarView SwiftUI rendering).
            // Dark: #1A1B1E base + tint at 15%. Light: white base + tint at 10%.
            let isDark = traitCollection.userInterfaceStyle == .dark
            senderAvatarContainer.backgroundColor = isDark
                ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
                : .white
            let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
            let tintColor = RosettaColors.avatarColor(for: colorIdx).withAlphaComponent(tintAlpha)
            senderAvatarInitialLabel.backgroundColor = tintColor
            senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey)
            // Dark: shade-3 text. Light: shade-6 (tint) text.
            senderAvatarInitialLabel.textColor = isDark
                ? RosettaColors.avatarTextColor(for: colorIdx)
                : RosettaColors.avatarColor(for: colorIdx)
            if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) {
                senderAvatarImageView.image = image
                senderAvatarImageView.isHidden = false
            } else {
                senderAvatarImageView.image = nil
                senderAvatarImageView.isHidden = true
            }
        } else {
            senderAvatarContainer.isHidden = true
        }

        // Reply icon (for swipe gesture) — positioned AFTER all bubble size adjustments
        // (sender name shift, etc.) so it's vertically centered on the final bubble.
        let replyIconDiameter: CGFloat = 34
        let replyIconX = bubbleView.frame.maxX - replyIconDiameter
        let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
        replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
        replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
    }

    private static func formattedDuration(seconds: Int) -> String {
        let safe = max(seconds, 0)
        let minutes = safe / 60
        let secs = safe % 60
        return String(format: "%d:%02d", minutes, secs)
    }

    /// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift).
    /// Parse voice preview: "tag::duration::waveform" or "duration::waveform"
    static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
        let parts = preview.components(separatedBy: "::")
        // Format: "tag::duration::waveform" or "duration::waveform" or "::duration::waveform"
        // Try 3+ parts: tag::duration::waveform (or leading empty ::duration::waveform)
        if parts.count >= 3 {
            // Check parts[1] first (tag::dur::wave), then parts[0] if that fails (edge cases)
            if let dur = Int(parts[1]) {
                return (TimeInterval(dur), parts.dropFirst(2).joined(separator: "::"))
            } else if let dur = Int(parts[0]) {
                return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::"))
            }
        }
        // 2 parts: duration::waveform
        if parts.count >= 2, let dur = Int(parts[0]) {
            return (TimeInterval(dur), parts.dropFirst(1).joined(separator: "::"))
        }
        // Single part: just duration (no waveform)
        if let dur = Int(parts[0]) {
            return (TimeInterval(dur), "")
        }
        return (0, "")
    }

    static func resolvePlayableVoiceURL(
        attachment: MessageAttachment,
        duration: TimeInterval,
        storedPassword: String?,
        onProgress: (@MainActor (Double) -> Void)? = nil
    ) async -> URL? {
        let fileName = "voice_\(Int(duration))s.m4a"
        if let cached = playableVoiceURLFromCache(attachmentId: attachment.id, fileName: fileName) {
            return cached
        }

        guard let downloaded = await downloadVoiceData(
            attachment: attachment,
            storedPassword: storedPassword,
            onProgress: onProgress
        ) else {
            return nil
        }
        _ = AttachmentCache.shared.saveFile(downloaded, forAttachmentId: attachment.id, fileName: fileName)
        return writePlayableVoiceTempFile(
            data: downloaded,
            attachmentId: attachment.id,
            fileName: fileName
        )
    }

    static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? {
        guard let decrypted = AttachmentCache.shared.loadFileData(
            forAttachmentId: attachmentId,
            fileName: fileName
        ) else {
            return nil
        }
        return writePlayableVoiceTempFile(data: decrypted, attachmentId: attachmentId, fileName: fileName)
    }

    private static func writePlayableVoiceTempFile(data: Data, attachmentId: String, fileName: String) -> URL? {
        let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("voice_play_\(attachmentId)_\(safeFileName)")
        try? FileManager.default.removeItem(at: tempURL)
        do {
            try data.write(to: tempURL, options: .atomic)
            return tempURL
        } catch {
            return nil
        }
    }

    private static func downloadVoiceData(
        attachment: MessageAttachment,
        storedPassword: String?,
        onProgress: (@MainActor (Double) -> Void)? = nil
    ) async -> Data? {
        let tag = attachment.effectiveDownloadTag
        guard !tag.isEmpty else { return nil }
        guard let storedPassword, !storedPassword.isEmpty else { return nil }

        do {
            let encryptedData = try await TransportManager.shared.downloadFile(
                tag: tag,
                server: attachment.transportServer,
                onProgress: onProgress
            )
            let encryptedString = String(decoding: encryptedData, as: UTF8.self)
            let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
            guard let decrypted = decryptAttachmentData(encryptedString: encryptedString, passwords: passwords) else {
                return nil
            }
            let rawData = parseAttachmentFileData(decrypted)

            // Desktop sends WebM/Opus — convert to M4A for iOS playback.
            // Transcoding (~200ms) runs off MainActor to avoid UI hitch.
            if WebMOpusConverter.isWebM(rawData) {
                return await Task.detached(priority: .userInitiated) {
                    WebMOpusConverter.convertToPlayable(rawData)
                }.value
            }
            return rawData
        } catch {
            return nil
        }
    }

    private static func decryptAttachmentData(encryptedString: String, passwords: [String]) -> Data? {
        let crypto = CryptoManager.shared
        for password in passwords {
            if let data = try? crypto.decryptWithPassword(
                encryptedString,
                password: password,
                requireCompression: true
            ) {
                return data
            }
        }
        for password in passwords {
            if let data = try? crypto.decryptWithPassword(encryptedString, password: password) {
                return data
            }
        }
        return nil
    }

    private static func parseAttachmentFileData(_ data: Data) -> Data {
        // 1. Data URI format (iOS images, files, legacy voice)
        if let string = String(data: data, encoding: .utf8),
           string.hasPrefix("data:"),
           let comma = string.firstIndex(of: ",") {
            let payload = String(string[string.index(after: comma)...])
            return Data(base64Encoded: payload) ?? data
        }
        // 2. Hex-encoded raw bytes (Desktop voice: Buffer.toString('hex'))
        if let string = String(data: data, encoding: .utf8),
           string.count >= 100,
           string.allSatisfy({ $0.isHexDigit }) {
            return Data(hexString: string)
        }
        // 3. Raw binary (fallback)
        return data
    }

    private static func fileIcon(for fileName: String) -> String {
        let ext = (fileName as NSString).pathExtension.lowercased()
        switch ext {
        case "pdf": return "doc.fill"
        case "zip", "rar", "7z": return "doc.zipper"
        case "jpg", "jpeg", "png", "gif": return "photo.fill"
        case "mp4", "mov", "avi": return "film.fill"
        case "mp3", "wav", "aac": return "waveform"
        default: return "doc.fill"
        }
    }

    private static func formattedFileSize(bytes: Int) -> String {
        if bytes < 1024 { return "\(bytes) B" }
        if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
        if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) }
        return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024))
    }

    /// Downloads avatar from CDN, decrypts, caches to disk, and returns the image.
    /// Shared logic with `MessageAvatarView.downloadAvatar()`.
    private static func downloadAndCacheAvatar(
        tag: String, attachmentId: String, storedPassword: String, senderKey: String,
        server: String = ""
    ) async -> UIImage? {
        do {
            let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
            let encryptedString = String(decoding: encryptedData, as: UTF8.self)

            let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
            guard let image = decryptAvatarImage(encryptedString: encryptedString, passwords: passwords)
            else { return nil }

            AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)

            // Android parity: save avatar to sender's profile after download
            if let jpegData = image.jpegData(compressionQuality: 0.85) {
                let base64 = jpegData.base64EncodedString()
                await MainActor.run {
                    AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
                }
            }
            return image
        } catch {
            return nil
        }
    }

    /// Tries each password candidate to decrypt avatar image data.
    private static func decryptAvatarImage(encryptedString: String, passwords: [String]) -> UIImage? {
        let crypto = CryptoManager.shared
        #if DEBUG
        print("[AVATAR-DBG] decryptAvatarImage blob=\(encryptedString.count) chars, \(passwords.count) candidates")
        #endif
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(
                encryptedString, password: password, requireCompression: true
            ) else {
                #if DEBUG
                print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → nil")
                #endif
                continue
            }
            #if DEBUG
            let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
            let utf8 = String(data: data.prefix(60), encoding: .utf8) ?? "<not utf8>"
            print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → \(data.count) bytes, hex=\(hex), utf8=\(utf8.prefix(60))")
            #endif
            if let img = parseAvatarImageData(data) { return img }
            #if DEBUG
            print("[AVATAR-DBG] parseAvatarImageData returned nil for requireCompression=true data")
            #endif
        }
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(
                encryptedString, password: password
            ) else {
                #if DEBUG
                print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → nil")
                #endif
                continue
            }
            #if DEBUG
            let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
            print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → \(data.count) bytes, hex=\(hex)")
            #endif
            if let img = parseAvatarImageData(data) { return img }
            #if DEBUG
            print("[AVATAR-DBG] parseAvatarImageData returned nil for noCompression data")
            #endif
        }
        #if DEBUG
        print("[AVATAR-DBG] ❌ All candidates failed")
        #endif
        return nil
    }

    /// Parses avatar image data (data URI or raw base64 or raw bytes).
    private static func parseAvatarImageData(_ data: Data) -> UIImage? {
        if let str = String(data: data, encoding: .utf8) {
            if str.hasPrefix("data:"),
               let commaIndex = str.firstIndex(of: ",") {
                let base64Part = String(str[str.index(after: commaIndex)...])
                if let imageData = Data(base64Encoded: base64Part),
                   let img = AttachmentCache.downsampledImage(from: imageData) {
                    return img
                }
            } else if let imageData = Data(base64Encoded: str),
                      let img = AttachmentCache.downsampledImage(from: imageData) {
                return img
            }
        }
        return AttachmentCache.downsampledImage(from: data)
    }

    // MARK: - Self-sizing (from pre-calculated layout)

    /// Effective height for external layout systems.
    var preferredHeight: CGFloat {
        currentLayout?.totalHeight ?? 50
    }

    // MARK: - Link Tap

    @objc private func handleLinkTap(_ gesture: UITapGestureRecognizer) {
        // In selection mode: any tap toggles selection
        if isInSelectionMode {
            guard let msgId = message?.id else { return }
            actions?.onToggleSelection(msgId)
            return
        }
        let pointInText = gesture.location(in: textLabel)
        // Check links first
        if let url = textLabel.textLayout?.linkAt(point: pointInText) {
            var finalURL = url
            if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true {
                finalURL = URL(string: "https://\(url.absoluteString)") ?? url
            }
            UIApplication.shared.open(finalURL)
            return
        }
        // Then check @mentions
        if let username = textLabel.textLayout?.mentionAt(point: pointInText) {
            actions?.onMentionTap(username) { [weak self] found in
                guard !found else { return }
                self?.shakeMentionLabel()
                UINotificationFeedbackGenerator().notificationOccurred(.error)
            }
            return
        }
    }

    @objc private func handleAvatarTap() {
        guard let key = message?.fromPublicKey, !key.isEmpty else { return }
        actions?.onAvatarTap(key)
    }

    // MARK: - Context Menu (Telegram-style)

    private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)

    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        // In selection mode: tap toggles selection instead of context menu
        if isInSelectionMode {
            guard let msgId = message?.id else { return }
            contextMenuHaptic.impactOccurred()
            actions?.onToggleSelection(msgId)
            return
        }
        contextMenuHaptic.impactOccurred()
        presentContextMenu()
    }

    private func presentContextMenu() {
        guard let message, let actions else { return }
        guard let layout = currentLayout else { return }

        // Capture snapshot from window (pixel-perfect, accounts for inverted scroll)
        guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }

        // Build bubble mask path
        let bubblePath = BubbleGeometryEngine.makeBezierPath(
            in: CGRect(origin: .zero, size: frame.size),
            mergeType: layout.mergeType,
            outgoing: layout.isOutgoing
        )

        // Build menu items
        let items = TelegramContextMenuBuilder.menuItems(
            for: message,
            actions: actions,
            isSavedMessages: isSavedMessages,
            isSystemAccount: isSystemAccount
        )

        TelegramContextMenuController.present(
            snapshot: snapshot,
            sourceFrame: frame,
            bubblePath: bubblePath,
            items: items,
            isOutgoing: layout.isOutgoing
        )
    }

    // MARK: - Swipe to Reply

    @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
        if isInSelectionMode { return }  // Disable swipe-to-reply in selection mode
        if isSavedMessages || isSystemAccount { return }
        let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false)
            || (currentLayout?.messageType == .groupInvite)
        if isReplyBlocked { return }

        let translation = gesture.translation(in: contentView)
        let threshold: CGFloat = 55
        let elasticCap: CGFloat = 85   // match SwiftUI SwipeToReplyModifier
        let backGestureEdge: CGFloat = 40

        switch gesture.state {
        case .began:
            // Record start position — reject if near left screen edge (iOS back gesture zone)
            let startPoint = gesture.location(in: contentView.window)
            swipeStartX = startPoint.x
            // Pre-warm haptic engine for instant response at threshold
            swipeHaptic.prepare()

        case .changed:
            // Reject gestures from back gesture zone (left 40pt)
            if let startX = swipeStartX, startX < backGestureEdge { return }

            // Telegram: ALL messages swipe LEFT
            let raw = min(translation.x, 0)
            guard raw < 0 else { return }

            // Elastic resistance past cap (Telegram rubber-band)
            let absRaw = abs(raw)
            let clamped: CGFloat
            if absRaw > elasticCap {
                clamped = -(elasticCap + (absRaw - elasticCap) * 0.15)
            } else {
                clamped = raw
            }

            bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
            // Move sender avatar with bubble during swipe (group chats)
            if !senderAvatarContainer.isHidden {
                senderAvatarContainer.transform = CGAffineTransform(translationX: clamped, y: 0)
            }

            // Icon progress: fade in from 4pt to threshold
            let absClamped = abs(clamped)
            let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0
            replyCircleView.alpha = progress
            replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress)
            replyIconView.alpha = progress
            replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)

            // Haptic at threshold crossing (once per gesture, pre-prepared)
            if absClamped >= threshold, !hasTriggeredSwipeHaptic {
                hasTriggeredSwipeHaptic = true
                swipeHaptic.impactOccurred()
            }

        case .ended, .cancelled:
            let shouldReply = abs(translation.x) >= threshold
            if shouldReply, let message, let actions {
                actions.onReply(message)
            }
            hasTriggeredSwipeHaptic = false
            swipeStartX = nil

            // Velocity-aware spring (Telegram passes swipe velocity for natural spring-back)
            let velocity = gesture.velocity(in: contentView)
            let currentOffset = bubbleView.transform.tx
            let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0
            let initialVelocity = CGVector(dx: relativeVx, dy: 0)

            let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity)
            let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)
            animator.addAnimations {
                self.bubbleView.transform = .identity
                self.senderAvatarContainer.transform = .identity
                self.replyCircleView.alpha = 0
                self.replyCircleView.transform = .identity
                self.replyIconView.alpha = 0
                self.replyIconView.transform = .identity
            }
            animator.startAnimation()

        default:
            break
        }
    }

    @objc private func handleDeliveryFailedTap() {
        guard let message, let actions else { return }
        actions.onRetry(message)
    }

    @objc private func callBackTapped() {
        guard let message, let actions else { return }
        let isOutgoing = currentLayout?.isOutgoing ?? false
        let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
        actions.onCall(peerKey)
    }

    @objc private func handleAttachmentDownload(_ notif: Notification) {
        guard let id = notif.object as? String, let message else { return }

        // Avatar download
        if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
           avatarAtt.id == id {
            if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
            let tag = avatarAtt.effectiveDownloadTag
            guard !tag.isEmpty else { return }
            guard let password = message.attachmentPassword, !password.isEmpty else {
                #if DEBUG
                print("[AVATAR-DBG] ❌ No attachmentPassword for avatar id=\(id)")
                #endif
                return
            }

            #if DEBUG
            print("[AVATAR-DBG] Starting download tag=\(tag.prefix(12))… pwd=\(password.prefix(8))… len=\(password.count)")
            #endif
            fileSizeLabel.text = "Downloading..."
            let messageId = message.id
            // Desktop parity: group avatar saves to group dialog key, not sender key
            // Desktop parity: in groups, avatar saves as group avatar.
            let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
                ? message.toPublicKey : message.fromPublicKey
            let server = avatarAtt.transportServer
            Task.detached(priority: .userInitiated) {
                let downloaded = await Self.downloadAndCacheAvatar(
                    tag: tag, attachmentId: id,
                    storedPassword: password, senderKey: avatarTargetKey,
                    server: server
                )
                await MainActor.run { [weak self] in
                    guard let self, self.message?.id == messageId else { return }
                    if let downloaded {
                        self.avatarImageView.image = downloaded
                        self.avatarImageView.isHidden = false
                        self.fileIconView.isHidden = true
                        let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
                        self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
                        // Trigger refresh of sender avatar circles in visible cells
                        NotificationCenter.default.post(
                            name: Notification.Name("avatarDidUpdate"), object: nil
                        )
                    } else {
                        self.fileSizeLabel.text = "Tap to retry"
                    }
                }
            }
            return
        }

        // File download (desktop parity: MessageFile.tsx download flow)
        if let fileAtt = message.attachments.first(where: { $0.type == .file }),
           fileAtt.id == id {
            let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
            let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName

            // Already cached? → share
            if let url = AttachmentCache.shared.fileURL(forAttachmentId: id, fileName: fileName) {
                shareFile(url)
                return
            }

            let tag = fileAtt.effectiveDownloadTag
            guard !tag.isEmpty else { return }
            guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
                fileSizeLabel.text = "File expired"
                return
            }

            fileSizeLabel.text = "Downloading..."
            fileIconSymbolView.isHidden = true
            fileProgressRing.show()
            let messageId = message.id
            let attId = fileAtt.id
            let server = fileAtt.transportServer
            fileProgressRing.onCancel = { [weak self] in
                self?.fileDownloadTask?.cancel()
                self?.fileDownloadTask = nil
                self?.fileProgressRing.hide()
                self?.fileIconSymbolView.image = UIImage(systemName: "arrow.down")
                self?.fileIconSymbolView.isHidden = false
                self?.fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
            }
            fileDownloadTask = Task { [weak self] in
                do {
                    let encryptedData = try await TransportManager.shared.downloadFile(
                        tag: tag, server: server,
                        onProgress: { [weak self] progress in
                            self?.fileProgressRing.setProgress(CGFloat(progress))
                        }
                    )
                    let encryptedString = String(decoding: encryptedData, as: UTF8.self)
                    let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)

                    // Try with compression first, then without (legacy)
                    var decrypted: Data?
                    for pw in passwords {
                        if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw, requireCompression: true) {
                            decrypted = d; break
                        }
                    }
                    if decrypted == nil {
                        for pw in passwords {
                            if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw) {
                                decrypted = d; break
                            }
                        }
                    }
                    guard let decrypted else { throw TransportError.invalidResponse }

                    // Parse data URI if present
                    let fileData: Data
                    if let str = String(data: decrypted, encoding: .utf8),
                       str.hasPrefix("data:"), let comma = str.firstIndex(of: ",") {
                        let b64 = String(str[str.index(after: comma)...])
                        fileData = Data(base64Encoded: b64) ?? decrypted
                    } else {
                        fileData = decrypted
                    }

                    let url = AttachmentCache.shared.saveFile(fileData, forAttachmentId: attId, fileName: fileName)
                    await MainActor.run { [weak self] in
                        guard let self, self.message?.id == messageId else { return }
                        self.fileDownloadTask = nil
                        self.fileProgressRing.hide()
                        self.fileIconSymbolView.isHidden = false
                        self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count)
                        self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName))
                        self.shareFile(url)
                    }
                } catch {
                    guard !Task.isCancelled else { return }
                    await MainActor.run { [weak self] in
                        guard let self, self.message?.id == messageId else { return }
                        self.fileDownloadTask = nil
                        self.fileProgressRing.hide()
                        self.fileIconSymbolView.image = UIImage(systemName: "arrow.down")
                        self.fileIconSymbolView.isHidden = false
                        self.fileSizeLabel.text = "File expired"
                    }
                }
            }
            return
        }
    }

    private func shareFile(_ cachedURL: URL) {
        guard let message else { return }
        // Files are stored encrypted (.enc) on disk. Decrypt to temp dir for sharing.
        guard let fileAtt = message.attachments.first(where: { $0.type == .file }) else { return }
        let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
        let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName

        guard let data = AttachmentCache.shared.loadFileData(forAttachmentId: fileAtt.id, fileName: fileName) else {
            fileSizeLabel.text = "File expired"
            return
        }
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
        try? data.write(to: tempURL, options: .atomic)

        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
        if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let window = scene.windows.first,
           let rootVC = window.rootViewController {
            var topVC = rootVC
            while let presented = topVC.presentedViewController { topVC = presented }
            if let popover = activityVC.popoverPresentationController {
                popover.sourceView = topVC.view
                popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0)
            }
            topVC.present(activityVC, animated: true)
        }
    }

    // MARK: - Mention Shake (invalid @username)

    /// Brief horizontal shake on the text label — Telegram parity for invalid @username.
    func shakeMentionLabel() {
        let anim = CAKeyframeAnimation(keyPath: "transform.translation.x")
        anim.values = [0, -6, 6, -4, 4, -2, 2, 0]
        anim.duration = 0.4
        anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        textLabel.layer.add(anim, forKey: "mentionShake")
    }

    // MARK: - Highlight (scroll-to-message flash)

    func showHighlight() {
        highlightOverlay.alpha = 0
        UIView.animate(withDuration: 0.2) {
            self.highlightOverlay.alpha = 1
        }
        // Auto-fade after 1.5s (Telegram parity)
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
            self?.hideHighlight()
        }
    }

    func hideHighlight() {
        UIView.animate(withDuration: 0.4) {
            self.highlightOverlay.alpha = 0
        }
    }

    @objc private func replyQuoteTapped() {
        guard let replyMessageId, let actions else { return }
        actions.onScrollToMessage(replyMessageId)
    }

    @objc private func fileContainerTapped() {
        if isInSelectionMode {
            guard let msgId = message?.id else { return }
            actions?.onToggleSelection(msgId)
            return
        }
        guard let message, let actions else { return }
        let isCallType = message.attachments.contains { $0.type == .call }
        if isCallType {
            // Tap anywhere on call bubble → call back
            let isOutgoing = currentLayout?.isOutgoing ?? false
            let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
            actions.onCall(peerKey)
        } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
            // Tap on avatar bubble → trigger download (Android parity)
            NotificationCenter.default.post(name: .triggerAttachmentDownload, object: avatarAtt.id)
        } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
            // Tap on file bubble → trigger download/share
            NotificationCenter.default.post(name: .triggerAttachmentDownload, object: fileAtt.id)
        } else if let layout = currentLayout, layout.isForward,
                  let fwdFile = layout.forwardAttachments.first(where: { $0.type == 2 }) {
            // Tap on forwarded file → download from CDN with forward chacha key, then share
            downloadAndOpenForwardedFile(replyAtt: fwdFile, chachaKeyPlain: layout.forwardChachaKeyPlain)
        }
    }

    private func downloadAndOpenForwardedFile(replyAtt: ReplyAttachmentData, chachaKeyPlain: String) {
        let parsed = AttachmentPreviewCodec.parseFilePreview(replyAtt.preview)
        let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName

        // Check cache first — decrypt from .enc and share
        if AttachmentCache.shared.fileURL(forAttachmentId: replyAtt.id, fileName: fileName) != nil {
            presentForwardedFileShare(attachmentId: replyAtt.id, fileName: fileName)
            return
        }

        let tag = replyAtt.effectiveDownloadTag
        guard !tag.isEmpty, !chachaKeyPlain.isEmpty else {
            #if DEBUG
            print("[FWD-FILE] SKIP: tag=\(replyAtt.effectiveDownloadTag.prefix(12)) chachaEmpty=\(chachaKeyPlain.isEmpty)")
            #endif
            return
        }

        Task { @MainActor in
            do {
                let server = replyAtt.transport.transport_server
                let encryptedData = try await TransportManager.shared.downloadFile(
                    tag: tag, server: server.isEmpty ? nil : server
                )
                let encryptedString = String(decoding: encryptedData, as: UTF8.self)

                // Use the ORIGINAL message's stored attachment password format
                // chacha_key_plain is the hex key from the original sender's attachmentPassword
                let storedPassword = "rawkey:\(chachaKeyPlain)"
                let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)

                #if DEBUG
                print("[FWD-FILE] tag=\(tag.prefix(12))… chachaKey=\(chachaKeyPlain.prefix(16))… passwords=\(passwords.count) encSize=\(encryptedData.count)")
                #endif

                // Use exact same decrypt method as MessageFileView
                let crypto = CryptoManager.shared
                var decryptedData: Data?

                // 1. Try with requireCompression (primary — prevents wrong-password garbage)
                for password in passwords {
                    if let data = try? crypto.decryptWithPassword(
                        encryptedString, password: password, requireCompression: true
                    ) {
                        #if DEBUG
                        print("[FWD-FILE] decrypted with compression: \(data.count) bytes")
                        #endif
                        decryptedData = data
                        break
                    }
                }

                // 2. Fallback: without compression (legacy uncompressed payloads ONLY)
                if decryptedData == nil {
                    for password in passwords {
                        if let data = try? crypto.decryptWithPassword(encryptedString, password: password) {
                            #if DEBUG
                            print("[FWD-FILE] decrypted WITHOUT compression: \(data.count) bytes — may be garbage!")
                            #endif
                            decryptedData = data
                            break
                        }
                    }
                }

                guard let decryptedData else {
                    #if DEBUG
                    print("[FWD-FILE] ALL decrypt attempts failed")
                    #endif
                    return
                }

                // Parse data URI if present (e.g. "data:text/html;base64,..."), otherwise use raw
                let fileData: Data
                if let decryptedString = String(data: decryptedData, encoding: .utf8),
                   decryptedString.hasPrefix("data:"),
                   let commaIndex = decryptedString.firstIndex(of: ",") {
                    let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
                    fileData = Data(base64Encoded: base64Part, options: .ignoreUnknownCharacters) ?? decryptedData
                    #if DEBUG
                    print("[FWD-FILE] data URI detected, base64 decoded: \(fileData.count) bytes")
                    #endif
                } else {
                    fileData = decryptedData
                }

                AttachmentCache.shared.saveFile(fileData, forAttachmentId: replyAtt.id, fileName: fileName)
                self.presentForwardedFileShare(attachmentId: replyAtt.id, fileName: fileName)
                self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName))
            } catch {
                #if DEBUG
                print("[FWD-FILE] download failed: \(error)")
                #endif
            }
        }
    }

    /// Shares a forwarded file: decrypts from .enc cache, writes to temp dir with original name.
    private func presentForwardedFileShare(attachmentId: String, fileName: String) {
        // Files in AttachmentCache are encrypted at rest (.enc). Decrypt before sharing.
        guard let data = AttachmentCache.shared.loadFileData(
            forAttachmentId: attachmentId, fileName: fileName
        ) else { return }
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
        try? data.write(to: tempURL, options: .atomic)

        guard let vc = findViewController() else { return }
        let activity = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
        vc.present(activity, animated: true)
    }

    private func findViewController() -> UIViewController? {
        var responder: UIResponder? = self
        while let next = responder?.next {
            if let vc = next as? UIViewController { return vc }
            responder = next
        }
        return nil
    }

    @objc private func handlePhotoTileTap(_ sender: UIButton) {
        // In selection mode: any tap toggles selection
        if isInSelectionMode {
            guard let msgId = message?.id else { return }
            actions?.onToggleSelection(msgId)
            return
        }
        guard sender.tag >= 0, sender.tag < photoAttachments.count,
              let message,
              let actions else {
            return
        }

        let attachment = photoAttachments[sender.tag]
        let imageView = photoTileImageViews[sender.tag]
        let sourceFrame = imageView.convert(imageView.bounds, to: nil)

        if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
            actions.onImageTap(attachment.id, sourceFrame, imageView)
            return
        }

        Task { [weak self] in
            await ImageLoadLimiter.shared.acquire()
            let loaded = await Task.detached(priority: .userInitiated) {
                AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
            }.value
            await ImageLoadLimiter.shared.release()
            guard !Task.isCancelled else { return }
            await MainActor.run {
                guard let self,
                      self.message?.id == message.id else {
                    return
                }
                if loaded != nil {
                    actions.onImageTap(attachment.id, sourceFrame, imageView)
                } else {
                    self.downloadPhotoAttachment(attachment: attachment, message: message)
                }
            }
        }
    }

    private func configurePhoto(for message: ChatMessage) {
        guard let layout = currentLayout, layout.hasPhoto else {
            resetPhotoTiles()
            return
        }

        let allPhotoAttachments = message.attachments.filter { $0.type == .image }
        totalPhotoAttachmentCount = allPhotoAttachments.count
        photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles))
        guard !photoAttachments.isEmpty else {
            resetPhotoTiles()
            return
        }

        let activeIds = Set(photoAttachments.map(\.id))
        for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) {
            task.cancel()
            photoLoadTasks.removeValue(forKey: attachmentId)
        }
        for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) {
            task.cancel()
            photoDownloadTasks.removeValue(forKey: attachmentId)
            downloadingAttachmentIds.remove(attachmentId)
            failedAttachmentIds.remove(attachmentId)
        }
        for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) {
            task.cancel()
            photoBlurHashTasks.removeValue(forKey: attachmentId)
        }

        for index in 0..<photoTileImageViews.count {
            let isActiveTile = index < photoAttachments.count
            let imageView = photoTileImageViews[index]
            let placeholderView = photoTilePlaceholderViews[index]
            let ring = photoTileProgressRings[index]
            let errorView = photoTileErrorViews[index]
            let downloadArrow = photoTileDownloadArrows[index]
            let button = photoTileButtons[index]

            button.isHidden = !isActiveTile

            guard isActiveTile else {
                imageView.image = nil
                imageView.isHidden = true
                placeholderView.isHidden = true
                ring.hide()
                errorView.isHidden = true
                downloadArrow.isHidden = true
                continue
            }

            let attachment = photoAttachments[index]
            let isUploading = layout.isOutgoing && message.deliveryStatus == .waiting
            if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
                failedAttachmentIds.remove(attachment.id)
                // During upload: show blurhash if available, then reveal full image on completion.
                // This gives visual feedback that upload is in progress (Telegram UX parity).
                if isUploading {
                    if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
                        setPhotoTileImage(blur, at: index, animated: false)
                    } else {
                        // Blurhash not decoded yet — show full image, start async decode
                        setPhotoTileImage(cached, at: index, animated: false)
                        startPhotoBlurHashTask(attachment: attachment)
                    }
                } else {
                    setPhotoTileImage(cached, at: index, animated: false)
                }
                placeholderView.isHidden = true
                ring.hide()
                errorView.isHidden = true
                downloadArrow.isHidden = true
            } else {
                if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
                    setPhotoTileImage(blur, at: index, animated: false)
                } else {
                    setPhotoTileImage(nil, at: index, animated: false)
                    startPhotoBlurHashTask(attachment: attachment)
                }
                placeholderView.isHidden = imageView.image != nil
                let hasFailed = failedAttachmentIds.contains(attachment.id)
                if hasFailed {
                    ring.hide()
                    errorView.isHidden = false
                    downloadArrow.isHidden = true
                } else if downloadingAttachmentIds.contains(attachment.id) {
                    ring.show()
                    errorView.isHidden = true
                    downloadArrow.isHidden = true
                } else {
                    // Not downloaded, not downloading — show download arrow
                    ring.hide()
                    errorView.isHidden = true
                    downloadArrow.isHidden = false
                }
                startPhotoLoadTask(attachment: attachment)
            }
        }
        layoutPhotoOverflowOverlay()
        let hasActiveUpload = TransportManager.uploadProgress[message.id] != nil
        let shouldShow = layout.isOutgoing && (message.deliveryStatus == .waiting || hasActiveUpload)
        if shouldShow {
            updatePhotoUploadingOverlay(isVisible: true)
            if uploadOverlayShowTime == 0 { uploadOverlayShowTime = CACurrentMediaTime() }
        } else {
            // Ring hides either here (deliveryStatus changed) or via "completed"
            // notification in the observer. No 2-second guard needed.
            updatePhotoUploadingOverlay(isVisible: false)
            uploadOverlayShowTime = 0
        }
    }

    /// Downloads forwarded images from CDN using forwardChachaKeyPlain for decryption.
    /// Converts ReplyAttachmentData images → MessageAttachment and reuses existing photo pipeline.
    private func configureForwardedPhotos() {
        guard let layout = currentLayout, layout.isForward else {
            resetPhotoTiles()
            return
        }
        let imageAtts = layout.forwardAttachments.filter { $0.type == 0 } // AttachmentType.image
        guard !imageAtts.isEmpty, layout.hasPhoto else {
            resetPhotoTiles()
            return
        }

        // Convert ReplyAttachmentData → MessageAttachment for the existing photo tile pipeline
        let converted: [MessageAttachment] = imageAtts.map { reply in
            MessageAttachment(
                id: reply.id,
                preview: reply.preview,
                blob: "",
                type: .image,
                transportTag: reply.transport.transport_tag,
                transportServer: reply.transport.transport_server
            )
        }
        totalPhotoAttachmentCount = converted.count
        photoAttachments = Array(converted.prefix(Self.maxVisiblePhotoTiles))
        guard !photoAttachments.isEmpty else {
            resetPhotoTiles()
            return
        }

        // Clean up old tasks
        let activeIds = Set(photoAttachments.map(\.id))
        for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) {
            task.cancel()
            photoLoadTasks.removeValue(forKey: attachmentId)
        }
        for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) {
            task.cancel()
            photoDownloadTasks.removeValue(forKey: attachmentId)
            downloadingAttachmentIds.remove(attachmentId)
            failedAttachmentIds.remove(attachmentId)
        }

        let chachaKey = layout.forwardChachaKeyPlain

        for index in 0..<photoTileImageViews.count {
            let isActiveTile = index < photoAttachments.count
            let imageView = photoTileImageViews[index]
            let placeholderView = photoTilePlaceholderViews[index]
            let ring = photoTileProgressRings[index]
            let errorView = photoTileErrorViews[index]
            let downloadArrow = photoTileDownloadArrows[index]
            let button = photoTileButtons[index]

            button.isHidden = !isActiveTile
            guard isActiveTile else {
                imageView.image = nil
                imageView.isHidden = true
                placeholderView.isHidden = true
                ring.hide()
                errorView.isHidden = true
                downloadArrow.isHidden = true
                continue
            }

            let attachment = photoAttachments[index]
            if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
                setPhotoTileImage(cached, at: index, animated: false)
                placeholderView.isHidden = true
                ring.hide()
                errorView.isHidden = true
                downloadArrow.isHidden = true
            } else {
                // Show blurhash placeholder
                if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
                    setPhotoTileImage(blur, at: index, animated: false)
                } else {
                    setPhotoTileImage(nil, at: index, animated: false)
                    startPhotoBlurHashTask(attachment: attachment)
                }
                placeholderView.isHidden = imageView.image != nil
                ring.show()
                errorView.isHidden = true
                downloadArrow.isHidden = true

                // Download from CDN using forwarded chacha key
                downloadForwardedPhoto(attachment: attachment, chachaKeyPlain: chachaKey)
            }
        }
        layoutPhotoOverflowOverlay()
        updatePhotoUploadingOverlay(isVisible: false)
    }

    /// Downloads a single forwarded photo from CDN, decrypts with chacha_key_plain.
    private func downloadForwardedPhoto(attachment: MessageAttachment, chachaKeyPlain: String) {
        let attachmentId = attachment.id
        guard photoDownloadTasks[attachmentId] == nil else { return }
        let tag = attachment.effectiveDownloadTag
        guard !tag.isEmpty, !chachaKeyPlain.isEmpty else {
            failedAttachmentIds.insert(attachmentId)
            return
        }

        downloadingAttachmentIds.insert(attachmentId)
        let server = attachment.transportServer

        photoDownloadTasks[attachmentId] = Task { [weak self] in
            do {
                let encryptedData = try await TransportManager.shared.downloadFile(
                    tag: tag, server: server.isEmpty ? nil : server
                )
                let encryptedString = String(decoding: encryptedData, as: UTF8.self)
                let passwords = MessageCrypto.attachmentPasswordCandidates(from: "rawkey:\(chachaKeyPlain)")
                let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
                await MainActor.run {
                    guard let self else { return }
                    self.photoDownloadTasks.removeValue(forKey: attachmentId)
                    self.downloadingAttachmentIds.remove(attachmentId)
                    guard let tileIndex = self.tileIndex(for: attachmentId),
                          tileIndex < self.photoTileImageViews.count else { return }
                    if let image {
                        self.failedAttachmentIds.remove(attachmentId)
                        AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
                        self.setPhotoTileImage(image, at: tileIndex, animated: true)
                        self.photoTilePlaceholderViews[tileIndex].isHidden = true
                        self.photoTileErrorViews[tileIndex].isHidden = true
                    } else {
                        self.failedAttachmentIds.insert(attachmentId)
                        self.photoTileErrorViews[tileIndex].isHidden = false
                    }
                    self.photoTileProgressRings[tileIndex].hide()
                    self.photoTileDownloadArrows[tileIndex].isHidden = true
                }
            } catch {
                await MainActor.run {
                    guard let self else { return }
                    self.photoDownloadTasks.removeValue(forKey: attachmentId)
                    self.downloadingAttachmentIds.remove(attachmentId)
                    self.failedAttachmentIds.insert(attachmentId)
                    if let tileIndex = self.tileIndex(for: attachmentId),
                       tileIndex < self.photoTileErrorViews.count {
                        self.photoTileProgressRings[tileIndex].hide()
                        self.photoTileErrorViews[tileIndex].isHidden = false
                    }
                }
            }
        }
    }

    // MARK: - Forward Item Photos (items 1+)

    /// Configures photo tiles for an additional forward item subview.
    private func configureForwardItemPhotos(subview: ForwardItemSubview, info: ForwardItemInfo, photoFrame: CGRect) {
        let imageAtts = info.attachments.filter { $0.type == 0 }
        guard !imageAtts.isEmpty, photoFrame.height > 0 else { return }

        let count = min(imageAtts.count, Self.maxVisiblePhotoTiles)
        subview.ensurePhotoTiles(count: count)
        subview.photoAttachmentIds = imageAtts.prefix(count).map(\.id)

        // Layout tiles using pre-calculated photoFrame dimensions
        let containerBounds = CGRect(x: 0, y: 0, width: photoFrame.width, height: photoFrame.height)
        let tileFrames = Self.photoTileFrames(count: count, in: containerBounds)
        for (index, frame) in tileFrames.enumerated() where index < subview.photoImageViews.count {
            subview.photoImageViews[index].frame = frame
            subview.photoImageViews[index].isHidden = false
            if index < subview.photoTileButtons.count {
                subview.photoTileButtons[index].frame = frame
                subview.photoTileButtons[index].isHidden = false
            }
        }
        // Hide excess tiles
        for index in count..<subview.photoImageViews.count {
            subview.photoImageViews[index].isHidden = true
            if index < subview.photoTileButtons.count {
                subview.photoTileButtons[index].isHidden = true
            }
        }

        // Tap handler: open fullscreen image viewer
        subview.onPhotoTap = { [weak self, weak subview] tileIndex in
            guard let self, let subview,
                  tileIndex < subview.photoAttachmentIds.count else { return }
            let attachmentId = subview.photoAttachmentIds[tileIndex]
            let imageView = subview.photoImageViews[tileIndex]
            let sourceFrame = imageView.convert(imageView.bounds, to: nil)
            self.actions?.onImageTap(attachmentId, sourceFrame, imageView)
        }

        let chachaKey = info.chachaKeyPlain
        for (index, att) in imageAtts.prefix(count).enumerated() {
            let imageView = subview.photoImageViews[index]
            if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: att.id) {
                imageView.image = cached
            } else {
                // Blurhash placeholder
                if let blur = Self.cachedBlurHashImage(from: att.preview) {
                    imageView.image = blur
                }
                downloadForwardItemPhoto(subview: subview, attachment: att, tileIndex: index, chachaKeyPlain: chachaKey)
            }
        }
    }

    /// Downloads a single photo for an additional forward item subview.
    private func downloadForwardItemPhoto(subview: ForwardItemSubview, attachment: ReplyAttachmentData, tileIndex: Int, chachaKeyPlain: String) {
        let attachmentId = attachment.id
        guard subview.photoDownloadTasks[attachmentId] == nil else { return }
        let tag = attachment.effectiveDownloadTag
        guard !tag.isEmpty, !chachaKeyPlain.isEmpty else { return }

        let server = attachment.transport.transport_server

        subview.photoDownloadTasks[attachmentId] = Task { [weak subview] in
            do {
                let encryptedData = try await TransportManager.shared.downloadFile(
                    tag: tag, server: server.isEmpty ? nil : server
                )
                let encryptedString = String(decoding: encryptedData, as: UTF8.self)
                let passwords = MessageCrypto.attachmentPasswordCandidates(from: "rawkey:\(chachaKeyPlain)")
                let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
                await MainActor.run {
                    guard let subview else { return }
                    subview.photoDownloadTasks.removeValue(forKey: attachmentId)
                    guard tileIndex < subview.photoImageViews.count else { return }
                    if let image {
                        AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
                        UIView.transition(
                            with: subview.photoImageViews[tileIndex],
                            duration: 0.18,
                            options: .transitionCrossDissolve,
                            animations: { subview.photoImageViews[tileIndex].image = image }
                        )
                    }
                }
            } catch {
                await MainActor.run {
                    subview?.photoDownloadTasks.removeValue(forKey: attachmentId)
                }
            }
        }
    }

    private func layoutPhotoTiles() {
        guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
        updatePhotoContainerMask(layout: layout)
        let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
        for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
            photoTileImageViews[index].frame = frame
            photoTilePlaceholderViews[index].frame = frame
            photoTileButtons[index].frame = frame
            photoTileProgressRings[index].center = CGPoint(x: frame.midX, y: frame.midY)
            photoTileErrorViews[index].frame = CGRect(
                x: frame.midX - 10, y: frame.midY - 10,
                width: 20, height: 20
            )
            // Download arrow: full tile frame, circle centered
            let arrow = photoTileDownloadArrows[index]
            arrow.frame = frame
            if let circle = arrow.viewWithTag(1001) {
                circle.center = CGPoint(x: frame.width / 2, y: frame.height / 2)
            }
        }
        photoUploadingOverlayView.frame = photoContainer.bounds
        photoUploadingRing.center = CGPoint(
            x: photoContainer.bounds.midX,
            y: photoContainer.bounds.midY
        )
        photoContainer.bringSubviewToFront(photoUploadingOverlayView)
        photoContainer.bringSubviewToFront(photoUploadingRing)
        photoContainer.bringSubviewToFront(photoOverflowOverlayView)
        layoutPhotoOverflowOverlay(frames: frames)
        applyPhotoLastTileMask(frames: frames, layout: layout)
    }

    private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
        guard let layout = layout ?? currentLayout else {
            photoContainer.layer.mask = nil
            return
        }
        photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
            bounds: photoContainer.bounds,
            mergeType: layout.mergeType,
            outgoing: layout.isOutgoing
        )
    }

    private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
        guard !frames.isEmpty else { return }

        // Reset per-tile masks first.
        for index in 0..<photoTileImageViews.count {
            photoTileImageViews[index].layer.mask = nil
            photoTilePlaceholderViews[index].layer.mask = nil
            photoTileButtons[index].layer.mask = nil
        }
        photoOverflowOverlayView.layer.mask = nil

        let lastVisibleIndex = photoAttachments.count - 1
        guard lastVisibleIndex >= 0, lastVisibleIndex < frames.count else { return }
        let tileFrame = frames[lastVisibleIndex]
        guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask(
            tileFrame: tileFrame,
            containerBounds: photoContainer.bounds,
            mergeType: layout.mergeType,
            outgoing: layout.isOutgoing
        ) else {
            return
        }

        applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex])
        applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex])
        applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex])

        // Keep overflow badge clipping aligned with the same rounded corner.
        applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView)
    }

    private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) {
        guard let path = prototype.path else {
            view.layer.mask = nil
            return
        }
        let mask = CAShapeLayer()
        mask.frame = CGRect(origin: .zero, size: view.bounds.size)
        mask.path = path
        view.layer.mask = mask
    }

    private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
        let spacing: CGFloat = 2
        let width = bounds.width
        let height = bounds.height
        guard count > 0, width > 0, height > 0 else { return [] }

        switch count {
        case 1:
            return [bounds]
        case 2:
            let cellWidth = (width - spacing) / 2
            return [
                CGRect(x: 0, y: 0, width: cellWidth, height: height),
                CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: height)
            ]
        case 3:
            let rightWidth = width * 0.34
            let leftWidth = width - spacing - rightWidth
            let rightTopHeight = (height - spacing) / 2
            let rightBottomHeight = height - rightTopHeight - spacing
            return [
                CGRect(x: 0, y: 0, width: leftWidth, height: height),
                CGRect(x: leftWidth + spacing, y: 0, width: rightWidth, height: rightTopHeight),
                CGRect(x: leftWidth + spacing, y: rightTopHeight + spacing, width: rightWidth, height: rightBottomHeight)
            ]
        case 4:
            let cellWidth = (width - spacing) / 2
            let topHeight = (height - spacing) / 2
            let bottomHeight = height - topHeight - spacing
            return [
                CGRect(x: 0, y: 0, width: cellWidth, height: topHeight),
                CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: topHeight),
                CGRect(x: 0, y: topHeight + spacing, width: cellWidth, height: bottomHeight),
                CGRect(x: cellWidth + spacing, y: topHeight + spacing, width: cellWidth, height: bottomHeight)
            ]
        default:
            let topCellWidth = (width - spacing) / 2
            let bottomCellWidth = (width - spacing * 2) / 3
            var topHeight = min(topCellWidth * 0.85, 176)
            var bottomHeight = min(bottomCellWidth * 0.85, 144)
            let expectedHeight = topHeight + spacing + bottomHeight
            if expectedHeight > 0 {
                let scale = height / expectedHeight
                topHeight *= scale
                bottomHeight *= scale
            }
            return [
                CGRect(x: 0, y: 0, width: topCellWidth, height: topHeight),
                CGRect(x: topCellWidth + spacing, y: 0, width: topCellWidth, height: topHeight),
                CGRect(x: 0, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
                CGRect(x: bottomCellWidth + spacing, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight),
                CGRect(x: (bottomCellWidth + spacing) * 2, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight)
            ]
        }
    }

    private func setPhotoTileImage(_ image: UIImage?, at index: Int, animated: Bool) {
        guard index >= 0, index < photoTileImageViews.count else { return }
        let imageView = photoTileImageViews[index]
        let update = {
            imageView.image = image
        }
        if animated, image != nil, imageView.image != nil {
            UIView.transition(
                with: imageView,
                duration: 0.18,
                options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction],
                animations: update
            )
        } else {
            update()
        }
        imageView.isHidden = image == nil
    }

    private func layoutPhotoOverflowOverlay(frames: [CGRect]? = nil) {
        let overflowCount = totalPhotoAttachmentCount - photoAttachments.count
        guard overflowCount > 0,
              !photoAttachments.isEmpty else {
            photoOverflowOverlayView.isHidden = true
            photoOverflowLabel.isHidden = true
            photoOverflowLabel.text = nil
            return
        }

        let lastVisibleIndex = photoAttachments.count - 1
        guard lastVisibleIndex >= 0, lastVisibleIndex < photoTileImageViews.count else {
            photoOverflowOverlayView.isHidden = true
            photoOverflowLabel.isHidden = true
            photoOverflowLabel.text = nil
            return
        }

        let frame: CGRect
        if let frames, lastVisibleIndex < frames.count {
            frame = frames[lastVisibleIndex]
        } else {
            frame = photoTileImageViews[lastVisibleIndex].frame
        }

        guard frame.width > 0, frame.height > 0 else {
            photoOverflowOverlayView.isHidden = true
            photoOverflowLabel.isHidden = true
            photoOverflowLabel.text = nil
            return
        }

        photoOverflowOverlayView.frame = frame
        photoOverflowOverlayView.isHidden = false
        photoOverflowLabel.isHidden = false
        photoOverflowLabel.text = "+\(overflowCount)"
        photoOverflowLabel.font = UIFont.systemFont(
            ofSize: max(18, min(frame.height * 0.34, 34)),
            weight: .semibold
        )
        photoOverflowLabel.frame = photoOverflowOverlayView.bounds
    }

    private var uploadProgressObserver: NSObjectProtocol?
    private var uploadOverlayShowTime: TimeInterval = 0

    private func updatePhotoUploadingOverlay(isVisible: Bool) {
        photoUploadingOverlayView.isHidden = !isVisible
        if isVisible {
            photoUploadingRing.show()
            // Cancel upload: prevent WebSocket send + delete the waiting message
            photoUploadingRing.onCancel = { [weak self] in
                guard let self, let msgId = self.message?.id else { return }
                self.uploadOverlayShowTime = 0
                SessionManager.shared.cancelOutgoingSend(messageId: msgId)
                MessageRepository.shared.deleteMessage(id: msgId)
                if let opponentKey = self.message?.toPublicKey {
                    DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
                }
            }
            // Subscribe to real upload progress (reads from notification userInfo
            // to avoid race with TransportManager.uploadProgress dict cleanup)
            if uploadProgressObserver == nil {
                uploadProgressObserver = NotificationCenter.default.addObserver(
                    forName: TransportManager.uploadProgressNotification,
                    object: nil, queue: .main
                ) { [weak self] notif in
                    guard let self,
                          let msgId = notif.object as? String,
                          msgId == self.message?.id,
                          let progress = notif.userInfo?["progress"] as? Double else { return }

                    if notif.userInfo?["completed"] as? Bool == true {
                        // Upload done → show 100% then hide ring
                        self.photoUploadingRing.setProgress(1.0)
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
                            self?.updatePhotoUploadingOverlay(isVisible: false)
                            self?.uploadOverlayShowTime = 0
                        }
                    } else {
                        self.photoUploadingRing.setProgress(CGFloat(progress))
                    }
                }
            }
        } else {
            photoUploadingRing.hide()
            if let observer = uploadProgressObserver {
                NotificationCenter.default.removeObserver(observer)
                uploadProgressObserver = nil
            }
        }
    }

    // MARK: - File Upload/Download Progress

    private func subscribeFileUploadProgress() {
        guard fileUploadProgressObserver == nil else { return }
        fileUploadProgressObserver = NotificationCenter.default.addObserver(
            forName: TransportManager.uploadProgressNotification,
            object: nil, queue: .main
        ) { [weak self] notif in
            guard let self,
                  let msgId = notif.object as? String,
                  msgId == self.message?.id,
                  let progress = TransportManager.uploadProgress[msgId] else { return }
            let visual = min(CGFloat(progress) * 0.85, 0.85)
            self.fileProgressRing.setProgress(visual)
        }
        fileProgressRing.onCancel = { [weak self] in
            guard let self, let msgId = self.message?.id else { return }
            SessionManager.shared.cancelOutgoingSend(messageId: msgId)
            MessageRepository.shared.deleteMessage(id: msgId)
            if let opponentKey = self.message?.toPublicKey {
                DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
            }
        }
    }

    private func unsubscribeFileUploadProgress() {
        if let observer = fileUploadProgressObserver {
            NotificationCenter.default.removeObserver(observer)
            fileUploadProgressObserver = nil
        }
        fileProgressRing.onCancel = nil
    }

    func startFileDownload(attachment: MessageAttachment, password: String, fileName: String) {
        guard fileDownloadTask == nil else { return }
        let tag = attachment.effectiveDownloadTag
        guard !tag.isEmpty else { return }

        fileIconSymbolView.isHidden = true
        fileProgressRing.show()
        fileSizeLabel.text = "Downloading..."

        let attachmentId = attachment.id
        fileDownloadTask = Task { [weak self] in
            do {
                let encryptedData = try await TransportManager.shared.downloadFile(
                    tag: tag, server: attachment.transportServer,
                    onProgress: { [weak self] progress in
                        self?.fileProgressRing.setProgress(CGFloat(progress))
                    }
                )
                let encryptedString = String(decoding: encryptedData, as: UTF8.self)
                let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
                var fileData: Data?
                for pwd in passwords {
                    if let d = try? CryptoManager.shared.decryptWithPassword(
                        encryptedString, password: pwd, requireCompression: true
                    ) {
                        // Parse data URI if present
                        if let str = String(data: d, encoding: .utf8),
                           str.hasPrefix("data:"),
                           let comma = str.firstIndex(of: ",") {
                            fileData = Data(base64Encoded: String(str[str.index(after: comma)...])) ?? d
                        } else {
                            fileData = d
                        }
                        break
                    }
                }
                guard let fileData else { throw TransportError.invalidResponse }
                let _ = AttachmentCache.shared.saveFile(
                    fileData, forAttachmentId: attachmentId, fileName: fileName
                )
                await MainActor.run { [weak self] in
                    guard let self else { return }
                    self.fileDownloadTask = nil
                    self.fileProgressRing.hide()
                    self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName))
                    self.fileIconSymbolView.isHidden = false
                    self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count)
                }
            } catch {
                guard !Task.isCancelled else { return }
                await MainActor.run { [weak self] in
                    guard let self else { return }
                    self.fileDownloadTask = nil
                    self.fileProgressRing.hide()
                    self.fileIconSymbolView.image = UIImage(systemName: "arrow.down")
                    self.fileIconSymbolView.isHidden = false
                    self.fileSizeLabel.text = "Download failed"
                }
            }
        }

        fileProgressRing.onCancel = { [weak self] in
            self?.fileDownloadTask?.cancel()
            self?.fileDownloadTask = nil
            self?.fileProgressRing.hide()
            self?.fileIconSymbolView.image = UIImage(systemName: "arrow.down")
            self?.fileIconSymbolView.isHidden = false
            self?.fileSizeLabel.text = Self.formattedFileSize(bytes: 0)
        }
    }

    private func tileIndex(for attachmentId: String) -> Int? {
        photoAttachments.firstIndex(where: { $0.id == attachmentId })
    }

    private func startPhotoBlurHashTask(attachment: MessageAttachment) {
        let attachmentId = attachment.id
        guard photoBlurHashTasks[attachmentId] == nil else { return }
        let hash = Self.extractBlurHash(from: attachment.preview)
        guard !hash.isEmpty else { return }
        if let cached = Self.blurHashCache.object(forKey: hash as NSString) {
            if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count {
                setPhotoTileImage(cached, at: tileIndex, animated: false)
                photoTilePlaceholderViews[tileIndex].isHidden = true
            }
            return
        }

        photoBlurHashTasks[attachmentId] = Task { [weak self] in
            let decoded = await Task.detached(priority: .background) {
                UIImage.fromBlurHash(hash, width: 48, height: 48)
            }.value
            guard !Task.isCancelled else { return }
            await MainActor.run {
                guard let self else { return }
                self.photoBlurHashTasks.removeValue(forKey: attachmentId)
                guard let decoded,
                      let tileIndex = self.tileIndex(for: attachmentId),
                      tileIndex < self.photoTileImageViews.count else {
                    return
                }
                Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
                // For uploading outgoing: replace full image with blurhash.
                // For downloading incoming: only set if no image yet.
                let isUploading = self.currentLayout?.isOutgoing == true
                    && self.message?.deliveryStatus == .waiting
                guard isUploading || self.photoTileImageViews[tileIndex].image == nil else { return }
                self.setPhotoTileImage(decoded, at: tileIndex, animated: false)
                self.photoTilePlaceholderViews[tileIndex].isHidden = true
            }
        }
    }

    private func startPhotoLoadTask(attachment: MessageAttachment) {
        if photoLoadTasks[attachment.id] != nil { return }
        let attachmentId = attachment.id
        photoLoadTasks[attachmentId] = Task { [weak self] in
            await ImageLoadLimiter.shared.acquire()
            let loaded = await Task.detached(priority: .userInitiated) {
                AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
            }.value
            await ImageLoadLimiter.shared.release()
            guard !Task.isCancelled else { return }
            await MainActor.run {
                guard let self else { return }
                self.photoLoadTasks.removeValue(forKey: attachmentId)
                guard let tileIndex = self.tileIndex(for: attachmentId),
                      tileIndex < self.photoTileImageViews.count,
                      let loaded else {
                    return
                }
                self.failedAttachmentIds.remove(attachmentId)
                self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
                self.photoTilePlaceholderViews[tileIndex].isHidden = true
                self.photoTileProgressRings[tileIndex].hide()
                self.photoTileErrorViews[tileIndex].isHidden = true
                self.photoTileDownloadArrows[tileIndex].isHidden = true
            }
        }
    }

    private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
        if photoDownloadTasks[attachment.id] != nil { return }
        let tag = attachment.effectiveDownloadTag
        #if DEBUG
        print("[PHOTO-DBG] downloadPhoto tag=\(tag.prefix(12))… pwd=\(message.attachmentPassword?.prefix(8) ?? "nil") len=\(message.attachmentPassword?.count ?? 0)")
        #endif
        guard !tag.isEmpty,
              let storedPassword = message.attachmentPassword,
              !storedPassword.isEmpty else {
            failedAttachmentIds.insert(attachment.id)
            if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count {
                photoTileProgressRings[tileIndex].hide()
                photoTileErrorViews[tileIndex].isHidden = false
                photoTileDownloadArrows[tileIndex].isHidden = true
            }
            return
        }

        let attachmentId = attachment.id
        failedAttachmentIds.remove(attachmentId)
        downloadingAttachmentIds.insert(attachmentId)
        if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileProgressRings.count {
            photoTileProgressRings[tileIndex].show()
            photoTileErrorViews[tileIndex].isHidden = true
            photoTileDownloadArrows[tileIndex].isHidden = true
            if tileIndex < photoTileButtons.count {
                photoTileButtons[tileIndex].isUserInteractionEnabled = false
            }
            photoContainer.bringSubviewToFront(photoTileProgressRings[tileIndex])
            photoTileProgressRings[tileIndex].onCancel = { [weak self] in
                self?.photoDownloadTasks[attachmentId]?.cancel()
                self?.photoDownloadTasks.removeValue(forKey: attachmentId)
                self?.downloadingAttachmentIds.remove(attachmentId)
                if let tileIdx = self?.tileIndex(for: attachmentId),
                   tileIdx < self?.photoTileProgressRings.count ?? 0 {
                    self?.photoTileProgressRings[tileIdx].hide()
                    self?.photoTileDownloadArrows[tileIdx].isHidden = false
                    if tileIdx < self?.photoTileButtons.count ?? 0 {
                        self?.photoTileButtons[tileIdx].isUserInteractionEnabled = true
                    }
                }
            }
        }

        let server = attachment.transportServer
        photoDownloadTasks[attachmentId] = Task { [weak self] in
            // PERF: concurrent CDN downloads are capped by TransportLimiter (max 4) inside
            // TransportManager.downloadFile(). Do NOT use ImageLoadLimiter here — it holds
            // a slot (max 3) for the entire download duration (seconds), starving fast
            // disk loads in startPhotoLoadTask() and causing cached photos to lag.
            do {
                let encryptedData = try await TransportManager.shared.downloadFile(
                    tag: tag, server: server,
                    onProgress: { [weak self] progress in
                        guard let self,
                              let tileIndex = self.tileIndex(for: attachmentId),
                              tileIndex < self.photoTileProgressRings.count else { return }
                        self.photoTileProgressRings[tileIndex].setProgress(CGFloat(progress))
                    }
                )
                let encryptedString = String(decoding: encryptedData, as: UTF8.self)
                let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
                let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
                await MainActor.run {
                    guard let self else { return }
                    self.photoDownloadTasks.removeValue(forKey: attachmentId)
                    self.downloadingAttachmentIds.remove(attachmentId)
                    guard let tileIndex = self.tileIndex(for: attachmentId),
                          tileIndex < self.photoTileImageViews.count else {
                        return
                    }
                    if let image {
                        self.failedAttachmentIds.remove(attachmentId)
                        AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
                        self.setPhotoTileImage(image, at: tileIndex, animated: true)
                        self.photoTilePlaceholderViews[tileIndex].isHidden = true
                        self.photoTileErrorViews[tileIndex].isHidden = true
                    } else {
                        self.failedAttachmentIds.insert(attachmentId)
                        self.photoTileErrorViews[tileIndex].isHidden = false
                    }
                    self.photoTileProgressRings[tileIndex].hide()
                    self.photoTileDownloadArrows[tileIndex].isHidden = true
                    if tileIndex < self.photoTileButtons.count {
                        self.photoTileButtons[tileIndex].isUserInteractionEnabled = true
                    }
                }
            } catch {
                guard !Task.isCancelled else { return }
                await MainActor.run {
                    guard let self else { return }
                    self.photoDownloadTasks.removeValue(forKey: attachmentId)
                    self.downloadingAttachmentIds.remove(attachmentId)
                    self.failedAttachmentIds.insert(attachmentId)
                    guard let tileIndex = self.tileIndex(for: attachmentId),
                          tileIndex < self.photoTileProgressRings.count else {
                        return
                    }
                    self.photoTileProgressRings[tileIndex].hide()
                    self.photoTileErrorViews[tileIndex].isHidden = false
                    self.photoTileDownloadArrows[tileIndex].isHidden = true
                    if tileIndex < self.photoTileButtons.count {
                        self.photoTileButtons[tileIndex].isUserInteractionEnabled = true
                    }
                }
            }
        }
    }

    private func resetPhotoTiles() {
        photoAttachments.removeAll()
        totalPhotoAttachmentCount = 0
        for task in photoLoadTasks.values {
            task.cancel()
        }
        photoLoadTasks.removeAll()
        for task in photoDownloadTasks.values {
            task.cancel()
        }
        photoDownloadTasks.removeAll()
        for task in photoBlurHashTasks.values {
            task.cancel()
        }
        photoBlurHashTasks.removeAll()
        downloadingAttachmentIds.removeAll()
        failedAttachmentIds.removeAll()
        photoContainer.layer.mask = nil
        updatePhotoUploadingOverlay(isVisible: false)
        photoOverflowOverlayView.isHidden = true
        photoOverflowLabel.isHidden = true
        photoOverflowLabel.text = nil

        for index in 0..<photoTileImageViews.count {
            photoTileImageViews[index].image = nil
            photoTileImageViews[index].isHidden = true
            photoTileImageViews[index].layer.mask = nil
            photoTilePlaceholderViews[index].isHidden = true
            photoTilePlaceholderViews[index].layer.mask = nil
            photoTileProgressRings[index].hide()
            photoTileErrorViews[index].isHidden = true
            photoTileDownloadArrows[index].isHidden = true
            photoTileButtons[index].isHidden = true
            photoTileButtons[index].layer.mask = nil
        }
        photoOverflowOverlayView.layer.mask = nil
    }

    private static func extractTag(from preview: String) -> String {
        AttachmentPreviewCodec.downloadTag(from: preview)
    }

    private static func extractBlurHash(from preview: String) -> String {
        AttachmentPreviewCodec.blurHash(from: preview)
    }

    private static func cachedBlurHashImage(from preview: String) -> UIImage? {
        let hash = extractBlurHash(from: preview)
        guard !hash.isEmpty else { return nil }
        return blurHashCache.object(forKey: hash as NSString)
    }

    private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
        let crypto = CryptoManager.shared
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(
                encryptedString, password: password, requireCompression: true
            ) else { continue }
            if let image = parseImageData(data) { return image }
        }
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(encryptedString, password: password) else { continue }
            if let image = parseImageData(data) { return image }
        }
        return nil
    }

    private static func parseImageData(_ data: Data) -> UIImage? {
        if let str = String(data: data, encoding: .utf8) {
            if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") {
                let base64Part = String(str[str.index(after: commaIndex)...])
                if let imageData = Data(base64Encoded: base64Part),
                   let image = AttachmentCache.downsampledImage(from: imageData) {
                    return image
                }
            } else if let imageData = Data(base64Encoded: str),
                      let image = AttachmentCache.downsampledImage(from: imageData) {
                return image
            }
        }
        return AttachmentCache.downsampledImage(from: data)
    }

    private func startSendingClockAnimation() {
        if clockFrameView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil {
            let frameRotation = CABasicAnimation(keyPath: "transform.rotation.z")
            frameRotation.duration = 6.0
            frameRotation.fromValue = NSNumber(value: Float(0))
            frameRotation.toValue = NSNumber(value: Float(Double.pi * 2.0))
            frameRotation.repeatCount = .infinity
            frameRotation.timingFunction = CAMediaTimingFunction(name: .linear)
            frameRotation.beginTime = 1.0
            clockFrameView.layer.add(frameRotation, forKey: Self.sendingClockAnimationKey)
        }
        if clockMinView.layer.animation(forKey: Self.sendingClockAnimationKey) == nil {
            let minRotation = CABasicAnimation(keyPath: "transform.rotation.z")
            minRotation.duration = 1.0
            minRotation.fromValue = NSNumber(value: Float(0))
            minRotation.toValue = NSNumber(value: Float(Double.pi * 2.0))
            minRotation.repeatCount = .infinity
            minRotation.timingFunction = CAMediaTimingFunction(name: .linear)
            minRotation.beginTime = 1.0
            clockMinView.layer.add(minRotation, forKey: Self.sendingClockAnimationKey)
        }
    }

    private func stopSendingClockAnimation() {
        clockFrameView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey)
        clockMinView.layer.removeAnimation(forKey: Self.sendingClockAnimationKey)
    }

    private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
        if isSentVisible && !wasSentCheckVisible {
            checkSentView.alpha = 1
            checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
            UIView.animate(
                withDuration: 0.1,
                delay: 0,
                options: [.curveEaseOut, .beginFromCurrentState]
            ) {
                self.checkSentView.transform = .identity
            }
        } else if !isSentVisible {
            checkSentView.alpha = 1
            checkSentView.transform = .identity
        }

        if isReadVisible && !wasReadCheckVisible {
            checkReadView.alpha = 1
            checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
            UIView.animate(
                withDuration: 0.1,
                delay: 0,
                options: [.curveEaseOut, .beginFromCurrentState]
            ) {
                self.checkReadView.transform = .identity
            }
        } else if !isReadVisible {
            checkReadView.alpha = 1
            checkReadView.transform = .identity
        }

        wasSentCheckVisible = isSentVisible
        wasReadCheckVisible = isReadVisible
    }

    private func updateStatusBackgroundVisibility() {
        guard let layout = currentLayout else {
            statusBackgroundView.isHidden = true
            return
        }
        // Telegram uses a dedicated status background on media messages.
        statusBackgroundView.isHidden = layout.messageType != .photo && layout.messageType != .emojiOnly
            && !layout.isForwardPhotoOnly
    }

    private func updateStatusBackgroundFrame() {
        guard !statusBackgroundView.isHidden else { return }
        var contentRect = timestampLabel.frame
        let statusNodes = [checkSentView, checkReadView, clockFrameView, clockMinView]
        for node in statusNodes where !node.isHidden {
            contentRect = contentRect.union(node.frame)
        }
        let insets = Self.statusBubbleInsets
        statusBackgroundView.frame = CGRect(
            x: contentRect.minX - insets.left,
            y: contentRect.minY - insets.top,
            width: contentRect.width + insets.left + insets.right,
            height: contentRect.height + insets.top + insets.bottom
        )
    }

    private func bringStatusOverlayToFront() {
        bubbleView.bringSubviewToFront(statusBackgroundView)
        bubbleView.bringSubviewToFront(timestampLabel)
        bubbleView.bringSubviewToFront(checkSentView)
        bubbleView.bringSubviewToFront(checkReadView)
        bubbleView.bringSubviewToFront(clockFrameView)
        bubbleView.bringSubviewToFront(clockMinView)
    }

    #if DEBUG
    private func assertStatusLaneFramesValid(layout: MessageCellLayout) {
        // emojiOnly has no visible bubble — status pill floats below emoji
        guard layout.messageType != .emojiOnly else { return }
        let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize)
        let frames = [
            ("timestamp", layout.timestampFrame),
            ("checkSent", layout.checkSentFrame),
            ("checkRead", layout.checkReadFrame),
            ("clock", layout.clockFrame)
        ]

        for (name, frame) in frames {
            assert(frame.origin.x.isFinite && frame.origin.y.isFinite
                   && frame.size.width.isFinite && frame.size.height.isFinite,
                   "Status frame \(name) has non-finite values: \(frame)")
            assert(frame.width >= 0 && frame.height >= 0,
                   "Status frame \(name) has negative size: \(frame)")
            guard !frame.isEmpty else { continue }
            let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0)
            assert(insetBounds.contains(frame),
                   "Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)")
        }
    }
    #endif

    // MARK: - Reuse

    func prepareForReuse() {
        uploadOverlayShowTime = 0
        if let observer = uploadProgressObserver {
            NotificationCenter.default.removeObserver(observer)
            uploadProgressObserver = nil
        }
        unsubscribeFileUploadProgress()
        fileDownloadTask?.cancel()
        fileDownloadTask = nil
        fileProgressRing.hide()
        layer.removeAnimation(forKey: "insertionSlide")
        layer.removeAnimation(forKey: "insertionMove")
        contentView.layer.removeAnimation(forKey: "insertionAlpha")
        layer.removeAnimation(forKey: "skeletonScale")
        layer.removeAnimation(forKey: "skeletonPositionX")
        layer.removeAnimation(forKey: "skeletonPositionY")
        layer.removeAnimation(forKey: "skeletonFadeIn")
        textLabel.layer.removeAnimation(forKey: "mentionShake")
        dateHeaderContainer.isHidden = true
        dateHeaderLabel.text = nil
        isInlineDateHeaderHidden = false
        message = nil
        actions = nil
        currentLayout = nil
        stopSendingClockAnimation()
        textLabel.textLayout = nil
        timestampLabel.text = nil
        checkSentView.image = nil
        checkSentView.isHidden = true
        checkSentView.alpha = 1
        checkSentView.transform = .identity
        checkReadView.image = nil
        checkReadView.isHidden = true
        checkReadView.alpha = 1
        checkReadView.transform = .identity
        clockFrameView.image = nil
        clockFrameView.isHidden = true
        clockMinView.image = nil
        clockMinView.isHidden = true
        wasSentCheckVisible = false
        wasReadCheckVisible = false
        statusBackgroundView.isHidden = true
        bubbleImageView.isHidden = false
        bubbleLayer.isHidden = false
        bubbleOutlineLayer.isHidden = false
        resetPhotoTiles()
        replyContainer.isHidden = true
        replyMessageId = nil
        highlightOverlay.alpha = 0
        fileContainer.isHidden = true
        voiceView.isHidden = true
        cleanupVoiceBlob()
        callArrowView.isHidden = true
        callBackButton.isHidden = true
        groupInviteContainer.isHidden = true
        groupInviteString = nil
        currentInviteStatus = .notJoined
        inviteStatusTask?.cancel()
        inviteStatusTask = nil
        avatarImageView.image = nil
        avatarImageView.isHidden = true
        fileIconView.isHidden = false
        fileNameLabel.isHidden = false
        fileSizeLabel.isHidden = false
        forwardLabel.isHidden = true
        forwardAvatarView.isHidden = true
        forwardNameLabel.isHidden = true
        for sv in additionalForwardViews { sv.resetPhotos(); sv.isHidden = true }
        senderNameLabel.isHidden = true
        senderAdminIconView.isHidden = true
        senderAvatarContainer.isHidden = true
        senderAvatarInitialLabel.backgroundColor = .clear
        senderAvatarImageView.image = nil
        photoContainer.isHidden = true
        bubbleView.transform = .identity
        senderAvatarContainer.transform = .identity
        replyCircleView.alpha = 0
        replyCircleView.transform = .identity
        replyIconView.alpha = 0
        replyIconView.transform = .identity
        hasTriggeredSwipeHaptic = false
        swipeStartX = nil
        deliveryFailedButton.isHidden = true
        deliveryFailedButton.alpha = 0
        isDeliveryFailedVisible = false
        // Selection: reset selected state on reuse, keep mode (same for all cells)
        isMessageSelected = false
        selectionCheckFill.isHidden = true
        selectionCheckmarkLayer.isHidden = true
    }

    // MARK: - Multi-Select

    func setSelectionMode(_ enabled: Bool, animated: Bool) {
        guard isInSelectionMode != enabled else { return }
        isInSelectionMode = enabled
        let newOffset: CGFloat = enabled ? 42 : 0

        if animated {
            selectionCheckContainer.isHidden = false
            let fromAlpha: Float = enabled ? 0 : 1
            let toAlpha: Float = enabled ? 1 : 0
            let slideFrom = enabled ? -42.0 : 0.0
            let slideTo = enabled ? 0.0 : -42.0

            // Telegram: 0.2s easeOut for checkbox fade + slide
            let alphaAnim = CABasicAnimation(keyPath: "opacity")
            alphaAnim.fromValue = fromAlpha
            alphaAnim.toValue = toAlpha
            alphaAnim.duration = 0.2
            alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
            alphaAnim.fillMode = .forwards
            alphaAnim.isRemovedOnCompletion = false
            selectionCheckContainer.layer.add(alphaAnim, forKey: "selectionAlpha")

            let posAnim = CABasicAnimation(keyPath: "position.x")
            posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom
            posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo
            posAnim.duration = 0.2
            posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
            selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide")

            selectionCheckContainer.layer.opacity = toAlpha

            // Telegram: 0.2s easeOut for content shift
            selectionOffset = newOffset
            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
                self.setNeedsLayout()
                self.layoutIfNeeded()
            } completion: { _ in
                if !enabled {
                    self.selectionCheckContainer.isHidden = true
                    self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionAlpha")
                    self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionSlide")
                    self.selectionCheckContainer.layer.opacity = 1
                }
            }
        } else {
            selectionOffset = newOffset
            selectionCheckContainer.isHidden = !enabled
            selectionCheckContainer.layer.opacity = enabled ? 1 : 0
            setNeedsLayout()
        }
    }

    func setMessageSelected(_ selected: Bool, animated: Bool) {
        guard isMessageSelected != selected else { return }
        isMessageSelected = selected
        selectionCheckFill.isHidden = !selected
        selectionCheckmarkLayer.isHidden = !selected
        selectionCheckBorder.isHidden = selected

        if animated && selected {
            // Telegram CheckNode: 3-stage scale 1→0.9→1.1→1 over 0.21s
            let anim = CAKeyframeAnimation(keyPath: "transform.scale")
            anim.values = [1.0, 0.9, 1.1, 1.0]
            anim.keyTimes = [0, 0.26, 0.62, 1.0]
            anim.duration = 0.21
            anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
            selectionCheckFill.add(anim, forKey: "checkBounce")
            selectionCheckmarkLayer.add(anim, forKey: "checkBounce")
        } else if animated && !selected {
            // Telegram CheckNode: 2-stage scale 1→0.9→1 over 0.15s
            let anim = CAKeyframeAnimation(keyPath: "transform.scale")
            anim.values = [1.0, 0.9, 1.0]
            anim.keyTimes = [0, 0.53, 1.0]
            anim.duration = 0.15
            anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
            selectionCheckContainer.layer.add(anim, forKey: "checkBounce")
        }
    }

    /// Called by NativeMessageList on every VoiceMessagePlayer progress tick for the active cell.
    func updateVoicePlayback(isPlaying: Bool, progress: CGFloat, currentTime: TimeInterval, duration: TimeInterval) {
        // Update main voice view (item 0)
        if !voiceView.isHidden {
            voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
            voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying)
            updateVoiceBlobState(isPlaying: isPlaying)
        }
        // Update forward item voice views (items 1+)
        let player = VoiceMessagePlayer.shared
        let isOut = currentLayout?.isOutgoing ?? false
        for sv in additionalForwardViews where !sv.isHidden && !sv.voiceView.isHidden {
            let isThis = sv.voicePlaybackMessageId == player.currentMessageId
            sv.voiceView.updatePlaybackState(
                isPlaying: isThis && player.isPlaying,
                progress: isThis ? CGFloat(player.progress) : 0
            )
            sv.voiceView.updateDurationDuringPlayback(
                currentTime: isThis ? player.currentTime : 0,
                totalDuration: isThis ? player.duration : 0,
                isPlaying: isThis && player.isPlaying
            )
            sv.updateBlobState(isPlaying: isThis && player.isPlaying, isOutgoing: isOut)
        }
    }

    // MARK: - Voice Blob (rendered in bubbleView, clipped to bubble bounds)

    private func updateVoiceBlobState(isPlaying: Bool) {
        if isPlaying {
            if voiceBlobView == nil {
                let blob = VoiceBlobView(
                    frame: .zero,
                    maxLevel: 0.3,
                    smallBlobRange: (min: 0, max: 0),
                    mediumBlobRange: (min: 0.7, max: 0.8),
                    bigBlobRange: (min: 0.8, max: 0.9)
                )
                let isDark = traitCollection.userInterfaceStyle == .dark
                let isOut = currentLayout?.isOutgoing ?? false
                let colors = RosettaColors.Voice.colors(isOutgoing: isOut, isDark: isDark)
                blob.setColor(colors.playButtonBg)

                // Even-odd mask to cut out the inner 44pt circle (ring only)
                let blobSize: CGFloat = 56
                let maskLayer = CAShapeLayer()
                let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize))
                let path = UIBezierPath(rect: fullRect)
                let innerDiameter: CGFloat = 44
                let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2,
                                          y: (blobSize - innerDiameter) / 2)
                path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin,
                                                        size: CGSize(width: innerDiameter, height: innerDiameter))))
                maskLayer.path = path.cgPath
                maskLayer.fillRule = .evenOdd
                blob.layer.mask = maskLayer

                // Insert below fileContainer so it's behind the play button
                bubbleView.insertSubview(blob, belowSubview: fileContainer)
                voiceBlobView = blob
            }
            // Force voiceView layout so playButton.frame is up-to-date
            voiceView.layoutIfNeeded()
            // Position blob centered on play button in bubbleView coords
            let playCenter = voiceView.convert(voiceView.playButtonCenter, to: bubbleView)
            let blobSize: CGFloat = 56
            voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2,
                                          y: playCenter.y - blobSize / 2,
                                          width: blobSize, height: blobSize)
            voiceBlobView?.startAnimating()
            voiceBlobView?.updateLevel(0.2)
        } else {
            voiceBlobView?.stopAnimating()
        }
    }

    private func cleanupVoiceBlob() {
        voiceBlobView?.stopAnimating()
        voiceBlobView?.removeFromSuperview()
        voiceBlobView = nil
    }

    func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
        guard !voiceView.isHidden else { return nil }
        return voiceView.convert(voiceView.bounds, to: window)
    }

    func bubbleFrameInWindow(_ window: UIWindow) -> CGRect {
        bubbleView.convert(bubbleView.bounds, to: window)
    }
}

// MARK: - UIGestureRecognizerDelegate

extension NativeMessageCell: UIGestureRecognizerDelegate {
    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
        if isInSelectionMode { return false }  // No swipe in selection mode

        // If touch is on the waveform during playback, let the waveform's scrub gesture win
        if !voiceView.isHidden, voiceView.isScrubbingEnabled {
            let pointInVoice = pan.location(in: voiceView)
            if voiceView.waveformFrame.contains(pointInVoice) {
                return false
            }
        }

        let velocity = pan.velocity(in: contentView)
        // Telegram: only left swipe (negative velocity.x), clear horizontal dominance
        return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
    }
}

// MARK: - Bubble Path Cache

/// Caches CGPath objects for bubble shapes to avoid recalculating Bezier paths every frame.
/// Telegram equivalent: PrincipalThemeEssentialGraphics caches bubble images.
final class BubblePathCache {
    static let shared = BubblePathCache()

    private let pathVersion = 9
    private var cache: [String: CGPath] = [:]

    func path(
        size: CGSize, origin: CGPoint,
        mergeType: BubbleMergeType,
        isOutgoing: Bool,
        metrics: BubbleMetrics
    ) -> CGPath {
        let key = [
            "v\(pathVersion)",
            "\(Int(size.width))x\(Int(size.height))",
            "ox\(Int(origin.x))",
            "oy\(Int(origin.y))",
            "\(mergeType)",
            "\(isOutgoing)",
            "r\(Int(metrics.mainRadius))",
            "m\(Int(metrics.auxiliaryRadius))",
            "t\(Int(metrics.tailProtrusion))",
        ].joined(separator: "_")
        if let cached = cache[key] { return cached }

        let rect = CGRect(origin: origin, size: size)
        let path = BubbleGeometryEngine.makeCGPath(
            in: rect,
            mergeType: mergeType,
            outgoing: isOutgoing,
            metrics: metrics
        )
        cache[key] = path

        // Evict if cache grows too large
        if cache.count > 200 {
            cache.removeAll()
        }

        return path
    }
}

// MARK: - ForwardItemSubview (additional forward items, Android parity)

/// Renders a single additional forwarded message inside a multi-forward bubble.
/// Contains: divider line, "Forwarded from" header, avatar, sender name, caption text.
final class ForwardItemSubview: UIView {
    private static let headerFont = UIFont.systemFont(ofSize: 14, weight: .regular)
    private static let nameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
    private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)

    let dividerLine = UIView()
    let headerLabel = UILabel()
    let avatarView = UIView()
    let avatarInitialLabel = UILabel()
    let avatarImageView = UIImageView()
    let nameLabel = UILabel()
    let textLabel = UILabel()

    // Photo tiles for forwarded images
    let photoContainer = UIView()
    private(set) var photoImageViews: [UIImageView] = []
    private(set) var photoTileButtons: [UIButton] = []
    var photoAttachmentIds: [String] = []
    var photoDownloadTasks: [String: Task<Void, Never>] = [:]
    var onPhotoTap: ((Int) -> Void)?  // tileIndex

    // Voice player for forwarded voice messages
    let voiceView = MessageVoiceView()
    var activeVoiceDownloadTask: Task<Void, Never>?
    private var voiceBlobView: VoiceBlobView?

    override init(frame: CGRect) {
        super.init(frame: frame)

        dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.12)
        addSubview(dividerLine)

        headerLabel.font = Self.headerFont
        headerLabel.text = "Forwarded from"
        addSubview(headerLabel)

        avatarView.layer.cornerRadius = 8
        avatarView.clipsToBounds = true
        addSubview(avatarView)

        avatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
        avatarInitialLabel.textColor = .white
        avatarInitialLabel.textAlignment = .center
        avatarView.addSubview(avatarInitialLabel)

        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.clipsToBounds = true
        avatarView.addSubview(avatarImageView)

        nameLabel.font = Self.nameFont
        addSubview(nameLabel)

        textLabel.font = Self.textFont
        textLabel.numberOfLines = 0
        addSubview(textLabel)

        photoContainer.clipsToBounds = true
        photoContainer.layer.cornerRadius = 10
        photoContainer.isHidden = true
        addSubview(photoContainer)

        voiceView.isHidden = true
        addSubview(voiceView)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError() }

    func configure(info: ForwardItemInfo, isOutgoing: Bool) {
        let accent: UIColor = isOutgoing ? .white : UIColor(red: 0.14, green: 0.54, blue: 0.9, alpha: 1)
        headerLabel.text = "Forwarded from"
        headerLabel.textColor = accent
        nameLabel.isHidden = false
        nameLabel.text = info.senderName
        nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
        nameLabel.textColor = accent
        dividerLine.isHidden = true // spacing only, no visible line

        textLabel.textColor = isOutgoing
            ? .white
            : UIColor { traits in
                traits.userInterfaceStyle == .dark ? .white : .darkText
            }
        let cleanCaption = info.caption.trimmingCharacters(in: .whitespacesAndNewlines)
        textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption
        textLabel.isHidden = cleanCaption.isEmpty

        // Avatar: real photo if available, otherwise Mantine "light" variant
        if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) {
            avatarImageView.image = avatarImage
            avatarImageView.isHidden = false
            avatarInitialLabel.isHidden = true
            avatarView.backgroundColor = .clear
        } else {
            avatarImageView.image = nil
            avatarImageView.isHidden = true
            avatarInitialLabel.isHidden = false
            avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased()
            let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey)
            let tint = RosettaColors.avatarColor(for: colorIndex)
            avatarView.backgroundColor = UIColor { traits in
                let isDark = traits.userInterfaceStyle == .dark
                let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark
                    ? (0x1A / 255.0, 0x1B / 255.0, 0x1E / 255.0)
                    : (1.0, 1.0, 1.0)
                let a: CGFloat = isDark ? 0.15 : 0.10
                var tR: CGFloat = 0, tG: CGFloat = 0, tB: CGFloat = 0, tA: CGFloat = 0
                tint.getRed(&tR, green: &tG, blue: &tB, alpha: &tA)
                return UIColor(
                    red: base.r * (1 - a) + tR * a,
                    green: base.g * (1 - a) + tG * a,
                    blue: base.b * (1 - a) + tB * a,
                    alpha: 1
                )
            }
            avatarInitialLabel.textColor = UIColor { traits in
                traits.userInterfaceStyle == .dark
                    ? RosettaColors.avatarTextColor(for: colorIndex)
                    : RosettaColors.avatarColor(for: colorIndex)
            }
        }
    }

    func ensurePhotoTiles(count: Int) {
        while photoImageViews.count < count {
            let index = photoImageViews.count
            let iv = UIImageView()
            iv.contentMode = .scaleAspectFill
            iv.clipsToBounds = true
            iv.isHidden = true
            photoContainer.addSubview(iv)
            photoImageViews.append(iv)

            let button = UIButton()
            button.tag = index
            button.addTarget(self, action: #selector(handlePhotoTileTap(_:)), for: .touchUpInside)
            photoContainer.addSubview(button)
            photoTileButtons.append(button)
        }
    }

    @objc private func handlePhotoTileTap(_ sender: UIButton) {
        onPhotoTap?(sender.tag)
    }

    func resetPhotos() {
        for task in photoDownloadTasks.values { task.cancel() }
        photoDownloadTasks.removeAll()
        photoAttachmentIds.removeAll()
        for iv in photoImageViews { iv.image = nil; iv.isHidden = true }
        for btn in photoTileButtons { btn.isHidden = true }
        photoContainer.isHidden = true
        onPhotoTap = nil
    }

    /// Unique playback ID for this forward item's voice (used by VoiceMessagePlayer).
    private(set) var voicePlaybackMessageId: String = ""

    func configureVoice(info: ForwardItemInfo, messageId: String, isOutgoing: Bool) {
        guard let voiceAtt = info.attachments.first(where: { $0.type == 5 }) else {
            resetVoice()
            return
        }
        let previewParts = NativeMessageCell.parseVoicePreview(voiceAtt.preview)
        let attachmentId = voiceAtt.id
        let duration = previewParts.duration
        let waveform = previewParts.waveform
        // Unique playback ID per forward item to avoid conflicts in VoiceMessagePlayer
        let playbackId = "\(messageId)_\(attachmentId)"
        voicePlaybackMessageId = playbackId

        voiceView.isHidden = false
        voiceView.configure(
            messageId: playbackId,
            attachmentId: attachmentId,
            preview: waveform,
            duration: duration,
            isOutgoing: isOutgoing
        )

        let storedPassword: String? = info.chachaKeyPlain.isEmpty
            ? nil : "rawkey:\(info.chachaKeyPlain)"
        let voiceFileName = "voice_\(Int(duration))s.m4a"
        let isCached = NativeMessageCell.playableVoiceURLFromCache(
            attachmentId: attachmentId, fileName: voiceFileName
        ) != nil
        voiceView.setDownloaded(isCached)

        let isCurrentVoice = VoiceMessagePlayer.shared.currentMessageId == playbackId
        voiceView.updatePlaybackState(
            isPlaying: isCurrentVoice && VoiceMessagePlayer.shared.isPlaying,
            progress: isCurrentVoice ? CGFloat(VoiceMessagePlayer.shared.progress) : 0
        )

        let convertedAtt = MessageAttachment(
            id: attachmentId, preview: voiceAtt.preview, blob: "",
            type: .voice,
            transportTag: voiceAtt.transport.transport_tag,
            transportServer: voiceAtt.transport.transport_server
        )

        voiceView.onPlayTapped = { [weak self] in
            guard let self else { return }
            if let cached = NativeMessageCell.playableVoiceURLFromCache(
                attachmentId: attachmentId, fileName: voiceFileName
            ) {
                self.voiceView.setDownloaded(true)
                VoiceMessagePlayer.shared.play(messageId: playbackId, fileURL: cached)
                return
            }
            self.voiceView.showDownloadProgress(0.027)
            let downloadTask = Task {
                let playableURL = await NativeMessageCell.resolvePlayableVoiceURL(
                    attachment: convertedAtt,
                    duration: duration,
                    storedPassword: storedPassword,
                    onProgress: { [weak self] progress in
                        guard let self, self.activeVoiceDownloadTask != nil else { return }
                        self.voiceView.showDownloadProgress(CGFloat(progress))
                    }
                )
                guard !Task.isCancelled else { return }
                if let url = playableURL {
                    self.voiceView.hideDownloadProgress()
                    self.voiceView.setDownloaded(true)
                    VoiceMessagePlayer.shared.play(messageId: playbackId, fileURL: url)
                } else {
                    self.voiceView.cancelDownloadProgress()
                }
            }
            self.activeVoiceDownloadTask = downloadTask
        }
        voiceView.onDownloadCancel = { [weak self] in
            self?.activeVoiceDownloadTask?.cancel()
            self?.voiceView.cancelDownloadProgress()
            self?.activeVoiceDownloadTask = nil
        }
    }

    func resetVoice() {
        activeVoiceDownloadTask?.cancel()
        activeVoiceDownloadTask = nil
        voiceView.isHidden = true
        voiceView.onPlayTapped = nil
        voiceView.onDownloadCancel = nil
        voiceBlobView?.stopAnimating()
        voiceBlobView?.removeFromSuperview()
        voiceBlobView = nil
    }

    func updateBlobState(isPlaying: Bool, isOutgoing: Bool) {
        guard !voiceView.isHidden else { return }
        if isPlaying {
            if voiceBlobView == nil {
                let blob = VoiceBlobView(
                    frame: .zero,
                    maxLevel: 0.3,
                    smallBlobRange: (min: 0, max: 0),
                    mediumBlobRange: (min: 0.7, max: 0.8),
                    bigBlobRange: (min: 0.8, max: 0.9)
                )
                let isDark = traitCollection.userInterfaceStyle == .dark
                let colors = RosettaColors.Voice.colors(isOutgoing: isOutgoing, isDark: isDark)
                blob.setColor(colors.playButtonBg)

                let blobSize: CGFloat = 56
                let maskLayer = CAShapeLayer()
                let fullRect = CGRect(origin: .zero, size: CGSize(width: blobSize, height: blobSize))
                let path = UIBezierPath(rect: fullRect)
                let innerDiameter: CGFloat = 44
                let innerOrigin = CGPoint(x: (blobSize - innerDiameter) / 2,
                                          y: (blobSize - innerDiameter) / 2)
                path.append(UIBezierPath(ovalIn: CGRect(origin: innerOrigin,
                                                        size: CGSize(width: innerDiameter, height: innerDiameter))))
                maskLayer.path = path.cgPath
                maskLayer.fillRule = .evenOdd
                blob.layer.mask = maskLayer

                insertSubview(blob, belowSubview: voiceView)
                voiceBlobView = blob
            }
            voiceView.layoutIfNeeded()
            let playCenter = voiceView.convert(voiceView.playButtonCenter, to: self)
            let blobSize: CGFloat = 56
            voiceBlobView?.frame = CGRect(x: playCenter.x - blobSize / 2,
                                          y: playCenter.y - blobSize / 2,
                                          width: blobSize, height: blobSize)
            voiceBlobView?.startAnimating()
            voiceBlobView?.updateLevel(0.2)
        } else {
            voiceBlobView?.stopAnimating()
        }
    }

    func applyFrames(itemFrame: ForwardItemFrame, bubbleWidth: CGFloat) {
        // Compute bounding box: from divider top to max of all content
        let topY = itemFrame.dividerY
        var bottomY = max(itemFrame.headerFrame.maxY, itemFrame.nameFrame.maxY)
        if itemFrame.avatarFrame.height > 0 { bottomY = max(bottomY, itemFrame.avatarFrame.maxY) }
        if itemFrame.textFrame.height > 0 { bottomY = max(bottomY, itemFrame.textFrame.maxY) }
        if itemFrame.photoFrame.height > 0 { bottomY = max(bottomY, itemFrame.photoFrame.maxY) }
        if itemFrame.voiceFrame.height > 0 { bottomY = max(bottomY, itemFrame.voiceFrame.maxY + 6) }

        // Set parent frame — positions this subview in bubble coords
        frame = CGRect(x: 0, y: topY, width: bubbleWidth, height: bottomY - topY)

        // All children in LOCAL coords (subtract topY)
        let dy = topY
        dividerLine.frame = CGRect(x: itemFrame.headerFrame.origin.x,
                                    y: 0,
                                    width: itemFrame.headerFrame.width,
                                    height: 1)

        headerLabel.frame = itemFrame.headerFrame.offsetBy(dx: 0, dy: -dy)
        avatarView.frame = itemFrame.avatarFrame.offsetBy(dx: 0, dy: -dy)
        avatarInitialLabel.frame = avatarView.bounds
        avatarImageView.frame = avatarView.bounds
        nameLabel.frame = itemFrame.nameFrame.offsetBy(dx: 0, dy: -dy)
        if itemFrame.textFrame.height > 0 {
            textLabel.frame = itemFrame.textFrame.offsetBy(dx: 0, dy: -dy)
        }

        // Photo container
        if itemFrame.photoFrame.height > 0 {
            photoContainer.isHidden = false
            photoContainer.frame = itemFrame.photoFrame.offsetBy(dx: 0, dy: -dy)
        } else {
            photoContainer.isHidden = true
        }

        // Voice player
        if itemFrame.voiceFrame.height > 0 {
            voiceView.isHidden = false
            voiceView.frame = itemFrame.voiceFrame.offsetBy(dx: 0, dy: -dy)
        } else {
            voiceView.isHidden = true
        }
    }
}

// MARK: - UICollectionView Wrapper

/// Thin UICollectionViewCell wrapper for NativeMessageCell (UIView).
/// Allows existing UICollectionView-based NativeMessageList to continue working
/// while NativeMessageCell itself is a plain UIView for RosettaListView.
final class NativeMessageCollectionCell: UICollectionViewCell {

    let messageView = NativeMessageCell(frame: .zero)

    override init(frame: CGRect) {
        super.init(frame: frame)
        messageView.frame = contentView.bounds
        messageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        contentView.addSubview(messageView)
        backgroundColor = .clear
        contentView.backgroundColor = .clear
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError() }

    override func prepareForReuse() {
        super.prepareForReuse()
        messageView.prepareForReuse()
    }

    override func preferredLayoutAttributesFitting(
        _ layoutAttributes: UICollectionViewLayoutAttributes
    ) -> UICollectionViewLayoutAttributes {
        let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
        attrs.size.height = messageView.preferredHeight
        return attrs
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        messageView.frame = contentView.bounds
    }
}
