import Combine
import os
import SwiftUI
import UIKit

/// Cached reply/forward data parsed from JSON blob (immutable per message).
struct ReplyDataCacheEntry {
    let replyName: String?
    let replyText: String?
    let replyMessageId: String?
    let forwardSenderName: String?
    let forwardSenderKey: String?
}

// MARK: - NativeMessageListController

/// UICollectionView-based message list with inverted scroll (newest at bottom).
/// Uses UIHostingConfiguration to render existing MessageCellView inside cells.
/// This gives UIKit scroll physics + SwiftUI rendering — best of both worlds.
///
/// Phase 7: Telegram-style keyboard sync. No CADisplayLink, no presentationLayer.
/// Keyboard insets applied ONCE on notification (not 120x/sec).
/// Interactive dismiss tracked via KVO inputAccessoryView notification.
@MainActor
final class NativeMessageListController: UIViewController {

    private static let perfLog = Logger(subsystem: "com.rosetta.messenger", category: "ChatOpen")

    private enum UIConstants {
        static let messageToComposerGap: CGFloat = 16
        static let scrollButtonSize: CGFloat = 40
        static let scrollButtonIconCanvas: CGFloat = 38
        static let scrollButtonBaseTrailing: CGFloat = 17
        static let scrollButtonCompactExtraTrailing: CGFloat = 18
        static let recordingScrollLift: CGFloat = 76

    }

    // MARK: - Configuration

    struct Config {
        var maxBubbleWidth: CGFloat
        var currentPublicKey: String
        var highlightedMessageId: String?
        var isSavedMessages: Bool
        var isSystemAccount: Bool
        var isGroupChat: Bool = false
        var groupAdminKey: String = ""
        var opponentPublicKey: String
        var opponentTitle: String
        var opponentUsername: String
        var actions: MessageCellActions
        var firstUnreadMessageId: String?
    }

    var config: Config

    // MARK: - Callbacks

    var onScrollToBottomVisibilityChange: ((Bool) -> Void)?
    var onPaginationTrigger: (() -> Void)?
    var onBottomPaginationTrigger: (() -> Void)?
    var hasNewerMessages: Bool = false
    var onTapBackground: (() -> Void)?
    var onComposerHeightChange: ((CGFloat) -> Void)?
    var onKeyboardDidHide: (() -> Void)?

    // Composer callbacks (forwarded from ComposerViewDelegate)
    var onComposerSend: (() -> Void)?
    var onComposerAttach: (() -> Void)?
    var onComposerTextChange: ((String) -> Void)?
    var onComposerFocusChange: ((Bool) -> Void)?
    var onComposerReplyCancel: (() -> Void)?
    var onComposerForwardCancel: (() -> Void)?
    var onComposerTyping: (() -> Void)?

    // MARK: - State

    private(set) var messages: [ChatMessage] = []
    var hasMoreMessages: Bool = true

    /// Cached group attachment password (hex-encoded group key, Desktop parity).
    private lazy var resolvedGroupKey: String? = {
        guard config.isGroupChat else { return nil }
        let session = SessionManager.shared
        guard let privKey = session.privateKeyHex else { return nil }
        let account = session.currentPublicKey ?? ""
        guard !account.isEmpty else { return nil }
        guard let gk = GroupRepository.shared.groupKey(
            account: account,
            privateKeyHex: privKey,
            groupDialogKey: config.opponentPublicKey
        ) else { return nil }
        // Desktop parity: Buffer.from(groupKey).toString('hex')
        return Data(gk.utf8).map { String(format: "%02x", $0) }.joined()
    }()

    // MARK: - Cached Theme (PERF: avoid UserDefaults read per layout)

    private var cachedIsDarkMode: Bool = true

    private func refreshCachedTheme() {
        let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "system"
        cachedIsDarkMode = themeMode != "light"
    }

    // MARK: - UIKit

    private var collectionView: UICollectionView!
    private static let unreadSeparatorId = "__unread_separator__"

    /// Extract NativeMessageCell from a collection view cell (wrapper pattern).
    private func messageCell(from cell: UICollectionViewCell) -> NativeMessageCell? {
        (cell as? NativeMessageCollectionCell)?.messageView
    }

    /// Extract NativeMessageCell at index path.
    private func messageCell(at indexPath: IndexPath) -> NativeMessageCell? {
        guard let cell = collectionView.cellForItem(at: indexPath) else { return nil }
        return messageCell(from: cell)
    }

    private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
    private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCollectionCell, ChatMessage>!
    private var separatorCellRegistration: UICollectionView.CellRegistration<UnreadSeparatorCell, Bool>!

    // MARK: - Insertion Animation
    private var hasCompletedInitialLoad = false

    // MARK: - Composer

    /// Pure UIKit composer (iOS < 26). nil on iOS 26+ (SwiftUI overlay).
    private(set) var composerView: ComposerView?
    private var lastComposerHeight: CGFloat = 0
    private var hasAppliedInitialInsets = false
    private var shouldSetupComposer = false

    // MARK: - Keyboard State (Telegram-style manual tracking)
    private var composerBottomConstraint: NSLayoutConstraint?
    private var composerHeightConstraint: NSLayoutConstraint?
    private var isKeyboardAnimating = false
    private(set) var isDisappearing = false
    /// Blocks programmatic re-focus after swipe-back dismisses keyboard.
    /// Cleared only when isInputFocused becomes true (user tap or reply action).
    fileprivate(set) var keyboardBlockedUntilUserTap = false
    private var currentKeyboardHeight: CGFloat = 0
    /// Timestamp of the last animated scrollToBottom call.
    /// When recent (< 1s), applyInsets/keyboardWillChangeFrame snap to
    /// -newInsetTop instead of compensating from the intermediate offset.
    /// A timestamp is used instead of a boolean flag because UIKit cancels
    /// the scroll animation when contentInset changes, firing
    /// scrollViewDidEndScrollingAnimation prematurely.
    private var scrollToBottomTimestamp: CFAbsoluteTime = 0

    // MARK: - Voice Playback Live Updates
    private var voicePlayerCancellables = Set<AnyCancellable>()

    // MARK: - Deferred Voice Send Collapse (Telegram pattern)
    /// Pending voice send transition: when the cell with `messageId` appears in
    /// update(messages:), `collapseAction` fires to dismiss recording chrome.
    private var pendingVoiceCollapse: (messageId: String, collapseAction: () -> Void)?
    private var voiceCollapseTimer: DispatchWorkItem?

    // MARK: - Scroll-to-Bottom Button
    private var scrollToBottomButton: UIButton?
    private var scrollToBottomButtonContainer: UIView?
    private var scrollToBottomTrailingConstraint: NSLayoutConstraint?
    private var scrollToBottomBottomConstraint: NSLayoutConstraint?
    private var scrollToBottomBadgeView: UIView?
    private var scrollToBottomBadgeLabel: UILabel?
    /// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
    private var lastReportedAtBottom: Bool = true

    // MARK: - Floating Date Headers (Telegram-style per-section pills)
    private var datePillPool: [(container: UIView, label: UILabel)] = []
    private var animatePillFrames = false
    /// PERF: Cache formatted date text per ms-timestamp to avoid Calendar/DateFormatter calls
    /// on every scroll frame. Invalidated daily (for Today/Yesterday transitions).
    private var dateTextCache: [Int64: String] = [:]
    private var dateTextCacheDay: Int = -1
    /// Extra top offset for sticky pills when parent has floating header overlay.
    /// Default 0 preserves behavior for SwiftUI host (where nav bar inflates safe area).
    var topStickyOffset: CGFloat = 0
    /// Container for floating date pills — parent VC can reparent for z-ordering.
    let datePillOverlay: UIView = {
        let v = UIView()
        v.isUserInteractionEnabled = false
        v.backgroundColor = .clear
        return v
    }()

    // MARK: - Empty State (pure UIKit, animates with keyboard)
    private var emptyStateContainer: UIView?
    private var emptyStateGuide: UILayoutGuide?

    // MARK: - Multi-Select

    private(set) var isSelectionMode = false
    private(set) var selectedMessageIds: Set<String> = []

    // MARK: - Message ID Lookup (O(1) instead of O(n) firstIndex)

    /// PERF: messageId → array index. Rebuilt on every update(messages:).
    /// Eliminates O(n) linear scan per visible cell per scroll frame.
    private var messageIdToIndex: [String: Int] = [:]

    // MARK: - Layout Cache (Telegram asyncLayout pattern)

    /// Generation counter for discarding stale async layout results.
    private var layoutGeneration: UInt64 = 0

    /// Cache: messageId → pre-calculated layout from background thread.
    /// All frame rects computed once, applied on main thread (just sets frames).
    private var layoutCache: [String: MessageCellLayout] = [:]

    /// Cache: messageId → pre-calculated CoreTextTextLayout for cell rendering.
    /// Eliminates double CoreText computation (measure + render → measure once, render from cache).
    private var textLayoutCache: [String: CoreTextTextLayout] = [:]

    /// Cache: messageId → parsed reply/forward data. Reply blobs are immutable after creation,
    /// so JSON decode only happens once per message instead of every cell configure.
    private var replyDataCache: [String: ReplyDataCacheEntry] = [:]

    // MARK: - Skeleton Loading

    private var skeletonView: NativeSkeletonView?
    private var isShowingSkeleton = false

    // MARK: - Init

    init(config: Config) {
        self.config = config
        super.init(nibName: nil, bundle: nil)
    }

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

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        refreshCachedTheme()
        setupCollectionView()
        setupNativeCellRegistration()
        setupSeparatorCellRegistration()
        setupDataSource()
        setupFloatingDateHeader()

        // Create pure UIKit composer after view hierarchy is ready.
        if shouldSetupComposer {
            shouldSetupComposer = false
            performSetupComposer()
        }

        // Telegram-style keyboard: single notification, manual constraint animation.
        NotificationCenter.default.addObserver(
            self, selector: #selector(keyboardWillChangeFrame(_:)),
            name: UIResponder.keyboardWillChangeFrameNotification, object: nil
        )
        NotificationCenter.default.addObserver(
            self, selector: #selector(keyboardDidHide),
            name: UIResponder.keyboardDidHideNotification, object: nil
        )
        NotificationCenter.default.addObserver(
            self, selector: #selector(interactiveKeyboardHeightChanged(_:)),
            name: NSNotification.Name("InteractiveKeyboardHeightChanged"), object: nil
        )
        NotificationCenter.default.addObserver(
            self, selector: #selector(handleAvatarDidUpdate),
            name: Notification.Name("avatarDidUpdate"), object: nil
        )
        // Resolve group admin key: try cached first (instant, no flash), then async fallback.
        if config.isGroupChat && config.groupAdminKey.isEmpty {
            let account = SessionManager.shared.currentPublicKey
            if let cached = GroupRepository.shared.cachedMembers(account: account, groupDialogKey: config.opponentPublicKey) {
                config.groupAdminKey = cached.adminKey
            } else {
                Task { @MainActor in
                    let members = try? await GroupService.shared.requestMembers(
                        groupDialogKey: self.config.opponentPublicKey
                    )
                    if let members, !members.isEmpty {
                        // Cache members for mention autocomplete
                        GroupRepository.shared.updateMemberCache(
                            account: account,
                            groupDialogKey: self.config.opponentPublicKey,
                            memberKeys: members
                        )
                        if let adminKey = members.first, !adminKey.isEmpty {
                            self.config.groupAdminKey = adminKey
                            self.calculateLayouts()
                            self.collectionView.reloadData()
                        }
                        // Trigger mention preload now that cache is populated
                        self.composerView?.preloadMentionMembers()
                    }
                }
            }
        }

        // Regenerate bubble images + full cell refresh on theme switch.
        registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in
            let oldStyle = previousTraitCollection.userInterfaceStyle
            let newStyle = self.traitCollection.userInterfaceStyle
            guard oldStyle != newStyle else { return }
            self.refreshCachedTheme()
            NativeMessageCell.regenerateBubbleImages(with: self.traitCollection)
            self.calculateLayouts()
            self.refreshAllMessageCells()
        }

        // Voice playback: live waveform progress updates
        setupVoicePlayerSubscription()

        // Show skeleton placeholder while messages load from DB
        if messages.isEmpty {
            showSkeleton()
        }
    }

    // MARK: - Skeleton

    private func showSkeleton() {
        guard skeletonView == nil else { return }

        let skeleton = NativeSkeletonView()
        skeleton.chatType = config.isGroupChat ? .group : .user
        skeleton.clipsToBounds = true
        skeleton.frame = view.bounds
        skeleton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        // Insert below composer so it never overlaps the input bar
        if let composer = composerView {
            view.insertSubview(skeleton, belowSubview: composer)
        } else {
            view.addSubview(skeleton)
        }
        skeletonView = skeleton
        isShowingSkeleton = true
    }

    /// Update skeleton bottom inset after layout (safe area + composer known).
    private func updateSkeletonInset() {
        guard let skeleton = skeletonView else { return }
        let composerH = max(lastComposerHeight, 60)
        let safeBottom = view.safeAreaInsets.bottom
        skeleton.bottomInset = composerH + safeBottom + 8
    }

    private func hideSkeletonAnimated() {
        isShowingSkeleton = false

        guard let skeleton = skeletonView else { return }
        skeletonView = nil

        // Gather visible cells for Telegram-exact fly-in animation
        var cellInfos: [(cell: UIView, isOutgoing: Bool)] = []
        let sortedIndexPaths = collectionView.indexPathsForVisibleItems
            .sorted { $0.item < $1.item }
        for ip in sortedIndexPaths {
            guard let cell = messageCell(at: ip),
                  let msgId = messageId(for: ip) else { continue }
            let isOutgoing = layoutCache[msgId]?.isOutgoing ?? false
            // Hide cells before animation — they'll fade in via skeleton transition
            // Animate on cell.layer (NOT contentView) to avoid conflicting with contentView's y:-1 flip
            cell.layer.opacity = 0
            cellInfos.append((cell, isOutgoing))
        }

        let heightNorm = collectionView.bounds.height - collectionView.contentInset.top

        if cellInfos.isEmpty {
            // Fallback: no visible cells — simple fade
            skeleton.animateOut {
                skeleton.removeFromSuperview()
            }
        } else {
            skeleton.animateOutWithCells(cellInfos, heightNorm: heightNorm) {
                skeleton.removeFromSuperview()
            }
        }

        // Fallback: force remove after 1.5s if animation didn't complete
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak skeleton] in
            skeleton?.removeFromSuperview()
        }
    }

    @objc private func handleAvatarDidUpdate() {
        reconfigureVisibleCells()
    }

    // Dismiss keyboard on swipe-back. Let keyboardWillChangeFrame update insets
    // normally so content slides down, but skip offset compensation (isDisappearing flag).
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        isDisappearing = true
        keyboardBlockedUntilUserTap = true
        // Block UIKit first-responder restoration at the UITextView level.
        composerView?.isFocusBlocked = true
        view.endEditing(true)
        onComposerFocusChange?(false)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        isDisappearing = false
        // UIKit's first-responder restoration has already been blocked
        // by isFocusBlocked during the transition. Safe to unblock now
        // for future user taps.
        composerView?.isFocusBlocked = false
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        if !hasAppliedInitialInsets {
            applyInsets()
            hasAppliedInitialInsets = true
        }
        // ComposerView reports height via delegate (composerHeightDidChange).
        // No polling needed — pure UIKit, no UIHostingController inflation bug.

        // Date pills sit between collection view and composer (z-order).
        // Composer covers pills naturally — no bringSubviewToFront needed.

        // Update skeleton inset now that safe area and composer height are known.
        updateSkeletonInset()
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        applyInsets()
        updateScrollToBottomButtonConstraints()
        // Update composer bottom when keyboard is hidden
        if currentKeyboardHeight == 0 {
            composerBottomConstraint?.constant = -view.safeAreaInsets.bottom
        }
    }

    // MARK: - Setup

    private func setupCollectionView() {
        var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
        listConfig.showsSeparators = false
        listConfig.backgroundColor = .clear
        let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
            let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
            section.interGroupSpacing = 0
            section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
            return section
        }

        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = .clear
        collectionView.delegate = self
        collectionView.prefetchDataSource = self
        collectionView.keyboardDismissMode = .interactive
        collectionView.showsVerticalScrollIndicator = false
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.alwaysBounceHorizontal = false

        // Inversion: flip the entire collection view.
        // Each cell is flipped back in UIHostingConfiguration.
        collectionView.transform = CGAffineTransform(scaleX: 1, y: -1)

        // We manage insets manually for composer/nav bar overlap.
        collectionView.contentInsetAdjustmentBehavior = .never

        view.addSubview(collectionView)

        // Full-screen collectionView — extends BEHIND composer for glass/blur effect.
        // Visible area controlled by contentInset (composer + keyboard).
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        // Tap to dismiss keyboard
        let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap))
        tap.cancelsTouchesInView = false
        collectionView.addGestureRecognizer(tap)
    }

    private func setupNativeCellRegistration() {
        nativeCellRegistration = UICollectionView.CellRegistration<NativeMessageCollectionCell, ChatMessage> {
            [weak self] wrapper, indexPath, message in
            let cell = wrapper.messageView
            guard let self else { return }

            // Patch group messages without attachment password (legacy messages before fix).
            var msg = message
            if self.config.isGroupChat,
               msg.attachmentPassword == nil,
               !msg.attachments.isEmpty,
               let gk = self.resolvedGroupKey {
                msg.attachmentPassword = gk
            }

            // Apply pre-calculated layout (just sets frames — no computation)
            if let layout = self.layoutCache[msg.id] {
                cell.apply(layout: layout)
            }

            // Parse reply data for quote display (cached — JSON decode once per message)
            let replyEntry: ReplyDataCacheEntry? = {
                guard msg.attachments.contains(where: { $0.type == .messages }) else { return nil }
                if let cached = self.replyDataCache[msg.id] { return cached }
                guard let att = msg.attachments.first(where: { $0.type == .messages }),
                      let data = att.blob.data(using: .utf8),
                      let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data),
                      let first = replies.first else {
                    let empty = ReplyDataCacheEntry(replyName: nil, replyText: nil, replyMessageId: nil, forwardSenderName: nil, forwardSenderKey: nil)
                    self.replyDataCache[msg.id] = empty
                    return empty
                }
                let senderKey = first.publicKey
                let name: String
                if senderKey == self.config.currentPublicKey {
                    name = "You"
                } else if senderKey == self.config.opponentPublicKey {
                    name = self.config.opponentTitle.isEmpty
                        ? String(senderKey.prefix(8)) + "…"
                        : self.config.opponentTitle
                } else {
                    name = DialogRepository.shared.dialogs[senderKey]?.opponentTitle
                        ?? String(senderKey.prefix(8)) + "…"
                }
                let displayText = MessageCellLayout.isGarbageOrEncrypted(msg.text) ? "" : msg.text
                let entry: ReplyDataCacheEntry
                if displayText.isEmpty {
                    entry = ReplyDataCacheEntry(replyName: nil, replyText: nil, replyMessageId: nil, forwardSenderName: name, forwardSenderKey: senderKey)
                } else {
                    let previewText = Self.replyPreviewText(for: first)
                    entry = ReplyDataCacheEntry(replyName: name, replyText: previewText, replyMessageId: first.message_id, forwardSenderName: nil, forwardSenderKey: nil)
                }
                self.replyDataCache[msg.id] = entry
                return entry
            }()

            cell.isSavedMessages = self.config.isSavedMessages
            cell.isSystemAccount = self.config.isSystemAccount
            cell.configure(
                message: msg,
                timestamp: self.formatTimestamp(msg.timestamp),
                textLayout: self.textLayoutCache[msg.id],
                actions: self.config.actions,
                replyName: replyEntry?.replyName,
                replyText: replyEntry?.replyText,
                replyMessageId: replyEntry?.replyMessageId,
                forwardSenderName: replyEntry?.forwardSenderName,
                forwardSenderKey: replyEntry?.forwardSenderKey
            )

            // Multi-select: apply selection state on cell (re)configuration
            cell.setSelectionMode(self.isSelectionMode, animated: false)
            if self.isSelectionMode {
                cell.setMessageSelected(self.selectedMessageIds.contains(msg.id), animated: false)
            }
        }
    }

    /// Format timestamp from milliseconds to "HH:mm" string.
    private static let timestampFormatter: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "HH:mm"
        return f
    }()

    /// PERF: Cache formatted timestamps — DateFormatter.string() is ~0.5ms per call.
    /// With 15 visible cells at 60fps, uncached = 450ms/sec of CPU.
    private var timestampCache: [Int64: String] = [:]

    private func formatTimestamp(_ ms: Int64) -> String {
        if let cached = timestampCache[ms] { return cached }
        let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
        let result = Self.timestampFormatter.string(from: date)
        timestampCache[ms] = result
        return result
    }

    /// Reply quote preview text for UIKit cells.
    /// Mirrors SwiftUI parity rules: explicit text first, then attachment type labels.
    private static func replyPreviewText(for reply: ReplyMessageData) -> String {
        let trimmed = reply.message.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmed.isEmpty {
            return EmojiParser.replaceShortcodes(in: reply.message)
        }
        if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" }
        if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" }
        if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" }
        if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
        if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" }
        if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" }
        return "Attachment"
    }

    /// Extract messageId, returning nil for the unread separator sentinel.
    private func messageId(for indexPath: IndexPath) -> String? {
        guard let id = dataSource.itemIdentifier(for: indexPath),
              id != Self.unreadSeparatorId else { return nil }
        return id
    }

    private func setupSeparatorCellRegistration() {
        separatorCellRegistration = UICollectionView.CellRegistration<UnreadSeparatorCell, Bool> {
            cell, _, isDarkMode in
            cell.configure(isDarkMode: isDarkMode)
        }
    }

    private func setupDataSource() {
        dataSource = UICollectionViewDiffableDataSource<Int, String>(
            collectionView: collectionView
        ) { [weak self] collectionView, indexPath, itemId in
            guard let self else { return UICollectionViewCell() }

            if itemId == Self.unreadSeparatorId {
                return collectionView.dequeueConfiguredReusableCell(
                    using: self.separatorCellRegistration,
                    for: indexPath,
                    item: self.cachedIsDarkMode
                )
            }

            guard let msgIndex = self.messageIdToIndex[itemId] else {
                return UICollectionViewCell()
            }
            let message = self.messages[msgIndex]
            return collectionView.dequeueConfiguredReusableCell(
                using: self.nativeCellRegistration,
                for: indexPath,
                item: message
            )
        }
    }

    // MARK: - Composer Setup

    /// Creates pure UIKit ComposerView pinned to bottom.
    /// Safe to call before viewDidLoad — defers until view hierarchy is ready.
    func setupComposer() {
        if !isViewLoaded {
            shouldSetupComposer = true
            return
        }
        performSetupComposer()
    }

    private func performSetupComposer() {
        let composer = ComposerView(frame: .zero)
        composer.delegate = self
        composer.isGroupChat = config.isGroupChat
        composer.groupDialogKey = config.opponentPublicKey
        if config.isGroupChat {
            composer.preloadMentionMembers()
        }
        composer.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(composer)

        let initialH: CGFloat = 60
        let heightC = composer.heightAnchor.constraint(equalToConstant: initialH)
        composerHeightConstraint = heightC
        lastComposerHeight = initialH

        let bottomC = composer.bottomAnchor.constraint(
            equalTo: view.bottomAnchor,
            constant: -view.safeAreaInsets.bottom
        )
        composerBottomConstraint = bottomC

        NSLayoutConstraint.activate([
            composer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            composer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            heightC,
            bottomC,
        ])

        composerView = composer
        setupScrollToBottomButton(above: composer)
        applyInsets()
    }

    // MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer)

    private func setupScrollToBottomButton(above composer: UIView) {
        let size = UIConstants.scrollButtonSize
        let rect = CGRect(x: 0, y: 0, width: size, height: size)

        // Container: Auto Layout positions it, clipsToBounds prevents overflow.
        // Nothing inside the container uses Auto Layout or UIView.transform.
        let container = UIView(frame: .zero)
        container.translatesAutoresizingMaskIntoConstraints = false
        container.backgroundColor = .clear
        container.clipsToBounds = false
        container.isUserInteractionEnabled = true
        view.addSubview(container)

        let trailing = container.trailingAnchor.constraint(
            equalTo: view.safeAreaLayoutGuide.trailingAnchor,
            constant: -UIConstants.scrollButtonBaseTrailing
        )
        let bottom = container.bottomAnchor.constraint(
            equalTo: view.keyboardLayoutGuide.topAnchor,
            constant: -(lastComposerHeight + 4)
        )
        NSLayoutConstraint.activate([
            container.widthAnchor.constraint(equalToConstant: size),
            container.heightAnchor.constraint(equalToConstant: size),
            trailing,
            bottom,
        ])
        scrollToBottomTrailingConstraint = trailing
        scrollToBottomBottomConstraint = bottom
        scrollToBottomButtonContainer = container

        // Button: hardcoded 40×40 frame. NO UIView.transform — scale is done
        // at CALayer level so UIKit never recalculates bounds through the
        // transform matrix during interactive keyboard dismiss.
        let button = UIButton(type: .custom)
        button.frame = rect
        button.clipsToBounds = false
        button.alpha = 0
        button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
        button.layer.allowsEdgeAntialiasing = true
        container.addSubview(button)

        // Glass circle background: hardcoded 40×40 frame, no autoresizingMask.
        let glass = TelegramGlassUIView(frame: rect)
        glass.isCircle = true
        glass.isUserInteractionEnabled = false
        button.addSubview(glass)

        // Telegram-style down icon (canvas 38×38, line width 1.5).
        let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
        imageView.contentMode = .center
        imageView.frame = rect
        imageView.tintColor = UIColor { traits in
            traits.userInterfaceStyle == .dark ? .white : .black
        }
        button.addSubview(imageView)

        let badgeView = UIView(frame: .zero)
        badgeView.backgroundColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1)
        badgeView.layer.cornerCurve = .continuous
        badgeView.layer.cornerRadius = 10
        badgeView.isHidden = true
        button.addSubview(badgeView)
        scrollToBottomBadgeView = badgeView

        let badgeLabel = UILabel(frame: .zero)
        badgeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
        badgeLabel.textColor = .white
        badgeLabel.textAlignment = .center
        badgeView.addSubview(badgeLabel)
        scrollToBottomBadgeLabel = badgeLabel

        button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside)
        scrollToBottomButton = button
        updateScrollToBottomButtonConstraints()
        updateScrollToBottomBadge()
    }

    var onJumpToBottom: (() -> Void)?

    @objc private func scrollToBottomTapped() {
        // Always reload + scroll: sliding window may have trimmed newest messages.
        // jumpToBottom does smart-merge reloadLatest (fast if cache is fresh).
        onJumpToBottom?()
        scrollToBottom(animated: true)
        onScrollToBottomVisibilityChange?(true)
    }

    /// Show/hide the scroll-to-bottom button with CALayer-level scaling.
    /// UIView.bounds stays 40×40 at ALL times — only rendered pixels scale.
    /// No UIView.transform, no layoutIfNeeded — completely bypasses the
    /// Auto Layout ↔ transform race condition during interactive dismiss.
    func setScrollToBottomVisible(_ visible: Bool) {
        guard let button = scrollToBottomButton else { return }

        // Suppress scroll-to-bottom during voice recording
        let isRecording = composerView?.recordingFlowState != .idle
            && composerView?.recordingFlowState != nil
        let effectiveVisible = visible && !isRecording

        let isCurrentlyVisible = button.alpha > 0.5
        guard effectiveVisible != isCurrentlyVisible else { return }

        UIView.animate(withDuration: 0.3, delay: 0,
                       usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
                       options: .beginFromCurrentState) {
            button.alpha = effectiveVisible ? 1 : 0
            button.layer.transform = effectiveVisible
                ? CATransform3DIdentity
                : CATransform3DMakeScale(0.2, 0.2, 1.0)
        }
        updateScrollToBottomBadge()
    }

    private func updateScrollToBottomButtonConstraints() {
        let safeBottom = view.safeAreaInsets.bottom
        let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
        scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
        scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4 + recordingAwareScrollLift())
    }

    private func recordingAwareScrollLift() -> CGFloat {
        guard let composer = composerView else { return 0 }
        switch composer.recordingFlowState {
        case .recordingLocked, .waitingForPreview, .draftPreview:
            return UIConstants.recordingScrollLift
        default:
            return 0
        }
    }

    private func updateScrollToBottomBadge() {
        guard let badgeView = scrollToBottomBadgeView,
              let badgeLabel = scrollToBottomBadgeLabel else {
            return
        }
        let unreadCount = DialogRepository.shared.dialogs[config.opponentPublicKey]?.unreadCount ?? 0
        guard unreadCount > 0 else {
            badgeView.isHidden = true
            badgeLabel.text = nil
            return
        }

        let badgeText = Self.compactUnreadCountString(unreadCount)
        badgeLabel.text = badgeText
        let badgeFont = badgeLabel.font ?? UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
        let textWidth = ceil((badgeText as NSString).size(withAttributes: [.font: badgeFont]).width)
        let badgeWidth = max(20, textWidth + 11)
        let badgeFrame = CGRect(
            x: floor((UIConstants.scrollButtonSize - badgeWidth) / 2),
            y: -7,
            width: badgeWidth,
            height: 20
        )
        badgeView.frame = badgeFrame
        badgeView.layer.cornerRadius = badgeFrame.height * 0.5
        badgeLabel.frame = badgeView.bounds
        badgeView.isHidden = false
    }

    private static func makeTelegramDownButtonImage() -> UIImage? {
        let size = CGSize(width: UIConstants.scrollButtonIconCanvas, height: UIConstants.scrollButtonIconCanvas)
        let renderer = UIGraphicsImageRenderer(size: size)
        return renderer.image { ctx in
            let gc = ctx.cgContext
            gc.clear(CGRect(origin: .zero, size: size))
            gc.setStrokeColor(UIColor.white.cgColor)
            gc.setLineWidth(1.5)
            gc.setLineCap(.round)
            gc.setLineJoin(.round)

            // Down chevron (v), centered in canvas — Telegram parity.
            let cx = size.width / 2
            let cy = size.height / 2
            gc.move(to: CGPoint(x: cx - 9.0, y: cy - 4.5))   // top-left
            gc.addLine(to: CGPoint(x: cx, y: cy + 4.5))       // bottom-center
            gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
            gc.strokePath()
        }.withRenderingMode(.alwaysTemplate)
    }

    private static func compactUnreadCountString(_ count: Int) -> String {
        if count >= 1_000_000 {
            return "\(count / 1_000_000)M"
        } else if count >= 1_000 {
            return "\(count / 1_000)K"
        } else {
            return "\(count)"
        }
    }

    // MARK: - Floating Date Headers (Telegram-style per-section pills)

    /// Create a reusable date pill overlay (glass + label).
    private func makeDatePill() -> (container: UIView, label: UILabel) {
        let c = UIView()
        c.alpha = 0
        c.isHidden = true
        c.isUserInteractionEnabled = false
        let glass = TelegramGlassUIView(frame: .zero)
        glass.isUserInteractionEnabled = false
        // NO autoresizingMask — frame set explicitly in updateFloatingDateHeader
        // to prevent 1-frame inflation when pool pill reuses with different text.
        glass.tag = 42  // marker to find glass subview later
        c.addSubview(glass)
        let l = UILabel()
        l.font = UIFont.systemFont(ofSize: 12, weight: .medium)
        l.textColor = UIColor { traits in
            traits.userInterfaceStyle == .dark ? .white : .black
        }
        l.textAlignment = .center
        c.addSubview(l)
        return (c, l)
    }

    private func setupFloatingDateHeader() {
        datePillOverlay.frame = view.bounds
        datePillOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(datePillOverlay)

        // Pre-create pool of 4 pills (max visible date sections at once).
        for _ in 0..<4 {
            let pill = makeDatePill()
            datePillOverlay.addSubview(pill.container)
            datePillPool.append(pill)
        }
    }

    /// Telegram-exact sticky date header positioning.
    ///
    /// Core formula from Telegram ListView (`.bottom` stick direction):
    ///   `headerY = min(max(sectionTopY, stickyY), sectionBottomY - pillH)`
    ///
    /// This single formula handles natural position, sticky, AND push between
    /// adjacent sections — no separate "stuck" vs "approach" logic needed.
    private func updateFloatingDateHeader() {
        guard !messages.isEmpty, collectionView != nil else { return }

        let pillH: CGFloat = 24
        let hPad: CGFloat = 7
        let stickyY = view.safeAreaInsets.top + topStickyOffset + 8

        // PERF: Invalidate dateText cache daily (Today/Yesterday change at midnight).
        let approxDay = Int(Date().timeIntervalSinceReferenceDate / 86400)
        if approxDay != dateTextCacheDay {
            dateTextCache.removeAll(keepingCapacity: true)
            dateTextCacheDay = approxDay
        }

        // 1. Group visible cells by date → section ranges in screen coords.
        struct DateSection {
            let text: String
            var topY: CGFloat      // visual top of section (oldest msg = smallest Y)
            var bottomY: CGFloat   // visual bottom of section (newest msg = largest Y)
        }

        // Build date for each visible cell, collect section ranges.
        var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:]

        for cell in collectionView.visibleCells {
            guard cell is NativeMessageCell else { continue }
            let cellFrame = collectionView.convert(cell.frame, to: view)

            guard let ip = collectionView.indexPath(for: cell),
                  let msgId = messageId(for: ip),
                  let msgIndex = messageIdToIndex[msgId] else { continue }

            // PERF: Cached dateText lookup — avoids Calendar/DateFormatter per cell per frame.
            let ts = messages[msgIndex].timestamp
            let dateText: String
            if let cached = dateTextCache[ts] {
                dateText = cached
            } else {
                let d = Date(timeIntervalSince1970: TimeInterval(ts) / 1000)
                let text = Self.formatDateText(d)
                dateTextCache[ts] = text
                dateText = text
            }

            if var existing = sectionMap[dateText] {
                existing.topY = min(existing.topY, cellFrame.minY)
                existing.bottomY = max(existing.bottomY, cellFrame.maxY)
                sectionMap[dateText] = existing
            } else {
                sectionMap[dateText] = (cellFrame.minY, cellFrame.maxY)
            }
        }

        // Sort sections by topY (top to bottom on screen).
        let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) }
            .sorted { $0.topY < $1.topY }

        // 2. Expand pool if more sections than pills (short chats spanning many days).
        while datePillPool.count < sections.count {
            let pill = makeDatePill()
            datePillOverlay.addSubview(pill.container)
            datePillPool.append(pill)
        }

        // 3. Position each section's pill using Telegram's formula.
        // PERF: Read width once before loop — UIScreen.main is deprecated and involves dispatch per call.
        let screenW = view.bounds.width
        var usedPillCount = 0
        for section in sections {

            // Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
            // +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
            let naturalY = section.topY + 9
            let headerY = min(max(naturalY, stickyY), section.bottomY - pillH)

            // Skip if pill is completely above screen.
            if headerY + pillH < 0 { continue }

            let pill = datePillPool[usedPillCount]

            // Phase 1: Internal properties — always snap (no animation).
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            // PERF: Only measure text when it actually changed.
            let textChanged = pill.label.text != section.text
            if textChanged { pill.label.text = section.text; pill.label.sizeToFit() }
            let textW = ceil(pill.label.intrinsicContentSize.width)
            let pillW = textW + hPad * 2
            let pillFrame = CGRect(
                x: round((screenW - pillW) / 2), y: headerY,
                width: pillW, height: pillH
            )
            let wasPreviouslyHidden = pill.container.isHidden
            pill.container.layer.cornerRadius = pillH / 2
            pill.container.isHidden = false
            // Telegram parity: all visible pills always alpha=1 (stuck pills never hide).
            // Fade in only when pill transitions from pool (hidden → active).
            if wasPreviouslyHidden {
                pill.container.alpha = 0
            } else {
                pill.container.alpha = 1
            }
            CATransaction.commit()

            // Fade in newly visible pills (0.3s, Telegram parity).
            if wasPreviouslyHidden {
                UIView.animate(withDuration: 0.3, delay: 0, options: .beginFromCurrentState) {
                    pill.container.alpha = 1
                }
            }

            // Phase 2: Container frame — animate during keyboard, snap otherwise.
            if animatePillFrames {
                // Inside UIView.animate block → implicit animation matches keyboard curve.
                pill.container.frame = pillFrame
            } else {
                CATransaction.begin()
                CATransaction.setDisableActions(true)
                pill.container.frame = pillFrame
                CATransaction.commit()
            }

            // Phase 3: Sync child frames to container (always snap).
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            pill.label.frame = pill.container.bounds
            if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
                glass.frame = pill.container.bounds
                glass.layoutIfNeeded()
            }
            CATransaction.commit()

            usedPillCount += 1
        }

        // Hide unused pills.
        for i in usedPillCount..<datePillPool.count {
            datePillPool[i].container.isHidden = true
        }

    }

    private static func formatDateText(_ date: Date) -> String {
        let calendar = Calendar.current
        if calendar.isDateInToday(date) { return "Today" }
        if calendar.isDateInYesterday(date) { return "Yesterday" }
        let now = Date()
        if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
            return sameYearDateFormatter.string(from: date)
        }
        return diffYearDateFormatter.string(from: date)
    }

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

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

    // MARK: - Empty State (UIKit-managed, animates with keyboard)

    func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
        if isEmpty {
            if let container = emptyStateContainer {
                if !info.isGroup {
                    updateEmptyStateContent(container, info: info)
                }
                container.isHidden = false
            } else {
                setupEmptyState(info: info)
            }
        } else {
            emptyStateContainer?.isHidden = true
        }
    }

    private func updateEmptyStateContent(_ container: UIView, info: EmptyChatInfo) {
        // Tags: 701=avatarContainer, 702=avatarImage, 703=initialsLabel,
        //        704=titleLabel, 705=subtitleLabel, 706=descLabel, 707=avatarBg
        let avatarSize: CGFloat = 80

        // Avatar image vs initials
        if let avatarImg = container.viewWithTag(702) as? UIImageView,
           let initialsLbl = container.viewWithTag(703) as? UILabel,
           let avatarBg = container.viewWithTag(707) {
            if let photo = info.avatarImage {
                avatarImg.image = photo
                avatarImg.isHidden = false
                initialsLbl.isHidden = true
                avatarBg.isHidden = true
            } else if info.isSavedMessages {
                avatarImg.isHidden = true
                initialsLbl.isHidden = false
                initialsLbl.text = ""
                avatarBg.backgroundColor = UIColor(RosettaColors.primaryBlue)
                avatarBg.isHidden = false
                // Bookmark icon via SF Symbol
                if let bookmarkImg = container.viewWithTag(708) as? UIImageView {
                    bookmarkImg.isHidden = false
                }
            } else {
                avatarImg.isHidden = true
                initialsLbl.isHidden = false
                initialsLbl.text = info.initials
                avatarBg.isHidden = false
                if let bookmarkImg = container.viewWithTag(708) as? UIImageView {
                    bookmarkImg.isHidden = true
                }

                let isDark = traitCollection.userInterfaceStyle == .dark
                let colorPair = RosettaColors.avatarColors[info.colorIndex % RosettaColors.avatarColors.count]
                let mantineDarkBody = UIColor(red: 0x1A / 255, green: 0x1B / 255, blue: 0x1E / 255, alpha: 1)
                let baseColor = isDark ? mantineDarkBody : .white
                let tintUIColor = UIColor(colorPair.tint)
                let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
                avatarBg.backgroundColor = baseColor.emptyStateBlended(with: tintUIColor, alpha: tintAlpha)

                let fontSize = avatarSize * 0.38
                initialsLbl.font = UIFont.systemFont(ofSize: fontSize, weight: .bold).emptyStateRounded()
                initialsLbl.textColor = isDark ? UIColor(colorPair.text) : tintUIColor
            }
        }

        // Title
        if let titleLbl = container.viewWithTag(704) as? UILabel {
            titleLbl.text = info.title
        }
        // Subtitle
        if let subtitleLbl = container.viewWithTag(705) as? UILabel {
            subtitleLbl.text = info.subtitle
            subtitleLbl.isHidden = info.isSavedMessages || info.subtitle.isEmpty
        }
        // Description
        if let descLbl = container.viewWithTag(706) as? UILabel {
            descLbl.text = info.isSavedMessages
                ? "Save messages here for quick access"
                : "No messages yet"
        }
    }

    private func setupEmptyState(info: EmptyChatInfo) {
        let card: UIView
        if info.isGroup {
            card = buildGroupEmptyCard()
        } else {
            card = buildPersonalEmptyCard(info: info)
        }

        // --- Insert into view ---
        if let cv = collectionView {
            view.insertSubview(card, aboveSubview: cv)
        } else {
            view.addSubview(card)
        }

        // Layout guide: safe area top → composer top (centers card in available space)
        let guide = UILayoutGuide()
        view.addLayoutGuide(guide)
        let bottom = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor

        NSLayoutConstraint.activate([
            guide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            guide.bottomAnchor.constraint(equalTo: bottom),
            card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            card.centerYAnchor.constraint(equalTo: guide.centerYAnchor),
        ])

        emptyStateContainer = card
        emptyStateGuide = guide

        // Populate content (personal chats only — group card is static)
        if !info.isGroup {
            updateEmptyStateContent(card, info: info)
        }
    }

    // MARK: - Group "You created a group" Card (Telegram parity)

    private func buildGroupEmptyCard() -> UIView {
        let card = UIView()
        card.translatesAutoresizingMaskIntoConstraints = false
        card.backgroundColor = .clear

        // Glass background
        let glass = TelegramGlassUIView(frame: .zero)
        glass.fixedCornerRadius = 20
        glass.translatesAutoresizingMaskIntoConstraints = false
        card.addSubview(glass)
        NSLayoutConstraint.activate([
            glass.topAnchor.constraint(equalTo: card.topAnchor),
            glass.leadingAnchor.constraint(equalTo: card.leadingAnchor),
            glass.trailingAnchor.constraint(equalTo: card.trailingAnchor),
            glass.bottomAnchor.constraint(equalTo: card.bottomAnchor),
        ])

        let textColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(RosettaColors.Dark.text)
            : UIColor(RosettaColors.Light.text)
        }
        let secondaryColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(RosettaColors.Dark.textSecondary)
            : UIColor(RosettaColors.Light.textSecondary)
        }

        // Title: "You created a group"
        let titleLabel = UILabel()
        titleLabel.text = "You created a group"
        titleLabel.font = .systemFont(ofSize: 17, weight: .bold)
        titleLabel.textColor = textColor
        titleLabel.textAlignment = .natural

        // Subtitle: "Groups can have:"
        let subtitleLabel = UILabel()
        subtitleLabel.text = "Groups can have:"
        subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
        subtitleLabel.textColor = textColor
        subtitleLabel.textAlignment = .natural

        // Checklist items
        let features = [
            "Up to 200,000 members",
            "Persistent chat history",
            "Invite links",
            "Admins with different rights",
        ]

        let checkmarkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
        let checkmarkImage = UIImage(systemName: "checkmark", withConfiguration: checkmarkConfig)

        var featureViews: [UIView] = []
        for feature in features {
            let row = UIView()
            row.translatesAutoresizingMaskIntoConstraints = false

            let icon = UIImageView(image: checkmarkImage)
            icon.tintColor = secondaryColor
            icon.translatesAutoresizingMaskIntoConstraints = false
            icon.contentMode = .center
            row.addSubview(icon)

            let label = UILabel()
            label.text = feature
            label.font = .systemFont(ofSize: 15, weight: .regular)
            label.textColor = textColor
            label.translatesAutoresizingMaskIntoConstraints = false
            row.addSubview(label)

            NSLayoutConstraint.activate([
                icon.leadingAnchor.constraint(equalTo: row.leadingAnchor),
                icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
                icon.widthAnchor.constraint(equalToConstant: 20),
                label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
                label.trailingAnchor.constraint(equalTo: row.trailingAnchor),
                label.topAnchor.constraint(equalTo: row.topAnchor),
                label.bottomAnchor.constraint(equalTo: row.bottomAnchor),
            ])

            featureViews.append(row)
        }

        // Checklist stack
        let checkStack = UIStackView(arrangedSubviews: featureViews)
        checkStack.axis = .vertical
        checkStack.spacing = 6

        // Main stack
        let mainStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, checkStack])
        mainStack.axis = .vertical
        mainStack.spacing = 10
        mainStack.alignment = .fill
        mainStack.setCustomSpacing(16, after: subtitleLabel)
        mainStack.translatesAutoresizingMaskIntoConstraints = false

        card.addSubview(mainStack)
        NSLayoutConstraint.activate([
            mainStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 20),
            mainStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24),
            mainStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24),
            mainStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -20),
        ])

        return card
    }

    // MARK: - Personal Chat Empty Card (avatar + title + "No messages yet")

    private func buildPersonalEmptyCard(info: EmptyChatInfo) -> UIView {
        let avatarSize: CGFloat = 80
        let card = UIView()
        card.translatesAutoresizingMaskIntoConstraints = false
        card.backgroundColor = .clear

        // Glass background
        let glass = TelegramGlassUIView(frame: .zero)
        glass.fixedCornerRadius = 20
        glass.translatesAutoresizingMaskIntoConstraints = false
        card.addSubview(glass)
        NSLayoutConstraint.activate([
            glass.topAnchor.constraint(equalTo: card.topAnchor),
            glass.leadingAnchor.constraint(equalTo: card.leadingAnchor),
            glass.trailingAnchor.constraint(equalTo: card.trailingAnchor),
            glass.bottomAnchor.constraint(equalTo: card.bottomAnchor),
        ])

        // --- Avatar ---
        let avatarContainer = UIView()
        avatarContainer.tag = 701
        avatarContainer.translatesAutoresizingMaskIntoConstraints = false
        avatarContainer.clipsToBounds = true
        avatarContainer.layer.cornerRadius = avatarSize / 2

        let avatarBg = UIView()
        avatarBg.tag = 707
        avatarBg.translatesAutoresizingMaskIntoConstraints = false
        avatarBg.clipsToBounds = true
        avatarBg.layer.cornerRadius = avatarSize / 2
        avatarContainer.addSubview(avatarBg)
        NSLayoutConstraint.activate([
            avatarBg.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
            avatarBg.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
            avatarBg.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
            avatarBg.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
        ])

        let avatarImage = UIImageView()
        avatarImage.tag = 702
        avatarImage.translatesAutoresizingMaskIntoConstraints = false
        avatarImage.contentMode = .scaleAspectFill
        avatarImage.clipsToBounds = true
        avatarImage.layer.cornerRadius = avatarSize / 2
        avatarContainer.addSubview(avatarImage)
        NSLayoutConstraint.activate([
            avatarImage.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
            avatarImage.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
            avatarImage.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
            avatarImage.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
        ])

        let initialsLabel = UILabel()
        initialsLabel.tag = 703
        initialsLabel.translatesAutoresizingMaskIntoConstraints = false
        initialsLabel.textAlignment = .center
        initialsLabel.lineBreakMode = .byTruncatingTail
        avatarContainer.addSubview(initialsLabel)
        NSLayoutConstraint.activate([
            initialsLabel.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor),
            initialsLabel.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor),
        ])

        let bookmarkIcon = UIImageView()
        bookmarkIcon.tag = 708
        bookmarkIcon.translatesAutoresizingMaskIntoConstraints = false
        bookmarkIcon.contentMode = .center
        bookmarkIcon.tintColor = .white
        let bookmarkFontSize = avatarSize * 0.38
        bookmarkIcon.image = UIImage(
            systemName: "bookmark.fill",
            withConfiguration: UIImage.SymbolConfiguration(pointSize: bookmarkFontSize, weight: .semibold)
        )
        bookmarkIcon.isHidden = true
        avatarContainer.addSubview(bookmarkIcon)
        NSLayoutConstraint.activate([
            bookmarkIcon.centerXAnchor.constraint(equalTo: avatarContainer.centerXAnchor),
            bookmarkIcon.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor),
        ])

        // --- Labels ---
        let titleLabel = UILabel()
        titleLabel.tag = 704
        titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
        titleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(RosettaColors.Dark.text)
            : UIColor(RosettaColors.Light.text)
        }
        titleLabel.textAlignment = .center

        let subtitleLabel = UILabel()
        subtitleLabel.tag = 705
        subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
        subtitleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(RosettaColors.Dark.textSecondary)
            : UIColor(RosettaColors.Light.textSecondary)
        }
        subtitleLabel.textAlignment = .center

        let descLabel = UILabel()
        descLabel.tag = 706
        descLabel.font = .systemFont(ofSize: 15, weight: .regular)
        descLabel.textColor = UIColor { $0.userInterfaceStyle == .dark
            ? UIColor(RosettaColors.Dark.textSecondary).withAlphaComponent(0.7)
            : UIColor(RosettaColors.Light.textSecondary).withAlphaComponent(0.7)
        }
        descLabel.textAlignment = .center

        let titleStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
        titleStack.axis = .vertical
        titleStack.spacing = 4
        titleStack.alignment = .center

        let mainStack = UIStackView(arrangedSubviews: [avatarContainer, titleStack, descLabel])
        mainStack.axis = .vertical
        mainStack.spacing = 16
        mainStack.alignment = .center
        mainStack.translatesAutoresizingMaskIntoConstraints = false

        card.addSubview(mainStack)
        NSLayoutConstraint.activate([
            avatarContainer.widthAnchor.constraint(equalToConstant: avatarSize),
            avatarContainer.heightAnchor.constraint(equalToConstant: avatarSize),
            mainStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 20),
            mainStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24),
            mainStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24),
            mainStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -20),
        ])

        return card
    }

    // MARK: - Multi-Select Methods

    func setSelectionMode(_ enabled: Bool, animated: Bool) {
        guard isSelectionMode != enabled else { return }
        isSelectionMode = enabled
        if !enabled { selectedMessageIds.removeAll() }

        // Update all visible cells
        for cell in collectionView.visibleCells {
            guard let msgCell = messageCell(from: cell) else { continue }
            msgCell.setSelectionMode(enabled, animated: animated)
            if enabled, let indexPath = collectionView.indexPath(for: cell),
               let msgId = messageId(for: indexPath) {
                msgCell.setMessageSelected(selectedMessageIds.contains(msgId), animated: false)
            }
        }

        // Hide/show composer in selection mode
        composerView?.isHidden = enabled
    }

    func updateSelectedIds(_ ids: Set<String>) {
        let oldIds = selectedMessageIds
        selectedMessageIds = ids

        for cell in collectionView.visibleCells {
            guard let wrapper = cell as? NativeMessageCollectionCell,
                  let msgCell = messageCell(from: wrapper),
                  let indexPath = collectionView.indexPath(for: wrapper),
                  let msgId = messageId(for: indexPath) else { continue }
            let wasSelected = oldIds.contains(msgId)
            let isSelected = ids.contains(msgId)
            if wasSelected != isSelected {
                msgCell.setMessageSelected(isSelected, animated: true)
            }
        }
    }

    // MARK: - Update

    /// Called from SwiftUI when messages array changes.
    func update(messages: [ChatMessage], animated: Bool = false) {
        // Defer skeleton dismiss until after snapshot is applied (cells must exist for fly-in animation)
        let shouldDismissSkeleton = isShowingSkeleton

        let oldIds = Set(self.messages.map(\.id))
        let oldNewestId = self.messages.last?.id

        // Detect new messages at the tail (sent/received in real-time).
        // Skip animation for: initial load, pagination, batch sync (>3 messages).
        let newIds = Set(messages.map(\.id)).subtracting(oldIds)
        let isInteractive = hasCompletedInitialLoad
            && !newIds.isEmpty
            && newIds.count <= 3
            && messages.last?.id != oldNewestId

        // Capture visible cell positions BEFORE applying snapshot (for position animation)
        var oldPositions: [String: CGFloat] = [:]
        // Capture pill positions for matching spring animation
        let oldPillPositions = isInteractive
            ? datePillPool.map { (y: $0.container.layer.position.y, visible: !$0.container.isHidden) }
            : []
        if isInteractive {
            for ip in collectionView.indexPathsForVisibleItems {
                guard let itemId = dataSource.itemIdentifier(for: ip),
                      let cell = collectionView.cellForItem(at: ip) else { continue }
                oldPositions[itemId] = cell.layer.position.y
            }
        }

        self.messages = messages

        // PERF: Rebuild O(1) lookup dictionary.
        messageIdToIndex = Dictionary(uniqueKeysWithValues: messages.enumerated().map { ($1.id, $0) })

        // PERF: Evict only removed message IDs instead of filtering entire caches.
        // Previous O(n) triple-filter ran on every update; now O(removed) only.
        if !layoutCache.isEmpty {
            let currentIds = Set(messages.map(\.id))
            let staleKeys = layoutCache.keys.filter { !currentIds.contains($0) }
            for key in staleKeys {
                layoutCache.removeValue(forKey: key)
                textLayoutCache.removeValue(forKey: key)
                replyDataCache.removeValue(forKey: key)
            }
        }

        // Layout calculation: sync for first load and interactive inserts,
        // async for bulk updates.
        // Interactive inserts (≤3 new messages) MUST be sync to avoid delayed
        // reconfigureVisibleCells() from calculateLayoutsAsync killing animations.
        if layoutCache.isEmpty {
            // First load: sync-calculate ALL messages.
            // With static DateFormatters + strict dirty skip: ~90-120ms for 200 msgs.
            // Windowed (25 sync + 50 async) saved 80ms but left 150 msgs without layout
            // → empty blue bubbles on scroll. Full sync eliminates this completely.
            let layoutStart = CACurrentMediaTime()
            calculateLayouts()
            let layoutMs = (CACurrentMediaTime() - layoutStart) * 1000
            Self.perfLog.notice("⚡ layoutSync: \(self.messages.count, privacy: .public) msgs in \(String(format: "%.0f", layoutMs), privacy: .public)ms")
        } else if isInteractive {
            // Interactive insert (1-3 messages): sync layout so no delayed reconfigure
            var dirtyIds = newIds
            for i in messages.indices where newIds.contains(messages[i].id) {
                if i > 0 { dirtyIds.insert(messages[i - 1].id) }
                if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
            }
            calculateLayouts(dirtyIds: dirtyIds)
        } else if !newIds.isEmpty {
            // Non-interactive update (pagination, Combine delivery, sync burst):
            // Sync layout ONLY for new messages + neighbors. batchCalculate now
            // strictly skips non-dirty messages (even without cached layout),
            // so 200 new IDs = ~200 CoreText calculations, not 200+150 spillover.
            // ~200 msgs × 0.5-1ms sync = 100-200ms (acceptable, no 1225ms freeze).
            var dirtyIds = newIds
            for i in messages.indices where newIds.contains(messages[i].id) {
                if i > 0 { dirtyIds.insert(messages[i - 1].id) }
                if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
            }
            calculateLayouts(dirtyIds: dirtyIds)
        }
        // else: newIds is empty — no new messages, skip layout recalculation.
        // Prevents Combine debounce duplicate from killing insertion animations
        // via calculateLayoutsAsync → reconfigureVisibleCells → dataSource.apply.

        var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
        snapshot.appendSections([0])
        var itemIds = messages.reversed().map(\.id)

        // Insert unread separator visually above the first unread message.
        // Reversed array: index 0 = newest (visual bottom), index N = oldest (visual top).
        // In inverted layout higher index = higher on screen, so insert at idx+1
        // to place separator between last-read and first-unread.
        if let unreadId = config.firstUnreadMessageId,
           let idx = itemIds.firstIndex(of: unreadId) {
            itemIds.insert(Self.unreadSeparatorId, at: idx + 1)
        }
        snapshot.appendItems(itemIds)

        // Reconfigure only neighbor cells whose BubblePosition/tail may have changed.
        // BubblePosition depends only on immediate predecessor/successor (shouldMerge),
        // so we only need to reconfigure messages adjacent to each new/changed message.
        if !newIds.isEmpty && newIds.count <= 20 {
            var neighborsToReconfigure = Set<String>()
            for i in messages.indices {
                if newIds.contains(messages[i].id) {
                    if i > 0 { neighborsToReconfigure.insert(messages[i - 1].id) }
                    if i < messages.count - 1 { neighborsToReconfigure.insert(messages[i + 1].id) }
                }
            }
            // Only reconfigure old cells that were already in the snapshot
            let toReconfigure = neighborsToReconfigure.filter { oldIds.contains($0) }
            if !toReconfigure.isEmpty {
                snapshot.reconfigureItems(Array(toReconfigure))
            }
        } else {
            // Bulk update (pagination, sync): reconfigure all existing cells
            let existingItems = itemIds.filter { oldIds.contains($0) }
            if !existingItems.isEmpty {
                snapshot.reconfigureItems(existingItems)
            }
        }

        let snapStart = CACurrentMediaTime()
        dataSource.apply(snapshot, animatingDifferences: false)
        if !hasCompletedInitialLoad {
            let snapMs = (CACurrentMediaTime() - snapStart) * 1000
            Self.perfLog.notice("⚡ snapshot.apply: \(itemIds.count, privacy: .public) items in \(String(format: "%.0f", snapMs), privacy: .public)ms")
        }

        // Voice send correlation: fire deferred collapse when the voice cell appears.
        // Handled AFTER layout settles (below), regardless of isInteractive.
        let voiceCorrelationMatch = pendingVoiceCollapse.flatMap { pending in
            newIds.contains(pending.messageId) ? pending : nil
        }
        if voiceCorrelationMatch != nil {
            pendingVoiceCollapse = nil
            voiceCollapseTimer?.cancel()
            voiceCollapseTimer = nil
        }

        // Dismiss skeleton AFTER snapshot applied — cells now exist for fly-in animation
        if shouldDismissSkeleton {
            collectionView.layoutIfNeeded()
            hideSkeletonAnimated()
        }

        if isInteractive {
            applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)

            // Animate date pills with same spring as cells
            updateFloatingDateHeader()
            for (i, pill) in datePillPool.enumerated() {
                guard i < oldPillPositions.count,
                      !pill.container.isHidden,
                      oldPillPositions[i].visible else { continue }
                let dy = oldPillPositions[i].y - pill.container.layer.position.y
                guard abs(dy) > 0.5 else { continue }

                let move = CASpringAnimation(keyPath: "position.y")
                move.fromValue = dy
                move.toValue = 0.0
                move.isAdditive = true
                move.stiffness = 555.0
                move.damping = 47.0
                move.mass = 1.0
                move.initialVelocity = 0
                move.duration = move.settlingDuration
                move.fillMode = .backwards
                pill.container.layer.add(move, forKey: "pillInsertionMove")
            }
        } else {
            updateFloatingDateHeader()
        }

        // Voice send: ensure cell is visible and animated, then fire collapse.
        if let match = voiceCorrelationMatch {
            // Scroll to bottom first so the voice cell is in the viewport
            collectionView.setContentOffset(
                CGPoint(x: 0, y: -collectionView.contentInset.top),
                animated: false
            )
            collectionView.layoutIfNeeded()

            // Voice cell animation is handled by UIKit's animatingDifferences: true.
            // No additional animation needed here.

            match.collapseAction()
        }

        if !hasCompletedInitialLoad && !messages.isEmpty {
            hasCompletedInitialLoad = true
        }

        updateScrollToBottomBadge()
    }

    /// Telegram-identical insertion animation:
    /// - New cells: contentView alpha 0→1 over 0.2s (matches ChatMessageBubbleItemNode.animateInsertion)
    /// - Existing cells: spring position animation on vertical delta (cells shift up smoothly)
    /// Uses contentView.alpha (UIView.animate) instead of cell.layer CABasicAnimation because
    /// reconfigureVisibleCells replaces content configuration but does NOT reset contentView.alpha.
    private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
        for ip in collectionView.indexPathsForVisibleItems {
            guard let cellId = dataSource.itemIdentifier(for: ip),
                  let cell = collectionView.cellForItem(at: ip) else { continue }

            if newIds.contains(cellId) {
                // Telegram: subnodes alpha 0→1 over 0.2s (animateInsertion/animateAdded)
                cell.contentView.alpha = 0
                UIView.animate(withDuration: 0.2) {
                    cell.contentView.alpha = 1
                }
            } else if let oldY = oldPositions[cellId] {
                // Existing cell shifted — animate position delta with spring
                let newY = cell.layer.position.y
                let dy = oldY - newY
                guard abs(dy) > 0.5 else { continue }

                let move = CASpringAnimation(keyPath: "position.y")
                move.fromValue = dy
                move.toValue = 0.0
                move.isAdditive = true
                move.stiffness = 555.0
                move.damping = 47.0
                move.mass = 1.0
                move.initialVelocity = 0
                move.duration = move.settlingDuration
                move.fillMode = .backwards
                cell.layer.add(move, forKey: "insertionMove")
            }
        }
    }

    // MARK: - Layout Calculation (Telegram asyncLayout pattern)

    /// Recalculate layouts for messages. When `dirtyIds` is provided, only those
    /// messages are recalculated (incremental mode). Otherwise recalculates all.
    /// `messageSubset` overrides the message array (used for windowed initial sync).
    private func calculateLayouts(dirtyIds: Set<String>? = nil, messageSubset: [ChatMessage]? = nil) {
        let msgs = messageSubset ?? messages
        guard !msgs.isEmpty else {
            layoutCache.removeAll()
            textLayoutCache.removeAll()
            return
        }
        let isDark = cachedIsDarkMode
        let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
            messages: msgs,
            maxBubbleWidth: config.maxBubbleWidth,
            currentPublicKey: config.currentPublicKey,
            opponentPublicKey: config.opponentPublicKey,
            opponentTitle: config.opponentTitle,
            isGroupChat: config.isGroupChat,
            groupAdminKey: config.groupAdminKey,
            isDarkMode: isDark,
            dirtyIds: dirtyIds,
            existingLayouts: dirtyIds != nil ? layoutCache : nil,
            existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil
        )
        layoutCache = layouts
        textLayoutCache = textLayouts
    }

    /// Async layout calculation on background thread via LayoutEngine actor.
    /// Results applied on main thread; stale results discarded via generation counter.
    /// `messageSubset` limits calculation to fewer messages (reduces CPU/heating).
    private func calculateLayoutsAsync(dirtyIds: Set<String>? = nil, messageSubset: [ChatMessage]? = nil) {
        let msgs = messageSubset ?? messages
        guard !msgs.isEmpty else {
            layoutCache.removeAll()
            textLayoutCache.removeAll()
            return
        }
        let isDark = cachedIsDarkMode

        let request = LayoutEngine.LayoutRequest(
            messages: msgs,
            maxBubbleWidth: config.maxBubbleWidth,
            currentPublicKey: config.currentPublicKey,
            opponentPublicKey: config.opponentPublicKey,
            opponentTitle: config.opponentTitle,
            isGroupChat: config.isGroupChat,
            groupAdminKey: config.groupAdminKey,
            isDarkMode: isDark,
            dirtyIds: dirtyIds,
            existingLayouts: dirtyIds != nil ? layoutCache : nil,
            existingTextLayouts: dirtyIds != nil ? textLayoutCache : nil
        )

        let asyncStart = CACurrentMediaTime()
        let msgCount = msgs.count
        Task { @MainActor [weak self] in
            let result = await LayoutEngine.shared.calculate(request)
            let asyncMs = (CACurrentMediaTime() - asyncStart) * 1000
            Self.perfLog.notice("⚡ layoutAsync: \(msgCount, privacy: .public) msgs in \(String(format: "%.0f", asyncMs), privacy: .public)ms (background)")
            guard let self, result.generation >= self.layoutGeneration else { return }
            self.layoutGeneration = result.generation
            // Merge instead of replace — preserves layouts added by sync interactive
            // inserts that happened while async was computing on background thread.
            // Without merge: new message layout gets lost → text disappears briefly.
            self.layoutCache.merge(result.layouts) { _, new in new }
            self.textLayoutCache.merge(result.textLayouts) { _, new in new }
            self.reconfigureVisibleCells()
        }
    }

    // MARK: - Inset Management

    /// Update content insets for composer overlap + keyboard.
    /// CollectionView extends full-screen (behind composer for glass/blur).
    /// contentInset.top (= visual bottom in inverted scroll) = composer space.
    ///
    /// Compensation rules for inverted scroll:
    /// - delta > 0 (keyboard opening / composer growing): ALWAYS compensate
    ///   offset so content rides up with keyboard, preserving reading context.
    /// - delta < 0 (keyboard closing / composer shrinking): do NOTHING.
    ///   UIScrollView automatically clamps contentOffset.y to the new minimum
    ///   (-contentInset.top) with smooth animation. Manual compensation here
    ///   would double the adjustment → content teleports upward.
    private func applyInsets() {
        guard collectionView != nil else { return }
        updateScrollToBottomButtonConstraints()

        let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
        let composerHeight = lastComposerHeight
        let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
        let topInset = view.safeAreaInsets.top + topStickyOffset + 6

        let oldInsetTop = collectionView.contentInset.top
        let delta = newInsetTop - oldInsetTop

        // Capture offset BEFORE setting insets — UIKit may auto-clamp after.
        let oldOffset = collectionView.contentOffset.y

        let recentScrollToBottom = CFAbsoluteTimeGetCurrent() - scrollToBottomTimestamp < 1.0

        collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
        collectionView.scrollIndicatorInsets = collectionView.contentInset

        if recentScrollToBottom && abs(delta) > 0.5 {
            // An animated scroll-to-bottom was recently started.
            // Reading contentOffset during the animation returns an intermediate
            // value — compensating from it produces a wrong final position.
            // Snap to the new bottom instead.
            collectionView.setContentOffset(
                CGPoint(x: 0, y: -newInsetTop), animated: false
            )
        } else {
            // Normal compensation: preserve reading position.
            let shouldCompensate = abs(delta) > 0.5
                && !collectionView.isDragging
                && !collectionView.isDecelerating
            if shouldCompensate {
                collectionView.contentOffset.y = oldOffset - delta
            }
        }
        updateScrollToBottomBadge()
    }

    /// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
    /// Short distance: standard animated scroll.
    /// Long distance: snapshot-based transition (no intermediate cell rendering).
    func scrollToBottom(animated: Bool) {
        guard !messages.isEmpty else { return }
        let targetY = -collectionView.contentInset.top
        let currentY = collectionView.contentOffset.y
        let distance = abs(currentY - targetY)

        guard animated, distance > 1 else {
            collectionView.setContentOffset(CGPoint(x: 0, y: targetY), animated: false)
            return
        }

        scrollToBottomTimestamp = CFAbsoluteTimeGetCurrent()

        if distance < 2000 {
            // Short distance: standard UICollectionView animated scroll
            collectionView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true)
            return
        }

        // Long distance: snapshot overlay hides the jump, then fades out.
        // 1. Capture current screen as snapshot
        let snapshot = collectionView.snapshotView(afterScreenUpdates: false)

        // 2. Jump to bottom instantly (cells load at destination)
        collectionView.setContentOffset(CGPoint(x: 0, y: targetY), animated: false)
        collectionView.layoutIfNeeded()

        // 3. Overlay snapshot → fade out to reveal new content
        if let snapshot {
            snapshot.frame = collectionView.bounds
            collectionView.addSubview(snapshot)
            UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
                snapshot.alpha = 0
            } completion: { _ in
                snapshot.removeFromSuperview()
            }
        }
    }

    /// Scroll to a specific message by ID (for reply quote navigation).
    func scrollToMessage(id: String, animated: Bool) {
        guard let snapshot = dataSource?.snapshot(),
              let itemIndex = snapshot.indexOfItem(id) else { return }
        let indexPath = IndexPath(item: itemIndex, section: 0)
        collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: animated)
    }

    /// Telegram-style highlight flash on a message cell.
    func animateHighlight(messageId: String?) {
        // Hide any previously highlighted cell
        for cell in collectionView.visibleCells {
            messageCell(from: cell)?.hideHighlight()
        }
        guard let messageId,
              let snapshot = dataSource?.snapshot(),
              let itemIndex = snapshot.indexOfItem(messageId) else { return }
        let indexPath = IndexPath(item: itemIndex, section: 0)
        guard let cell = messageCell(at: indexPath) else { return }
        cell.showHighlight()
    }

    /// Reconfigure visible cells without rebuilding the snapshot.
    func reconfigureVisibleCells() {
        var snapshot = dataSource.snapshot()
        let visibleIds = collectionView.indexPathsForVisibleItems.compactMap {
            dataSource.itemIdentifier(for: $0)
        }
        snapshot.reconfigureItems(visibleIds)
        dataSource.apply(snapshot, animatingDifferences: false)
    }

    /// Reconfigure all message cells and force a layout pass.
    /// Used for deterministic theme refresh when trait style changes.
    private func refreshAllMessageCells() {
        var snapshot = dataSource.snapshot()
        let allIds = snapshot.itemIdentifiers
        guard !allIds.isEmpty else { return }
        snapshot.reconfigureItems(allIds)
        dataSource.apply(snapshot, animatingDifferences: false)
        collectionView.collectionViewLayout.invalidateLayout()
        collectionView.layoutIfNeeded()
    }

    // MARK: - Bubble Position

    private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition {
        let chronoIndex = messages.count - 1 - reversedIndex
        guard chronoIndex >= 0, chronoIndex < messages.count else { return .single }

        let currentIsFromMe = message.isFromMe(myPublicKey: config.currentPublicKey)

        let hasPrev: Bool = {
            guard chronoIndex > 0 else { return false }
            let prev = messages[chronoIndex - 1]
            return prev.isFromMe(myPublicKey: config.currentPublicKey) == currentIsFromMe
        }()

        let hasNext: Bool = {
            guard chronoIndex + 1 < messages.count else { return false }
            let next = messages[chronoIndex + 1]
            return next.isFromMe(myPublicKey: config.currentPublicKey) == currentIsFromMe
        }()

        switch (hasPrev, hasNext) {
        case (false, false): return .single
        case (false, true):  return .top
        case (true, true):   return .mid
        case (true, false):  return .bottom
        }
    }

    // MARK: - Actions

    @objc private func handleBackgroundTap() {
        guard composerView?.isRecording != true else { return }
        onTapBackground?()
    }

    // MARK: - Telegram-Style Keyboard Handler

    /// Single handler for keyboard show/hide/resize (Telegram pattern).
    /// Extracts height, duration, curve from notification → animates ONE constraint.
    @objc private func keyboardWillChangeFrame(_ notification: Notification) {
        guard let info = notification.userInfo,
              let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
        else { return }

        let screenHeight = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height
        let keyboardHeight = max(0, screenHeight - endFrame.minY)
        let safeBottom = view.safeAreaInsets.bottom
        let composerBottom = max(keyboardHeight, safeBottom)

        let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
        let curve = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? 7

        currentKeyboardHeight = keyboardHeight
        isKeyboardAnimating = true

        // Telegram pattern: animate composer position + content insets in ONE block.
        // Explicit composerHeightConstraint prevents the 372pt inflation bug.
        let composerH = lastComposerHeight
        let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
        let topInset = view.safeAreaInsets.top + topStickyOffset + 6
        let oldInsetTop = collectionView.contentInset.top
        let delta = newInsetTop - oldInsetTop

        // Capture offset BEFORE animation — UIKit may auto-clamp after inset change.
        // Skip compensation during swipe-back — let content slide down naturally.
        let oldOffset = collectionView.contentOffset.y
        let recentScroll = CFAbsoluteTimeGetCurrent() - scrollToBottomTimestamp < 1.0
        let snapToBottom = recentScroll && abs(delta) > 0.5
        let shouldCompensate = !snapToBottom
            && !isDisappearing
            && abs(delta) > 0.5
            && !collectionView.isDragging
            && !collectionView.isDecelerating

        let options = UIView.AnimationOptions(rawValue: curve << 16).union(.beginFromCurrentState)
        UIView.animate(withDuration: duration, delay: 0, options: options, animations: {
            self.composerBottomConstraint?.constant = -composerBottom

            self.collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
            self.collectionView.scrollIndicatorInsets = self.collectionView.contentInset

            if snapToBottom {
                // Recent scroll-to-bottom — maintain bottom target.
                self.collectionView.contentOffset.y = -newInsetTop
            } else if shouldCompensate {
                self.collectionView.contentOffset.y = oldOffset - delta
            }

            self.view.layoutIfNeeded()

            // Position pills at FINAL positions — implicit animation from
            // UIView.animate matches keyboard curve/duration automatically.
            self.animatePillFrames = true
            self.updateFloatingDateHeader()
            self.animatePillFrames = false
        }, completion: { _ in
            self.isKeyboardAnimating = false
            self.updateFloatingDateHeader()
        })
    }

    /// Interactive dismiss — follows finger frame-by-frame.
    /// CRITICAL: Only process when user is actively dragging the collectionView.
    /// The inputAccessoryView KVO fires for ALL keyboard frame changes, including
    /// the system open/close animation. Without this guard, KVO races ahead of
    /// keyboardWillChangeFrame and steals the animation delta (sets insets to the
    /// target value instantly → the notification's delta becomes 0 → no offset
    /// compensation → content stays still while composer moves).
    @objc private func interactiveKeyboardHeightChanged(_ notification: Notification) {
        // Reject if system is animating keyboard (let keyboardWillChangeFrame handle it)
        guard !isKeyboardAnimating else { return }
        // Reject if user is NOT actively dragging — this KVO is a side-effect
        // of the system keyboard show/hide, not a genuine interactive dismiss
        guard collectionView.isDragging || collectionView.isTracking else { return }
        guard let height = notification.userInfo?["height"] as? CGFloat else { return }

        currentKeyboardHeight = height
        let composerBottom = max(height, view.safeAreaInsets.bottom)
        composerBottomConstraint?.constant = -composerBottom
        applyInsets()
    }

    @objc private func keyboardDidHide() {
        currentKeyboardHeight = 0
        isKeyboardAnimating = false
        onKeyboardDidHide?()
    }
}

// MARK: - UICollectionViewDelegate

extension NativeMessageListController: UICollectionViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if !isKeyboardAnimating {
            updateFloatingDateHeader()
        }

        let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
        let isAtBottom = offsetFromBottom < 50
        updateScrollToBottomBadge()

        // Dedup — only fire when value actually changes.
        // Without this, callback fires 60fps during keyboard animation
        // → @State update → SwiftUI re-render → layout loop → Hang.
        if isAtBottom != lastReportedAtBottom {
            lastReportedAtBottom = isAtBottom
            onScrollToBottomVisibilityChange?(isAtBottom)
            setScrollToBottomVisible(!isAtBottom)
        }

        // Top pagination (older messages) — inverted scroll: visual top = content bottom
        // Telegram parity: prefetch well ahead of the edge (~4 cells at 500pt).
        let offsetFromTop = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height
        if offsetFromTop < 500, hasMoreMessages {
            onPaginationTrigger?()
        }

        // Bottom pagination (newer messages) — reuse offsetFromBottom from above
        if offsetFromBottom < 500, hasNewerMessages {
            onBottomPaginationTrigger?()
        }
    }

}

// MARK: - UICollectionViewDataSourcePrefetching

extension NativeMessageListController: UICollectionViewDataSourcePrefetching {

    /// Telegram parity: proactively warm encrypted disk → memory image cache
    /// for upcoming cells. Eliminates placeholder→image flash during scroll.
    func collectionView(_ collectionView: UICollectionView,
                        prefetchItemsAt indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            guard let messageId = messageId(for: indexPath),
                  let idx = messageIdToIndex[messageId] else { continue }
            let msg = messages[idx]
            for att in msg.attachments where att.type == .image {
                let attId = att.id
                // Skip if already in memory cache
                if AttachmentCache.shared.cachedImage(forAttachmentId: attId) != nil { continue }
                // PERF: Use ImageLoadLimiter to cap concurrent disk I/O (max 3).
                // Previous unbounded Task.detached launched 150+ tasks during fast scroll.
                Task.detached(priority: .utility) {
                    await ImageLoadLimiter.shared.acquire()
                    let _ = AttachmentCache.shared.loadImage(forAttachmentId: attId)
                    await ImageLoadLimiter.shared.release()
                }
            }
        }
    }

    func collectionView(_ collectionView: UICollectionView,
                        cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        // No-op: disk-to-memory prefetch is cheap to complete
    }
}

// MARK: - ComposerViewDelegate

extension NativeMessageListController: ComposerViewDelegate {

    func composerDidTapSend(_ composer: ComposerView) {
        onComposerSend?()
    }

    func composerDidTapAttach(_ composer: ComposerView) {
        onComposerAttach?()
    }

    func composerTextDidChange(_ composer: ComposerView, text: String) {
        onComposerTextChange?(text)
    }

    func composerFocusDidChange(_ composer: ComposerView, isFocused: Bool) {
        onComposerFocusChange?(isFocused)
    }

    func composerHeightDidChange(_ composer: ComposerView, height: CGFloat) {
        guard abs(height - lastComposerHeight) > 0.5 else { return }
        lastComposerHeight = height
        composerHeightConstraint?.constant = height
        onComposerHeightChange?(height)
        // applyInsets() calculates the delta and compensates contentOffset.
        // No manual offset adjustment here — applyInsets() handles it uniformly.
        applyInsets()
    }

    func composerDidCancelReply(_ composer: ComposerView) {
        onComposerReplyCancel?()
    }

    func composerDidCancelForward(_ composer: ComposerView) {
        onComposerForwardCancel?()
    }

    func composerUserDidType(_ composer: ComposerView) {
        onComposerTyping?()
    }

    func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat) {
        // Route interactive dismiss KVO to the same notification handler
        // that NativeMessageListController already uses.
        NotificationCenter.default.post(
            name: NSNotification.Name("InteractiveKeyboardHeightChanged"),
            object: nil,
            userInfo: ["height": height]
        )
    }

    // MARK: - Voice Playback Live Updates

    private func setupVoicePlayerSubscription() {
        let player = VoiceMessagePlayer.shared

        // Throttle to ~30fps to avoid excessive updates (display link fires at 60-120fps)
        player.$progress
            .receive(on: RunLoop.main)
            .throttle(for: .milliseconds(33), scheduler: RunLoop.main, latest: true)
            .sink { [weak self] _ in
                self?.updatePlayingVoiceCell()
            }
            .store(in: &voicePlayerCancellables)

        // React immediately to play/stop state changes
        player.$isPlaying
            .receive(on: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] _ in
                self?.updatePlayingVoiceCell()
            }
            .store(in: &voicePlayerCancellables)

        // When playback stops (currentMessageId → nil), reset the previously playing cell
        player.$currentMessageId
            .receive(on: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] _ in
                self?.updateAllVisibleVoiceCells()
            }
            .store(in: &voicePlayerCancellables)

        // Sequential voice autoplay: when a voice message finishes, play the next one
        player.onPlaybackCompleted = { [weak self] finishedMessageId in
            self?.autoplayNextVoiceMessage(after: finishedMessageId)
        }
    }

    private func updatePlayingVoiceCell() {
        let player = VoiceMessagePlayer.shared
        guard let playingId = player.currentMessageId else { return }
        for cell in collectionView.visibleCells {
            guard let messageCell = self.messageCell(from: cell) else { continue }
            // Match main voice view OR any forward item voice view
            let matchesMain = messageCell.currentMessageId == playingId
            let matchesForward = messageCell.hasForwardVoiceWithPlaybackId(playingId)
            guard matchesMain || matchesForward else { continue }
            messageCell.updateVoicePlayback(
                isPlaying: player.isPlaying,
                progress: CGFloat(player.progress),
                currentTime: player.currentTime,
                duration: player.duration
            )
            return
        }
    }

    private func updateAllVisibleVoiceCells() {
        let player = VoiceMessagePlayer.shared
        for cell in collectionView.visibleCells {
            guard let messageCell = self.messageCell(from: cell) else { continue }
            let isThisMessage = messageCell.currentMessageId == player.currentMessageId
                || messageCell.hasForwardVoiceWithPlaybackId(player.currentMessageId)
            messageCell.updateVoicePlayback(
                isPlaying: isThisMessage && player.isPlaying,
                progress: isThisMessage ? CGFloat(player.progress) : 0,
                currentTime: isThisMessage ? player.currentTime : 0,
                duration: isThisMessage ? player.duration : 0
            )
        }
    }

    /// Sequential voice autoplay: after a voice message finishes, play the next
    /// consecutive voice message. Stops if the next message is not a voice type
    /// or if the next voice is not cached locally.
    private func autoplayNextVoiceMessage(after finishedMessageId: String) {
        guard let currentIndex = messageIdToIndex[finishedMessageId] else { return }
        let nextIndex = currentIndex + 1
        guard nextIndex < messages.count else { return }

        let nextMessage = messages[nextIndex]

        // Stop chain if next message is not a voice message
        guard let voiceAtt = nextMessage.attachments.first(where: { $0.type == .voice }) else { return }

        // Parse duration from preview to construct cache filename
        let previewParts = NativeMessageCell.parseVoicePreview(voiceAtt.preview)
        let fileName = "voice_\(Int(previewParts.duration))s.m4a"

        // Only auto-play if already cached (don't auto-download)
        guard let cachedURL = NativeMessageCell.playableVoiceURLFromCache(
            attachmentId: voiceAtt.id, fileName: fileName
        ) else { return }

        VoiceMessagePlayer.shared.play(messageId: nextMessage.id, fileURL: cachedURL)
    }

    // MARK: - Voice Recording

    func composerDidStartRecording(_ composer: ComposerView) {
        // Cancel any lingering pending collapse from a previous send
        if let pending = pendingVoiceCollapse {
            pendingVoiceCollapse = nil
            voiceCollapseTimer?.cancel()
            voiceCollapseTimer = nil
            pending.collapseAction()
        }
        // Block interactive keyboard dismiss while recording voice message.
        collectionView.keyboardDismissMode = .none
        setScrollToBottomVisible(false)
        updateScrollToBottomButtonConstraints()
        view.layoutIfNeeded()
    }

    func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
        collectionView.keyboardDismissMode = .interactive
        updateScrollToBottomButtonConstraints()

        guard sendImmediately,
              let url = composer.lastRecordedURL,
              let data = try? Data(contentsOf: url) else {
            // Guard fail while overlay may still be showing — force immediate collapse
            if composer.voiceSendNeedsDeferred {
                composer.performDeferredVoiceSendCollapse()
            }
            return
        }

        // Generate messageId HERE so we can register correlation BEFORE send
        let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()

        // Deferred collapse: register pending collapse for when the message cell appears
        if composer.voiceSendNeedsDeferred {
            let collapseAction: () -> Void = { [weak composer] in
                composer?.performDeferredVoiceSendCollapse()
            }
            pendingVoiceCollapse = (messageId: messageId, collapseAction: collapseAction)

            // Safety timer: if cell doesn't appear within 600ms, force collapse
            let timer = DispatchWorkItem { [weak self] in
                guard let self, let pending = self.pendingVoiceCollapse else { return }
                self.pendingVoiceCollapse = nil
                pending.collapseAction()
            }
            voiceCollapseTimer = timer
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: timer)
        }

        let pending = PendingAttachment.fromVoice(
            data: data,
            duration: composer.lastRecordedDuration,
            waveform: composer.lastRecordedWaveform
        )
        let pubKey = config.opponentPublicKey
        let title = config.opponentTitle
        let username = config.opponentUsername
        Task { @MainActor in
            _ = try? await SessionManager.shared.sendMessageWithAttachments(
                text: "",
                attachments: [pending],
                toPublicKey: pubKey,
                opponentTitle: title,
                opponentUsername: username,
                messageId: messageId
            )
        }
    }

    func composerDidCancelRecording(_ composer: ComposerView) {
        collectionView.keyboardDismissMode = .interactive
        updateScrollToBottomButtonConstraints()
        view.layoutIfNeeded()
    }

    func composerDidLockRecording(_ composer: ComposerView) {
        // Recording locked — UI handled by ComposerView
        updateScrollToBottomButtonConstraints()
        view.layoutIfNeeded()
    }

    private func animateVoiceSendTransition(source: VoiceSendTransitionSource, messageId: String) {
        guard let window = view.window else { return }
        let snapshot = source.snapshotView
        snapshot.frame = source.sourceFrameInWindow
        snapshot.layer.cornerRadius = source.cornerRadius
        snapshot.layer.cornerCurve = .continuous
        snapshot.clipsToBounds = true
        window.addSubview(snapshot)

        resolveVoiceTargetFrame(messageId: messageId, attempt: 0, snapshot: snapshot)
    }

    private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) {
        guard let window = view.window else {
            snapshot.removeFromSuperview()
            return
        }
        let maxAttempts = 12
        guard attempt <= maxAttempts else {
            UIView.animate(withDuration: 0.16, animations: {
                snapshot.alpha = 0
                snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
            }, completion: { _ in
                snapshot.removeFromSuperview()
            })
            return
        }

        let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window)
        guard let targetFrame else {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
                self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot)
            }
            return
        }
        UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) {
            snapshot.frame = targetFrame
            snapshot.layer.cornerRadius = 12
            snapshot.alpha = 0.84
        } completion: { _ in
            UIView.animate(withDuration: 0.12, animations: {
                snapshot.alpha = 0
            }, completion: { _ in
                snapshot.removeFromSuperview()
            })
        }
    }

    private func targetFrameForVoiceMessage(messageId: String, in window: UIWindow) -> CGRect? {
        let snapshot = dataSource.snapshot()
        guard let itemIndex = snapshot.indexOfItem(messageId) else { return nil }
        let indexPath = IndexPath(item: itemIndex, section: 0)

        if collectionView.cellForItem(at: indexPath) == nil {
            collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false)
            collectionView.layoutIfNeeded()
        }
        guard let cell = messageCell(at: indexPath) else {
            return nil
        }
        return cell.voiceTransitionTargetFrame(in: window) ?? cell.bubbleFrameInWindow(window)
    }
}

// MARK: - PreSizedCell

/// UICollectionViewCell subclass that uses pre-calculated heights from C++ engine.
/// When `preCalculatedHeight` is set, `preferredLayoutAttributesFitting` returns it
/// immediately — skipping the expensive SwiftUI self-sizing layout pass.
final class PreSizedCell: UICollectionViewCell {
    /// Precomputed layout height for the message cell. Set in cell registration closure.
    var preCalculatedHeight: CGFloat?

    override func preferredLayoutAttributesFitting(
        _ layoutAttributes: UICollectionViewLayoutAttributes
    ) -> UICollectionViewLayoutAttributes {
        if let h = preCalculatedHeight {
            let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
            attrs.size.height = h
            return attrs
        }
        // Fallback: UIHostingConfiguration self-sizing
        return super.preferredLayoutAttributesFitting(layoutAttributes)
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        preCalculatedHeight = nil
    }
}

// MARK: - NativeUnreadSeparator

private struct NativeUnreadSeparator: View {
    var body: some View {
        HStack(spacing: 8) {
            Rectangle()
                .fill(RosettaColors.Adaptive.textTertiary.opacity(0.4))
                .frame(height: 0.5)
            Text("Unread Messages")
                .font(.system(size: 13, weight: .medium))
                .foregroundStyle(RosettaColors.Adaptive.textSecondary)
            Rectangle()
                .fill(RosettaColors.Adaptive.textTertiary.opacity(0.4))
                .frame(height: 0.5)
        }
        .padding(.vertical, 8)
        .padding(.horizontal, 6)
    }
}

// MARK: - NativeMessageListView (UIViewControllerRepresentable)

/// SwiftUI bridge for NativeMessageListController.
struct NativeMessageListView: UIViewControllerRepresentable {
    let messages: [ChatMessage]
    let maxBubbleWidth: CGFloat
    let currentPublicKey: String
    let highlightedMessageId: String?
    let route: ChatRoute
    let actions: MessageCellActions
    let hasMoreMessages: Bool
    let firstUnreadMessageId: String?
    /// true = create UIKit ComposerView bridge. false = no composer (system chats).
    let useUIKitComposer: Bool
    /// Empty chat state data (iOS < 26). nil = no empty state management.
    var emptyChatInfo: EmptyChatInfo?
    var scrollToMessageId: String?
    var shouldScrollToBottom: Bool = false
    var scrollToBottomTrigger: UInt = 0
    var onAtBottomChange: ((Bool) -> Void)?
    var onPaginate: (() -> Void)?
    var onBottomPaginate: (() -> Void)?
    var onJumpToBottom: (() -> Void)?
    var hasNewerMessagesFlag: Bool = false
    var onTapBackground: (() -> Void)?
    var onNewMessageAutoScroll: (() -> Void)?
    var onComposerHeightChange: ((CGFloat) -> Void)?
    var onKeyboardDidHide: (() -> Void)?

    // Multi-select state
    var isMultiSelectMode: Bool = false
    var selectedMessageIds: Set<String> = []

    // Composer state (iOS < 26, forwarded to ComposerView)
    @Binding var messageText: String
    @Binding var isInputFocused: Bool
    var replySenderName: String?
    var replyPreviewText: String?
    var onSend: (() -> Void)?
    var onAttach: (() -> Void)?
    var onTyping: (() -> Void)?
    var onReplyCancel: (() -> Void)?

    func makeUIViewController(context: Context) -> NativeMessageListController {
        let config = NativeMessageListController.Config(
            maxBubbleWidth: maxBubbleWidth,
            currentPublicKey: currentPublicKey,
            highlightedMessageId: highlightedMessageId,
            isSavedMessages: route.isSavedMessages,
            isSystemAccount: route.isSystemAccount,
            isGroupChat: route.isGroup,
            opponentPublicKey: route.publicKey,
            opponentTitle: route.title,
            opponentUsername: route.username,
            actions: actions,
            firstUnreadMessageId: firstUnreadMessageId
        )

        let controller = NativeMessageListController(config: config)
        controller.hasMoreMessages = hasMoreMessages

        // Create pure UIKit composer (iOS < 26)
        if useUIKitComposer {
            controller.setupComposer()
        }

        wireCallbacks(controller, context: context)

        // Force view load so dataSource/collectionView are initialized.
        controller.loadViewIfNeeded()
        // Do NOT call update(messages:) here — defer to updateUIViewController.
        // Telegram-iOS pattern: controller starts empty/skeleton, messages load
        // in the first updateUIViewController pass. This keeps makeUIViewController
        // lightweight so NavigationStack push animation starts instantly.

        // Apply initial composer state
        if useUIKitComposer {
            syncComposerState(controller)
        }

        return controller
    }

    func updateUIViewController(_ controller: NativeMessageListController, context: Context) {
        let coordinator = context.coordinator

        // Update config
        var configChanged = false
        if controller.config.maxBubbleWidth != maxBubbleWidth {
            controller.config.maxBubbleWidth = maxBubbleWidth
            configChanged = true
        }
        let highlightChanged = controller.config.highlightedMessageId != highlightedMessageId
        if highlightChanged {
            controller.config.highlightedMessageId = highlightedMessageId
        }
        if controller.config.firstUnreadMessageId != firstUnreadMessageId {
            controller.config.firstUnreadMessageId = firstUnreadMessageId
            configChanged = true
        }

        controller.hasMoreMessages = hasMoreMessages
        controller.hasNewerMessages = hasNewerMessagesFlag

        wireCallbacks(controller, context: context)

        // Multi-select state sync
        if controller.isSelectionMode != isMultiSelectMode {
            controller.setSelectionMode(isMultiSelectMode, animated: true)
        }
        if controller.selectedMessageIds != selectedMessageIds {
            controller.updateSelectedIds(selectedMessageIds)
        }

        // Sync composer state (iOS < 26)
        if useUIKitComposer {
            syncComposerState(controller)
            // Force immediate layout to process pending height change from reply
            // bar show/hide. Without this, height report is deferred to
            // DispatchQueue.main.async, and scrollToBottom may calculate offset
            // before contentInset reflects the new composer height.
            controller.composerView?.layoutIfNeeded()
        }

        // Filter out service messages ($a=) — only for chat list display
        let visibleMessages = messages.filter { !$0.text.hasPrefix("$a=") }

        // Update messages
        let messagesChanged = coordinator.lastMessageFingerprint != messageFingerprint
        if messagesChanged {
            let wasAtBottom = coordinator.isAtBottom
            let lastMessageId = coordinator.lastNewestMessageId
            let newNewestId = visibleMessages.last?.id
            let lastIsOutgoing = visibleMessages.last?.isFromMe(myPublicKey: currentPublicKey) == true

            controller.update(messages: visibleMessages)
            coordinator.lastMessageFingerprint = messageFingerprint
            coordinator.lastNewestMessageId = newNewestId

            if (wasAtBottom || shouldScrollToBottom || lastIsOutgoing),
               newNewestId != lastMessageId, newNewestId != nil {
                DispatchQueue.main.async {
                    controller.scrollToBottom(animated: true)
                }
                onNewMessageAutoScroll?()
            }
        } else if configChanged {
            controller.reconfigureVisibleCells()
        }

        // Scroll-to-bottom button request (counter avoids Binding write-back cycle)
        if scrollToBottomTrigger != coordinator.lastScrollTrigger {
            coordinator.lastScrollTrigger = scrollToBottomTrigger
            DispatchQueue.main.async {
                controller.scrollToBottom(animated: true)
            }
        }

        // Scroll-to-message request
        if let targetId = scrollToMessageId, targetId != coordinator.lastScrollTargetId {
            coordinator.lastScrollTargetId = targetId
            DispatchQueue.main.async {
                controller.scrollToMessage(id: targetId, animated: true)
            }
        }

        // Highlight animation (Telegram-style flash on scroll-to-message)
        if highlightChanged {
            controller.animateHighlight(messageId: highlightedMessageId)
        }

        // Empty state (pure UIKit — animates with keyboard)
        if let info = emptyChatInfo {
            controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: info)
        }
    }

    private func wireCallbacks(_ controller: NativeMessageListController, context: Context) {
        let coordinator = context.coordinator

        controller.onScrollToBottomVisibilityChange = { [weak coordinator] isAtBottom in
            coordinator?.isAtBottom = isAtBottom
            DispatchQueue.main.async { onAtBottomChange?(isAtBottom) }
        }
        controller.onPaginationTrigger = { onPaginate?() }
        controller.onBottomPaginationTrigger = { onBottomPaginate?() }
        controller.onJumpToBottom = { onJumpToBottom?() }
        controller.onTapBackground = { onTapBackground?() }
        controller.onComposerHeightChange = { h in
            DispatchQueue.main.async { onComposerHeightChange?(h) }
        }
        controller.onKeyboardDidHide = { onKeyboardDidHide?() }

        // Composer callbacks → SwiftUI state
        controller.onComposerSend = { onSend?() }
        controller.onComposerAttach = { onAttach?() }
        controller.onComposerTextChange = { text in
            DispatchQueue.main.async { messageText = text }
        }
        controller.onComposerFocusChange = { focused in
            DispatchQueue.main.async { isInputFocused = focused }
        }
        controller.onComposerReplyCancel = { onReplyCancel?() }
        controller.onComposerTyping = { onTyping?() }
    }

    private func syncComposerState(_ controller: NativeMessageListController) {
        guard let composer = controller.composerView else { return }
        composer.setText(messageText)
        composer.setReply(senderName: replySenderName, previewText: replyPreviewText)

        // After swipe-back dismisses keyboard, block programmatic re-focus
        // until user taps text field OR SwiftUI sets isInputFocused=true (reply).
        if controller.keyboardBlockedUntilUserTap {
            if isInputFocused {
                controller.keyboardBlockedUntilUserTap = false
                // Fall through to setFocused below
            } else {
                return // Don't touch focus while blocked
            }
        }

        composer.setFocused(isInputFocused)
    }

    // MARK: - Fingerprint

    private var messageFingerprint: String {
        guard let first = messages.first, let last = messages.last else { return "empty" }
        return "\(messages.count)_\(first.id)_\(last.id)_\(last.deliveryStatus.rawValue)_\(last.isRead)"
    }

    // MARK: - Coordinator

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    final class Coordinator {
        var lastMessageFingerprint: String = ""
        var lastNewestMessageId: String?
        var lastScrollTargetId: String?
        var lastScrollTrigger: UInt = 0
        var isAtBottom: Bool = true
    }
}

// MARK: - Empty Chat State

struct EmptyChatInfo {
    let title: String
    let subtitle: String
    let initials: String
    let colorIndex: Int
    let isOnline: Bool
    let isSavedMessages: Bool
    let avatarImage: UIImage?
    let isGroup: Bool
}

// MARK: - UIFont/UIColor Helpers (Empty State)

private extension UIFont {
    func emptyStateRounded() -> UIFont {
        guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self }
        return UIFont(descriptor: descriptor, size: 0)
    }
}

private extension UIColor {
    func emptyStateBlended(with color: UIColor, alpha: CGFloat) -> UIColor {
        var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
        var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
        getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
        color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
        let inv = 1.0 - alpha
        return UIColor(
            red: r1 * inv + r2 * alpha,
            green: g1 * inv + g2 * alpha,
            blue: b1 * inv + b2 * alpha,
            alpha: a1 * inv + a2 * alpha
        )
    }
}
