import UIKit

/// 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).
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {

    // MARK: - Constants

    private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC
    private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
    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: 13, weight: .regular)
    private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
    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 fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
    private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
    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(.alwaysOriginal)
    }()
    // Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail)
    private static let bubbleImages = BubbleImageFactory.generate(
        outgoingColor: outgoingColor,
        incomingColor: incomingColor
    )
    private static let blurHashCache: NSCache<NSString, UIImage> = {
        let cache = NSCache<NSString, UIImage>()
        cache.countLimit = 200
        return cache
    }()

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

    // 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()

    // Photo collage (up to 5 tiles)
    private let photoContainer = UIView()
    private var photoTileImageViews: [UIImageView] = []
    private var photoTilePlaceholderViews: [UIView] = []
    private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
    private var photoTileErrorViews: [UIImageView] = []
    private var photoTileDownloadArrows: [UIView] = []
    private var photoTileButtons: [UIButton] = []
    private let photoUploadingOverlayView = UIView()
    private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium)
    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 fileNameLabel = UILabel()
    private let fileSizeLabel = UILabel()

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

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

    // Forward header
    private let forwardLabel = UILabel()
    private let forwardAvatarView = UIView()
    private let forwardNameLabel = UILabel()

    // 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 var currentLayout: MessageCellLayout?
    var isSavedMessages = false
    var isSystemAccount = 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> = []

    // MARK: - Init

    override init(frame: CGRect) {
        super.init(frame: frame)
        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

        // 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.12
        bubbleLayer.shadowRadius = 0.6
        bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
        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
        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)
        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 indicator = UIActivityIndicatorView(style: .medium)
            indicator.color = .white
            indicator.hidesWhenStopped = true
            indicator.isHidden = true
            photoContainer.addSubview(indicator)

            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)
            photoTileActivityIndicators.append(indicator)
            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)

        photoUploadingIndicator.color = .white
        photoUploadingIndicator.hidesWhenStopped = true
        photoUploadingIndicator.isHidden = true
        photoContainer.addSubview(photoUploadingIndicator)

        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)
        fileNameLabel.font = Self.fileNameFont
        fileNameLabel.textColor = .white
        fileContainer.addSubview(fileNameLabel)
        fileSizeLabel.font = Self.fileSizeFont
        fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
        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)

        bubbleView.addSubview(fileContainer)

        // 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 message"
        forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
        bubbleView.addSubview(forwardLabel)

        forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
        forwardAvatarView.layer.cornerRadius = 10
        bubbleView.addSubview(forwardAvatarView)

        forwardNameLabel.font = Self.forwardNameFont
        forwardNameLabel.textColor = .white
        bubbleView.addSubview(forwardNameLabel)

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

        replyIconView.image = Self.telegramReplyArrowImage
        replyIconView.contentMode = .scaleAspectFit
        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)

        // Interactions
        let contextMenu = UIContextMenuInteraction(delegate: self)
        bubbleView.addInteraction(contextMenu)

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

    // MARK: - Configure + Apply Layout

    /// 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,
        forwardSenderName: String? = nil
    ) {
        self.message = message
        self.actions = actions

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

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

        // Timestamp
        timestampLabel.text = timestamp
        if isMediaStatus {
            timestampLabel.textColor = .white
        } else {
            timestampLabel.textColor = isOutgoing
                ? UIColor.white.withAlphaComponent(0.55)
                : UIColor.white.withAlphaComponent(0.6)
        }

        // 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
                checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage
                if message.isRead {
                    checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage
                    shouldShowReadCheck = true
                }
            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
        if let replyName {
            replyContainer.isHidden = false
            replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
            replyNameLabel.text = replyName
            replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
            replyTextLabel.text = replyText ?? ""
            replyTextLabel.textColor = isOutgoing
                ? UIColor.white.withAlphaComponent(0.8)
                : UIColor.white.withAlphaComponent(0.6)
        } else {
            replyContainer.isHidden = true
        }

        // Forward
        if let forwardSenderName {
            forwardLabel.isHidden = false
            forwardAvatarView.isHidden = false
            forwardNameLabel.isHidden = false
            forwardNameLabel.text = forwardSenderName
        } else {
            forwardLabel.isHidden = true
            forwardAvatarView.isHidden = true
            forwardNameLabel.isHidden = true
        }

        // Photo
        configurePhoto(for: message)

        // File
        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)
                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 = UIColor.white.withAlphaComponent(0.6)
                }

                // 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 fileAtt = message.attachments.first(where: { $0.type == .file }) {
                let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
                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 = parsed.fileName
                fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
                fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
                callArrowView.isHidden = true
                callBackButton.isHidden = true
            } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
                fileNameLabel.font = Self.fileNameFont
                fileNameLabel.text = "Avatar"
                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 = "Shared profile photo"
                    fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
                } else {
                    let isOutgoing = currentLayout?.isOutgoing ?? false
                    if isOutgoing {
                        // Own avatar — already uploaded, just loading from disk
                        fileSizeLabel.text = "Shared profile photo"
                    } else {
                        // Incoming avatar — needs download on tap (Android parity)
                        fileSizeLabel.text = "Tap to download"
                    }
                    fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
                    // 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
                                self.fileSizeLabel.text = "Shared profile photo"
                            }
                        }
                        // CDN download is triggered by user tap via .triggerAttachmentDownload
                    }
                }
            } else {
                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"
                fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
                callArrowView.isHidden = true
                callBackButton.isHidden = true
            }
        } else {
            fileContainer.isHidden = true
        }
    }

    /// 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 }

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

        // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
        let bubbleX: CGFloat
        if layout.isOutgoing {
            bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
        } else {
            bubbleX = tailProtrusion + 2
        }

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

        // ── 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
        )

        // ── 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 {
            bubbleLayer.shadowOpacity = 0.04
            bubbleLayer.shadowRadius = 0.4
            bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2)
            bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
        } else if layout.hasTail {
            bubbleLayer.shadowOpacity = 0.12
            bubbleLayer.shadowRadius = 0.6
            bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
            bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
        } else {
            bubbleLayer.shadowOpacity = 0.12
            bubbleLayer.shadowRadius = 0.6
            bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
            bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent(
                layout.isOutgoing ? 0.16 : 0.22
            ).cgColor
        }

        // 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 {
                // File layout: vertically centered icon + title + size
                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)
                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
            }
        }

        // Forward
        if layout.isForward {
            forwardLabel.frame = layout.forwardHeaderFrame
            forwardAvatarView.frame = layout.forwardAvatarFrame
            forwardNameLabel.frame = layout.forwardNameFrame
        }

        // 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 (for swipe gesture) — positioned behind bubble's trailing edge.
        // Starts hidden (alpha=0, scale=0). As bubble slides left via transform,
        // the icon is revealed in the gap between shifted bubble and original position.
        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)
    }

    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
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(
                encryptedString, password: password, requireCompression: true
            ) else { continue }
            if let img = parseAvatarImageData(data) { return img }
        }
        for password in passwords {
            guard let data = try? crypto.decryptWithPassword(
                encryptedString, password: password
            ) else { continue }
            if let img = parseAvatarImageData(data) { return img }
        }
        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)

    override func preferredLayoutAttributesFitting(
        _ layoutAttributes: UICollectionViewLayoutAttributes
    ) -> UICollectionViewLayoutAttributes {
        // Always return concrete height — never fall to super (expensive self-sizing)
        let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
        attrs.size.height = currentLayout?.totalHeight ?? 50
        return attrs
    }

    // MARK: - Context Menu

    func contextMenuInteraction(
        _ interaction: UIContextMenuInteraction,
        configurationForMenuAtLocation location: CGPoint
    ) -> UIContextMenuConfiguration? {
        guard let message, let actions else { return nil }
        return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
            var items: [UIAction] = []
            if !message.text.isEmpty {
                items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
                    actions.onCopy(message.text)
                })
            }
            // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
            let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
            let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded
            if canReplyForward {
                items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
                    actions.onReply(message)
                })
                items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
                    actions.onForward(message)
                })
            }
            items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
                actions.onDelete(message)
            })
            return UIMenu(children: items)
        }
    }

    // MARK: - Swipe to Reply

    @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
        // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
        if isSavedMessages || isSystemAccount { return }
        let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) ?? false
        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)

            // 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.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,
              let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
              avatarAtt.id == id else { return }
        // Already downloaded?
        if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
        // Download from CDN
        let tag = avatarAtt.effectiveDownloadTag
        guard !tag.isEmpty else { return }
        guard let password = message.attachmentPassword, !password.isEmpty else { return }

        // Show loading state
        fileSizeLabel.text = "Downloading..."

        let messageId = message.id
        let senderKey = message.fromPublicKey
        let server = avatarAtt.transportServer
        Task.detached(priority: .userInitiated) {
            let downloaded = await Self.downloadAndCacheAvatar(
                tag: tag, attachmentId: id,
                storedPassword: password, senderKey: senderKey,
                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
                    self.fileSizeLabel.text = "Shared profile photo"
                } else {
                    self.fileSizeLabel.text = "Tap to retry"
                }
            }
        }
    }

    @objc private func fileContainerTapped() {
        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)
        }
    }

    @objc private func handlePhotoTileTap(_ sender: UIButton) {
        guard sender.tag >= 0, sender.tag < photoAttachments.count,
              let message,
              let actions else {
            return
        }

        let attachment = photoAttachments[sender.tag]
        if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
            actions.onImageTap(attachment.id)
            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)
                } 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 indicator = photoTileActivityIndicators[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
                indicator.stopAnimating()
                indicator.isHidden = true
                errorView.isHidden = true
                downloadArrow.isHidden = true
                continue
            }

            let attachment = photoAttachments[index]
            if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
                failedAttachmentIds.remove(attachment.id)
                setPhotoTileImage(cached, at: index, animated: false)
                placeholderView.isHidden = true
                indicator.stopAnimating()
                indicator.isHidden = true
                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 {
                    indicator.stopAnimating()
                    indicator.isHidden = true
                    errorView.isHidden = false
                    downloadArrow.isHidden = true
                } else if downloadingAttachmentIds.contains(attachment.id) {
                    indicator.startAnimating()
                    indicator.isHidden = false
                    errorView.isHidden = true
                    downloadArrow.isHidden = true
                } else {
                    // Not downloaded, not downloading — show download arrow
                    indicator.stopAnimating()
                    indicator.isHidden = true
                    errorView.isHidden = true
                    downloadArrow.isHidden = false
                }
                startPhotoLoadTask(attachment: attachment)
            }
        }
        layoutPhotoOverflowOverlay()
        updatePhotoUploadingOverlay(
            isVisible: layout.isOutgoing && message.deliveryStatus == .waiting
        )
    }

    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
            photoTileActivityIndicators[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
        photoUploadingIndicator.center = CGPoint(
            x: photoContainer.bounds.midX,
            y: photoContainer.bounds.midY
        )
        photoContainer.bringSubviewToFront(photoUploadingOverlayView)
        photoContainer.bringSubviewToFront(photoUploadingIndicator)
        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 func updatePhotoUploadingOverlay(isVisible: Bool) {
        photoUploadingOverlayView.isHidden = !isVisible
        if isVisible {
            photoUploadingIndicator.isHidden = false
            photoUploadingIndicator.startAnimating()
        } else {
            photoUploadingIndicator.stopAnimating()
            photoUploadingIndicator.isHidden = true
        }
    }

    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: .utility) {
                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)
                // Do not override already loaded real image.
                guard 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.photoTileActivityIndicators[tileIndex].stopAnimating()
                self.photoTileActivityIndicators[tileIndex].isHidden = true
                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
        guard !tag.isEmpty,
              let storedPassword = message.attachmentPassword,
              !storedPassword.isEmpty else {
            failedAttachmentIds.insert(attachment.id)
            if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count {
                photoTileActivityIndicators[tileIndex].stopAnimating()
                photoTileActivityIndicators[tileIndex].isHidden = true
                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 < photoTileActivityIndicators.count {
            photoTileActivityIndicators[tileIndex].startAnimating()
            photoTileActivityIndicators[tileIndex].isHidden = false
            photoTileErrorViews[tileIndex].isHidden = true
            photoTileDownloadArrows[tileIndex].isHidden = true
        }

        let server = attachment.transportServer
        photoDownloadTasks[attachmentId] = Task { [weak self] in
            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)
                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.photoTileActivityIndicators[tileIndex].stopAnimating()
                    self.photoTileActivityIndicators[tileIndex].isHidden = true
                    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)
                    guard let tileIndex = self.tileIndex(for: attachmentId),
                          tileIndex < self.photoTileActivityIndicators.count else {
                        return
                    }
                    self.photoTileActivityIndicators[tileIndex].stopAnimating()
                    self.photoTileActivityIndicators[tileIndex].isHidden = true
                    self.photoTileErrorViews[tileIndex].isHidden = false
                    self.photoTileDownloadArrows[tileIndex].isHidden = 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
            photoTileActivityIndicators[index].stopAnimating()
            photoTileActivityIndicators[index].isHidden = true
            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
    }

    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) {
        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

    override func prepareForReuse() {
        super.prepareForReuse()
        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
        resetPhotoTiles()
        replyContainer.isHidden = true
        fileContainer.isHidden = true
        callArrowView.isHidden = true
        callBackButton.isHidden = true
        avatarImageView.image = nil
        avatarImageView.isHidden = true
        fileIconView.isHidden = false
        forwardLabel.isHidden = true
        forwardAvatarView.isHidden = true
        forwardNameLabel.isHidden = true
        photoContainer.isHidden = true
        bubbleView.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
    }
}

// MARK: - UIGestureRecognizerDelegate

extension NativeMessageCell: UIGestureRecognizerDelegate {
    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
        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
    }
}
