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 = 8
        static let scrollButtonCompactExtraTrailing: CGFloat = 18
        static let scrollButtonBottomOffset: CGFloat = 20
    }

    // MARK: - Configuration

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

    // MARK: - UIKit

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

    // 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 var currentKeyboardHeight: CGFloat = 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: - 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()

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

    // Phase 7: No viewWillAppear/viewWillDisappear — CADisplayLink removed entirely.

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

    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 }

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

            // Parse reply data for quote display
            let replyAtt = message.attachments.first { $0.type == .messages }
            var replyName: String?
            var replyText: String?
            var forwardSenderName: 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(message.text) ? "" : message.text
                    if displayText.isEmpty {
                        // Forward
                        forwardSenderName = name
                    } else {
                        // Reply quote
                        replyName = name
                        replyText = first.message.isEmpty ? "Photo" : first.message
                    }
                }
            }

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

    /// 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 = true
        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 + UIConstants.scrollButtonBottomOffset)
        )
        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 = true
        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
        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 + UIConstants.scrollButtonBottomOffset)
    }

    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)

            let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
            gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
            gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
            gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
            gc.strokePath()
        }.withRenderingMode(.alwaysOriginal)
    }

    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: - Update

    /// Called from SwiftUI when messages array changes.
    func update(messages: [ChatMessage], animated: Bool = false) {
        let oldIds = Set(self.messages.map(\.id))
        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: animated)
        updateScrollToBottomBadge()
    }

    // 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
        }
        #if DEBUG
        let start = CFAbsoluteTimeGetCurrent()
        #endif

        let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
            messages: messages,
            maxBubbleWidth: config.maxBubbleWidth,
            currentPublicKey: config.currentPublicKey,
            opponentPublicKey: config.opponentPublicKey,
            opponentTitle: config.opponentTitle
        )
        layoutCache = layouts
        textLayoutCache = textLayouts

        #if DEBUG
        let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
        print("⚡ PERF_LAYOUT | \(messages.count) msgs | \(String(format: "%.1f", elapsed))ms | textLayouts cached: \(textLayouts.count)")
        #endif
    }

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

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

        // Always compensate symmetrically: open AND close.
        // Setting exact `oldOffset - delta` overrides any UIKit auto-clamping.
        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 }
        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)
    }

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

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

        #if DEBUG
        let screenH = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height
        let kbH = max(0, screenH - endFrame.minY)
        print("⌨️ keyboardWillChange | kbHeight=\(kbH) endFrame=\(endFrame) composerH=\(lastComposerHeight) currentInset=\(collectionView?.contentInset.top ?? 0)")
        #endif

        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.
        let oldOffset = collectionView.contentOffset.y
        let shouldCompensate = 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

            // Enforce exact offset — overrides any UIKit auto-clamping.
            if shouldCompensate {
                self.collectionView.contentOffset.y = oldOffset - delta
            }

            self.view.layoutIfNeeded()
        }, completion: { _ in
            self.isKeyboardAnimating = false
        })
    }

    /// 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() {
        #if DEBUG
        print("⌨️ didHide | cv.frame=\(collectionView?.frame ?? .zero) composer.frame=\(composerView?.frame ?? .zero) offset=\(collectionView?.contentOffset ?? .zero) composerConst=\(composerBottomConstraint?.constant ?? 0)")
        #endif
        currentKeyboardHeight = 0
        isKeyboardAnimating = false
        onKeyboardDidHide?()
    }
}

// MARK: - UICollectionViewDelegate

extension NativeMessageListController: UICollectionViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        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
    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,
            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
        }
        if controller.config.highlightedMessageId != highlightedMessageId {
            controller.config.highlightedMessageId = highlightedMessageId
            configChanged = true
        }
        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)
        }

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

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