import SwiftUI
import UIKit

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

    }

    // MARK: - Configuration

    struct Config {
        var maxBubbleWidth: CGFloat
        var currentPublicKey: String
        var highlightedMessageId: String?
        var isSavedMessages: Bool
        var isSystemAccount: Bool
        var isGroupChat: Bool = false
        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 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 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: - UIKit

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
    private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>!

    // 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: - 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 dateHideTimer: Timer?
    private var areDatePillsVisible = false
    private var animatePillFrames = false

    // MARK: - Empty State (UIKit-managed, animates with keyboard)
    private var emptyStateHosting: UIHostingController<EmptyChatContent>?
    private var emptyStateGuide: UILayoutGuide?

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

    /// 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] = [:]

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

        setupCollectionView()
        setupNativeCellRegistration()
        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
        )
        // 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 }
            NativeMessageCell.regenerateBubbleImages(with: self.traitCollection)
            self.calculateLayouts()
            self.refreshAllMessageCells()
        }
    }

    @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.
    }

    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.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<NativeMessageCell, ChatMessage> {
            [weak self] cell, indexPath, message in
            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
            let replyAtt = msg.attachments.first { $0.type == .messages }
            var replyName: String?
            var replyText: String?
            var replyMessageId: String?
            var forwardSenderName: String?
            var forwardSenderKey: String?

            if let att = replyAtt {
                if let data = att.blob.data(using: .utf8),
                   let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data),
                   let first = replies.first {
                    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
                    if displayText.isEmpty {
                        // Forward
                        forwardSenderName = name
                        forwardSenderKey = senderKey
                    } else {
                        // Reply quote
                        replyName = name
                        let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message
                        replyText = EmojiParser.replaceShortcodes(in: rawReplyMsg)
                        replyMessageId = first.message_id
                    }
                }
            }

            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: replyName,
                replyText: replyText,
                replyMessageId: replyMessageId,
                forwardSenderName: forwardSenderName,
                forwardSenderKey: forwardSenderKey
            )
        }
    }

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

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

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

            let reversedIndex = indexPath.item
            guard reversedIndex < self.messages.count else { return UICollectionViewCell() }

            let messageIndex = self.messages.count - 1 - reversedIndex
            let message = self.messages[messageIndex]

            // ALL messages use pure UIKit NativeMessageCell (no SwiftUI)
            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.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()
    }

    @objc private func scrollToBottomTapped() {
        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 }
        let isCurrentlyVisible = button.alpha > 0.5
        guard visible != isCurrentlyVisible else { return }

        UIView.animate(withDuration: 0.3, delay: 0,
                       usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
                       options: .beginFromCurrentState) {
            button.alpha = visible ? 1 : 0
            button.layer.transform = visible
                ? 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)
    }

    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() {
        // Pre-create pool of 4 pills (max visible date sections at once).
        for _ in 0..<4 {
            let pill = makeDatePill()
            view.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 + 8

        // 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 let nativeCell = cell as? NativeMessageCell else { continue }
            // Determine this cell's date text
            // Use the layout's dateHeaderText if available, else compute from message
            let cellFrame = collectionView.convert(cell.frame, to: view)

            // We need the date text for this cell. Find its message.
            guard let ip = collectionView.indexPath(for: cell) else { continue }
            let msgIndex = messages.count - 1 - ip.item
            guard msgIndex >= 0, msgIndex < messages.count else { continue }
            let msgDate = Date(timeIntervalSince1970: TimeInterval(messages[msgIndex].timestamp) / 1000)
            let dateText = Self.formatDateText(msgDate)

            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()
            if let composer = composerView {
                view.insertSubview(pill.container, belowSubview: composer)
            } else {
                view.addSubview(pill.container)
            }
            datePillPool.append(pill)
        }

        // 3. Position each section's pill using Telegram's formula.
        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)

            // Is this pill stuck (clamped to stickyY) or at natural position?
            let isStuck = naturalY < stickyY && headerY <= stickyY

            // Skip only 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)
            pill.label.text = section.text
            pill.label.sizeToFit()
            let textW = ceil(pill.label.intrinsicContentSize.width)
            let pillW = textW + hPad * 2
            let screenW = UIScreen.main.bounds.width
            let pillFrame = CGRect(
                x: round((screenW - pillW) / 2), y: headerY,
                width: pillW, height: pillH
            )
            pill.container.layer.cornerRadius = pillH / 2
            pill.container.isHidden = false
            // Natural-position pills always visible. Stuck pills fade with timer.
            pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
            pill.container.tag = isStuck ? 1 : 0
            CATransaction.commit()

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

        // 3. Show/hide with timer.
        if usedPillCount > 0 {
            showDatePills()
        }
    }

    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 func showDatePills() {
        dateHideTimer?.invalidate()
        if !areDatePillsVisible {
            areDatePillsVisible = true
            UIView.animate(withDuration: 0.2) {
                for pill in self.datePillPool where !pill.container.isHidden {
                    // tag=1 → stuck pill (fade in). tag=0 → natural (already alpha=1).
                    if pill.container.tag == 1 { pill.container.alpha = 1 }
                }
            }
        }
        dateHideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
            self?.hideDatePills()
        }
    }

    private func hideDatePills() {
        guard areDatePillsVisible else { return }
        areDatePillsVisible = false
        UIView.animate(withDuration: 0.4) {
            for pill in self.datePillPool where !pill.container.isHidden {
                // Only fade STUCK pills (tag=1). Natural pills stay visible.
                if pill.container.tag == 1 { pill.container.alpha = 0 }
            }
        }
    }

    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 hosting = emptyStateHosting {
                hosting.rootView = EmptyChatContent(info: info)
                hosting.view.isHidden = false
            } else {
                setupEmptyState(info: info)
            }
        } else {
            emptyStateHosting?.view.isHidden = true
        }
    }

    private func setupEmptyState(info: EmptyChatInfo) {
        let hosting = UIHostingController(rootView: EmptyChatContent(info: info))
        hosting.view.backgroundColor = .clear
        hosting.view.translatesAutoresizingMaskIntoConstraints = false

        addChild(hosting)
        if let cv = collectionView {
            view.insertSubview(hosting.view, aboveSubview: cv)
        } else {
            view.addSubview(hosting.view)
        }
        hosting.didMove(toParent: self)

        // Layout guide spans from safe area top to composer top.
        // When keyboard moves the composer, this guide shrinks and
        // the empty state re-centers — all in the same UIKit animation block.
        let guide = UILayoutGuide()
        view.addLayoutGuide(guide)

        let bottomAnchor = composerView?.topAnchor ?? view.safeAreaLayoutGuide.bottomAnchor

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

        emptyStateHosting = hosting
        emptyStateGuide = guide
    }

    // MARK: - Update

    /// Called from SwiftUI when messages array changes.
    func update(messages: [ChatMessage], animated: Bool = false) {
        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 {
                if let cellId = dataSource.itemIdentifier(for: ip),
                   let cell = collectionView.cellForItem(at: ip) {
                    oldPositions[cellId] = cell.layer.position.y
                }
            }
        }

        self.messages = messages

        // Recalculate ALL layouts — BubblePosition depends on neighbors in the FULL
        // array, so inserting one message changes the previous message's position/tail.
        // CoreText measurement is ~0.1ms per message; 50 msgs ≈ 5ms — well under 16ms.
        calculateLayouts()

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

        // Reconfigure existing cells whose BubblePosition/tail may have changed.
        // Without this, DiffableDataSource reuses stale cells (wrong corners/tail).
        let existingItems = itemIds.filter { oldIds.contains($0) }
        if !existingItems.isEmpty {
            snapshot.reconfigureItems(existingItems)
        }

        dataSource.apply(snapshot, animatingDifferences: false)

        // Apply Telegram-style insertion animations after layout settles
        if isInteractive {
            collectionView.layoutIfNeeded()
            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()
        }

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

        updateScrollToBottomBadge()
    }

    /// Telegram-style message insertion animation (iOS 26+ parity).
    /// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s).
    /// Existing messages: spring position animation from old Y to new Y.
    /// All position animations use CASpringAnimation (stiffness=555, damping=47).
    /// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex.
    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) {
                // NEW cell: slide up from below + alpha fade
                // In inverted CV: negative offset = below on screen
                let slideOffset = -cell.bounds.height * 1.2

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

                // Alpha fade: 0 → 1 (Telegram-parity: fast fade)
                let alpha = CABasicAnimation(keyPath: "opacity")
                alpha.fromValue = 0.0
                alpha.toValue = 1.0
                alpha.duration = 0.12
                alpha.fillMode = .backwards
                cell.contentView.layer.add(alpha, forKey: "insertionAlpha")

            } else if let oldY = oldPositions[cellId] {
                // EXISTING cell: spring from old position to new position
                let delta = oldY - cell.layer.position.y
                guard abs(delta) > 0.5 else { continue }

                let move = CASpringAnimation(keyPath: "position.y")
                move.fromValue = delta
                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 ALL messages using the full array.
    /// BubblePosition is computed from neighbors — partial recalculation produces
    /// stale positions (wrong corners, missing tails on live insertion).
    private func calculateLayouts() {
        guard !messages.isEmpty else {
            layoutCache.removeAll()
            textLayoutCache.removeAll()
            return
        }
        let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
        let isDark = themeMode != "light"
        let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
            messages: messages,
            maxBubbleWidth: config.maxBubbleWidth,
            currentPublicKey: config.currentPublicKey,
            opponentPublicKey: config.opponentPublicKey,
            opponentTitle: config.opponentTitle,
            isGroupChat: config.isGroupChat,
            isDarkMode: isDark
        )
        layoutCache = layouts
        textLayoutCache = textLayouts
    }

    // 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 + 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).
    func scrollToBottom(animated: Bool) {
        guard !messages.isEmpty else { return }
        if animated { scrollToBottomTimestamp = CFAbsoluteTimeGetCurrent() }
        collectionView.setContentOffset(
            CGPoint(x: 0, y: -collectionView.contentInset.top),
            animated: animated
        )
    }

    /// 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 {
            (cell as? NativeMessageCell)?.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 = collectionView.cellForItem(at: indexPath) as? NativeMessageCell 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() {
        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 + 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)
        }

        let offsetFromTop = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height
        if offsetFromTop < 200, hasMoreMessages {
            onPaginationTrigger?()
        }
    }

}

// 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 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: - 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 (iOS < 26). false = iOS 26+ (SwiftUI overlay).
    let useUIKitComposer: Bool
    /// Empty chat state data (iOS < 26). nil = no empty state management.
    var emptyChatInfo: EmptyChatInfo?
    var scrollToMessageId: String?
    var shouldScrollToBottom: Bool = false
    @Binding var scrollToBottomRequested: Bool
    var onAtBottomChange: ((Bool) -> Void)?
    var onPaginate: (() -> Void)?
    var onTapBackground: (() -> Void)?
    var onNewMessageAutoScroll: (() -> Void)?
    var onComposerHeightChange: ((CGFloat) -> Void)?
    var onKeyboardDidHide: (() -> Void)?

    // 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()
        controller.update(messages: messages)

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

        wireCallbacks(controller, context: context)

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

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

            controller.update(messages: messages)
            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
        if scrollToBottomRequested {
            DispatchQueue.main.async {
                controller.scrollToBottom(animated: true)
                scrollToBottomRequested = false
            }
        }

        // 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 (iOS < 26 — UIKit-managed for keyboard animation parity)
        if let info = emptyChatInfo {
            controller.updateEmptyState(isEmpty: messages.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.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 isAtBottom: Bool = true
    }
}

// MARK: - Empty Chat State (UIKit-hosted SwiftUI content)

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

/// SwiftUI content rendered inside a UIHostingController so it participates
/// in the UIKit keyboard animation block (same timing as the composer).
struct EmptyChatContent: View {
    let info: EmptyChatInfo

    var body: some View {
        VStack(spacing: 16) {
            AvatarView(
                initials: info.initials,
                colorIndex: info.colorIndex,
                size: 80,
                isOnline: info.isOnline,
                isSavedMessages: info.isSavedMessages,
                image: info.avatarImage
            )

            VStack(spacing: 4) {
                Text(info.title)
                    .font(.system(size: 17, weight: .semibold))
                    .foregroundStyle(RosettaColors.Adaptive.text)

                if !info.isSavedMessages && !info.subtitle.isEmpty {
                    Text(info.subtitle)
                        .font(.system(size: 14, weight: .regular))
                        .foregroundStyle(RosettaColors.Adaptive.textSecondary)
                }
            }

            Text(info.isSavedMessages
                 ? "Save messages here for quick access"
                 : "No messages yet")
                .font(.system(size: 15))
                .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
                .padding(.top, 4)
        }
        .padding(.horizontal, 24)
        .padding(.vertical, 20)
        .background { TelegramGlassRoundedRect(cornerRadius: 20) }
    }
}
