import UIKit
import SwiftUI

// MARK: - ChatListCollectionController

/// UIViewController hosting a UICollectionView for the chat list.
/// Uses DiffableDataSource for smooth animated updates and manual-frame ChatListCell
/// for Telegram-level scroll performance.
///
/// Integrates into SwiftUI via `ChatListCollectionView` (UIViewControllerRepresentable).
final class ChatListCollectionController: UIViewController {

    // MARK: - Sections

    enum Section: Int, CaseIterable {
        case requests
        case pinned
        case unpinned
    }

    // MARK: - Callbacks (to SwiftUI)

    var onSelectDialog: ((Dialog) -> Void)?
    var onDeleteDialog: ((Dialog) -> Void)?
    var onTogglePin: ((Dialog) -> Void)?
    var onToggleMute: ((Dialog) -> Void)?
    var onPinnedStateChange: ((Bool) -> Void)?
    var onPinnedHeaderFractionChange: ((CGFloat) -> Void)?
    var onShowRequests: (() -> Void)?
    var onScrollToTopRequested: (() -> Void)?
    var onScrollOffsetChange: ((CGFloat) -> Void)?
    var onMarkAsRead: ((Dialog) -> Void)?

    // MARK: - Data

    private(set) var pinnedDialogs: [Dialog] = []
    private(set) var unpinnedDialogs: [Dialog] = []
    private(set) var requestsCount: Int = 0
    private(set) var typingDialogs: [String: Set<String>] = [:]
    private(set) var isSyncing: Bool = false
    private var lastReportedExpansion: CGFloat = 1.0
    private var lastReportedPinnedHeaderFraction: CGFloat = -1.0
    private let searchCollapseDistance: CGFloat = 54
    /// Extra top offset for custom header bar (nav bar is hidden)
    var customHeaderBarHeight: CGFloat = 44
    private var searchHeaderExpansion: CGFloat = 1.0
    private var hasInitializedTopOffset = false
    private var isPinnedFractionReportScheduled = false
    /// PERF: Track last structural snapshot apply to suppress animations during bursts.
    private var lastStructureChangeTime: CFAbsoluteTime = 0

    // MARK: - UI

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
    /// Overscroll background — fills rubber-band area with pinnedItemBackgroundColor (Telegram parity)
    private let overscrollBackgroundView = UIView()
    private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
    private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
    private let floatingTabBarTotalHeight: CGFloat = 72
    private var chatListBottomInset: CGFloat {
        floatingTabBarTotalHeight + view.safeAreaInsets.bottom
    }

    // Dialog lookup by ID for cell configuration
    private var dialogMap: [String: Dialog] = [:]

    /// PERF: Cached section identifiers — avoids dataSource.snapshot() copy per scroll frame.
    private var cachedSectionIdentifiers: [Section] = []

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
        setupCollectionView()
        setupCellRegistrations()
        setupDataSource()
        setupScrollToTop()
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
        // PERF: Reconfigure only visible cells instead of full reloadData().
        // reloadData() discards all cell state and rebuilds everything.
        if let visiblePaths = collectionView.indexPathsForVisibleItems as? [IndexPath], !visiblePaths.isEmpty {
            var snapshot = dataSource.snapshot()
            let visibleIds = visiblePaths.compactMap { dataSource.itemIdentifier(for: $0) }
            snapshot.reconfigureItems(visibleIds)
            dataSource.apply(snapshot, animatingDifferences: false)
            cachedSectionIdentifiers = snapshot.sectionIdentifiers
        }
    }

    deinit {
        NotificationCenter.default.removeObserver(self, name: .chatListScrollToTop, object: nil)
    }

    // MARK: - Collection View Setup

    private func setupCollectionView() {
        let layout = createLayout()
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
        // PERF: Override tintColor to match background — prevents AccentColor (#248AE6)
        // from flashing through during diffable data source animation stacking.
        collectionView.tintColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
        collectionView.delegate = self
        collectionView.prefetchDataSource = self
        collectionView.keyboardDismissMode = .onDrag
        collectionView.showsVerticalScrollIndicator = false
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.alwaysBounceVertical = true
        collectionView.alwaysBounceHorizontal = false
        collectionView.contentInsetAdjustmentBehavior = .never
        applyInsets()
        view.addSubview(collectionView)

        // Overscroll background — behind cells, shows pinnedItemBackgroundColor on pull-down bounce
        overscrollBackgroundView.isUserInteractionEnabled = false
        overscrollBackgroundView.isHidden = true
        collectionView.insertSubview(overscrollBackgroundView, at: 0)

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

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        applyInsets()
        if !hasInitializedTopOffset {
            collectionView.setContentOffset(
                CGPoint(x: 0, y: -collectionView.contentInset.top),
                animated: false
            )
            hasInitializedTopOffset = true
        }
        schedulePinnedHeaderFractionReport()
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        applyInsets()
    }

    private func applyInsets() {
        guard collectionView != nil else { return }
        let oldTopInset = collectionView.contentInset.top
        let topInset = view.safeAreaInsets.top + customHeaderBarHeight + (searchCollapseDistance * searchHeaderExpansion)
        let bottomInset = chatListBottomInset
        collectionView.contentInset.top = topInset
        collectionView.contentInset.bottom = bottomInset
        collectionView.verticalScrollIndicatorInsets.top = topInset
        collectionView.verticalScrollIndicatorInsets.bottom = bottomInset

        guard hasInitializedTopOffset,
              !collectionView.isDragging,
              !collectionView.isDecelerating else { return }

        let delta = topInset - oldTopInset
        if abs(delta) > 0.1 {
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            collectionView.contentOffset.y -= delta
            CATransaction.commit()
        }
    }

    func setSearchHeaderExpansion(_ expansion: CGFloat) {
        let clamped = max(0.0, min(1.0, expansion))
        guard abs(searchHeaderExpansion - clamped) > 0.002 else { return }

        CATransaction.begin()
        CATransaction.setDisableActions(true)
        searchHeaderExpansion = clamped
        applyInsets()
        CATransaction.commit()
        reportPinnedHeaderFraction()
    }


    private func updateOverscrollBackground() {
        let hasPin = !pinnedDialogs.isEmpty
        overscrollBackgroundView.isHidden = !hasPin
        guard hasPin, collectionView != nil else { return }

        let isDark = traitCollection.userInterfaceStyle == .dark
        overscrollBackgroundView.backgroundColor = isDark
            ? UIColor(red: 0x1C / 255, green: 0x1C / 255, blue: 0x1D / 255, alpha: 1)
            : UIColor(red: 0xF7 / 255, green: 0xF7 / 255, blue: 0xF7 / 255, alpha: 1)

        let offset = collectionView.contentOffset.y
        let insetTop = collectionView.contentInset.top
        let overscrollAmount = -(offset + insetTop)
        let width = collectionView.bounds.width

        if overscrollAmount > 0 {
            overscrollBackgroundView.frame = CGRect(
                x: 0, y: offset,
                width: width, height: insetTop + overscrollAmount
            )
        } else {
            overscrollBackgroundView.frame = CGRect(
                x: 0, y: -insetTop,
                width: width, height: insetTop
            )
        }
    }

    private func schedulePinnedHeaderFractionReport(force: Bool = false) {
        if isPinnedFractionReportScheduled { return }
        isPinnedFractionReportScheduled = true
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            self.isPinnedFractionReportScheduled = false
            self.reportPinnedHeaderFraction(force: force)
        }
    }

    private func calculatePinnedHeaderFraction() -> CGFloat {
        guard collectionView != nil,
              view.window != nil,
              collectionView.window != nil,
              !pinnedDialogs.isEmpty else { return 0.0 }

        // Telegram: itemNode.frame is in VISUAL space (changes with scroll).
        // UICollectionView cell.frame is in CONTENT space (static).
        // Convert: visibleMaxY = cell.frame.maxY - contentOffset.y
        let offset = collectionView.contentOffset.y
        var maxPinnedVisibleOffset: CGFloat = 0.0
        var foundAny = false

        for cell in collectionView.visibleCells {
            guard let indexPath = collectionView.indexPath(for: cell),
                  sectionForIndexPath(indexPath) == .pinned else {
                continue
            }
            let visibleMaxY = cell.frame.maxY - offset
            maxPinnedVisibleOffset = max(maxPinnedVisibleOffset, visibleMaxY)
            foundAny = true
        }

        if !foundAny { return 0.0 }

        let viewportInsetTop = collectionView.contentInset.top
        guard viewportInsetTop > 0 else { return 0.0 }

        if maxPinnedVisibleOffset >= viewportInsetTop {
            return 1.0
        }

        return max(0.0, min(1.0, maxPinnedVisibleOffset / viewportInsetTop))
    }

    private func reportPinnedHeaderFraction(force: Bool = false) {
        let fraction = calculatePinnedHeaderFraction()
        if !force, abs(fraction - lastReportedPinnedHeaderFraction) < 0.005 {
            return
        }
        lastReportedPinnedHeaderFraction = fraction
        onPinnedHeaderFractionChange?(fraction)
    }

    private func createLayout() -> UICollectionViewCompositionalLayout {
        var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
        listConfig.showsSeparators = false
        listConfig.backgroundColor = .clear

        // Swipe actions
        listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
            self?.trailingSwipeActions(for: indexPath)
        }
        listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
            self?.leadingSwipeActions(for: indexPath)
        }

        let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
            let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
            section.interGroupSpacing = 0

            // Add pinned section background decoration to pinned AND requests sections
            // (requests sit above pinned, so they share the same gray background)
            if let self,
               sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
               !self.pinnedDialogs.isEmpty {
                let sectionId = self.dataSource?.snapshot().sectionIdentifiers[sectionIndex]
                if sectionId == .pinned || sectionId == .requests {
                    let bgItem = NSCollectionLayoutDecorationItem.background(
                        elementKind: PinnedSectionBackgroundView.elementKind
                    )
                    section.decorationItems = [bgItem]
                }
            }

            return section
        }

        layout.register(
            PinnedSectionBackgroundView.self,
            forDecorationViewOfKind: PinnedSectionBackgroundView.elementKind
        )

        return layout
    }

    // MARK: - Cell Registrations

    private func setupCellRegistrations() {
        cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
            [weak self] cell, indexPath, dialog in
            guard let self else { return }
            let typingUsers = self.typingDialogs[dialog.opponentKey]
            cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)

            // Separator rules:
            // 1) last pinned row -> full-width separator
            // 2) last unpinned row -> hidden separator
            // 3) others -> regular inset separator
            let section = self.sectionForIndexPath(indexPath)
            let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1
            let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1
            if isLastInPinned {
                cell.setSeparatorStyle(hidden: false, fullWidth: true)
            } else {
                cell.setSeparatorStyle(hidden: isLastInUnpinned, fullWidth: false)
            }
        }

        requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
            [weak self] cell, _, count in
            // Always show separator under requests row
            cell.configure(count: count, showBottomSeparator: true)
        }
    }

    // MARK: - Data Source

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

            // CRITICAL: use sectionIdentifier, NOT rawValue mapping.
            // When sections are skipped (e.g. no requests), indexPath.section=0
            // could be .pinned, not .requests. rawValue mapping would be wrong.
            let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]

            if section == .requests {
                return collectionView.dequeueConfiguredReusableCell(
                    using: self.requestsCellRegistration,
                    for: indexPath,
                    item: self.requestsCount
                )
            }

            guard let dialog = self.dialogMap[itemId] else {
                return UICollectionViewCell()
            }

            return collectionView.dequeueConfiguredReusableCell(
                using: self.cellRegistration,
                for: indexPath,
                item: dialog
            )
        }
    }

    // MARK: - Update Data

    func updateDialogs(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int,
                       typingDialogs: [String: Set<String>], isSyncing: Bool) {
        self.typingDialogs = typingDialogs
        self.isSyncing = isSyncing

        // Check if structure changed (IDs or order)
        let oldPinnedIds = self.pinnedDialogs.map(\.id)
        let oldUnpinnedIds = self.unpinnedDialogs.map(\.id)
        let newPinnedIds = pinned.map(\.id)
        let newUnpinnedIds = unpinned.map(\.id)
        let structureChanged = oldPinnedIds != newPinnedIds
            || oldUnpinnedIds != newUnpinnedIds
            || self.requestsCount != requestsCount

        self.pinnedDialogs = pinned
        self.unpinnedDialogs = unpinned
        self.requestsCount = requestsCount

        // Build lookup map
        dialogMap.removeAll(keepingCapacity: true)
        for d in pinned { dialogMap[d.id] = d }
        for d in unpinned { dialogMap[d.id] = d }

        if structureChanged {
            // Structure changed — rebuild snapshot (animate inserts/deletes/moves)
            var snapshot = NSDiffableDataSourceSnapshot<Section, String>()

            if requestsCount > 0 {
                snapshot.appendSections([.requests])
                snapshot.appendItems(["__requests__"], toSection: .requests)
            }
            if !pinned.isEmpty {
                snapshot.appendSections([.pinned])
                snapshot.appendItems(newPinnedIds, toSection: .pinned)
            }
            snapshot.appendSections([.unpinned])
            snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)

            // PERF: Suppress animations during sync or rapid structural changes
            // to prevent overlapping UIKit diffable-data-source animations (blue flash).
            let now = CFAbsoluteTimeGetCurrent()
            let timeSinceLastChange = now - lastStructureChangeTime
            lastStructureChangeTime = now
            let shouldAnimate = !isSyncing && timeSinceLastChange > 0.3

            cachedSectionIdentifiers = snapshot.sectionIdentifiers
            dataSource.apply(snapshot, animatingDifferences: shouldAnimate) { [weak self] in
                self?.reportPinnedHeaderFraction(force: true)
            }
        } else {
            reportPinnedHeaderFraction()
        }

        // Always reconfigure ONLY visible cells (cheap — just updates content, no layout rebuild).
        // PERF: Suppress implicit animations to prevent blue flash during rapid sync updates.
        UIView.performWithoutAnimation {
            reconfigureVisibleCells()
        }

        // Notify host immediately so top chrome reacts in the same frame.
        onPinnedStateChange?(!pinned.isEmpty)
        updateOverscrollBackground()
    }

    /// Directly reconfigure only visible cells — no snapshot rebuild, no animation.
    /// This is the cheapest way to update cell content (online, read status, badges).
    private func reconfigureVisibleCells() {
        for cell in collectionView.visibleCells {
            guard let indexPath = collectionView.indexPath(for: cell) else { continue }
            guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }

            if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
                let typingUsers = typingDialogs[dialog.opponentKey]
                chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
            } else if let reqCell = cell as? ChatListRequestsCell {
                reqCell.configure(count: requestsCount, showBottomSeparator: true)
            }
        }
    }

    // MARK: - Scroll to Top

    private func setupScrollToTop() {
        NotificationCenter.default.addObserver(
            self, selector: #selector(handleScrollToTop),
            name: .chatListScrollToTop, object: nil
        )
    }

    @objc private func handleScrollToTop() {
        guard collectionView.numberOfSections > 0,
              collectionView.numberOfItems(inSection: 0) > 0 else { return }
        collectionView.scrollToItem(
            at: IndexPath(item: 0, section: 0),
            at: .top,
            animated: true
        )
        // Reset search bar expansion
        lastReportedExpansion = 1.0
        onScrollOffsetChange?(1.0)
    }

    // MARK: - Swipe Actions

    private func sectionForIndexPath(_ indexPath: IndexPath) -> Section? {
        // PERF: Use cached identifiers — avoids full snapshot copy per call.
        // Cache is updated on every dataSource.apply().
        guard indexPath.section < cachedSectionIdentifiers.count else { return nil }
        return cachedSectionIdentifiers[indexPath.section]
    }

    private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard let section = sectionForIndexPath(indexPath),
              section != .requests else { return nil }

        let dialog = dialogForIndexPath(indexPath)
        guard let dialog else { return nil }

        // Delete
        let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
            DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
            completion(true)
        }
        delete.image = UIImage(systemName: "trash.fill")
        delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)

        // Mute/Unmute (skip for Saved Messages)
        guard !dialog.isSavedMessages else {
            return UISwipeActionsConfiguration(actions: [delete])
        }

        let mute = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
            DispatchQueue.main.async { self?.onToggleMute?(dialog) }
            completion(true)
        }
        mute.image = UIImage(systemName: dialog.isMuted ? "bell.fill" : "bell.slash.fill")
        mute.backgroundColor = dialog.isMuted
            ? UIColor.systemGreen
            : UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange

        return UISwipeActionsConfiguration(actions: [delete, mute])
    }

    private func leadingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard let section = sectionForIndexPath(indexPath),
              section != .requests else { return nil }

        let dialog = dialogForIndexPath(indexPath)
        guard let dialog else { return nil }

        let pin = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
            DispatchQueue.main.async { self?.onTogglePin?(dialog) }
            completion(true)
        }
        pin.image = UIImage(systemName: dialog.isPinned ? "pin.slash.fill" : "pin.fill")
        pin.backgroundColor = UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange

        let config = UISwipeActionsConfiguration(actions: [pin])
        config.performsFirstActionWithFullSwipe = true
        return config
    }

    private func dialogForIndexPath(_ indexPath: IndexPath) -> Dialog? {
        guard let itemId = dataSource.itemIdentifier(for: indexPath) else { return nil }
        return dialogMap[itemId]
    }
}

// MARK: - UICollectionViewDelegate

extension ChatListCollectionController: UICollectionViewDelegate {

    // MARK: - Scroll-Linked Search Bar (Telegram: 54pt collapse distance)

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // Only react to user-driven scroll, not programmatic/layout changes
        guard scrollView.isDragging || scrollView.isDecelerating else { return }
        let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + customHeaderBarHeight + searchCollapseDistance
        let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance))
        if abs(expansion - lastReportedExpansion) > 0.005 {
            lastReportedExpansion = expansion
            onScrollOffsetChange?(expansion)
        }
        reportPinnedHeaderFraction()
        updateOverscrollBackground()
    }

    func scrollViewWillEndDragging(
        _ scrollView: UIScrollView,
        withVelocity velocity: CGPoint,
        targetContentOffset: UnsafeMutablePointer<CGPoint>
    ) {
        // Telegram snap-to-edge: if search bar is partially visible, snap to
        // fully visible (>50%) or fully hidden (<50%).
        guard lastReportedExpansion > 0.0 && lastReportedExpansion < 1.0 else { return }
        let headerTop = view.safeAreaInsets.top + customHeaderBarHeight
        if lastReportedExpansion < 0.5 {
            targetContentOffset.pointee.y = -headerTop
        } else {
            targetContentOffset.pointee.y = -(headerTop + searchCollapseDistance)
        }
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.deselectItem(at: indexPath, animated: true)

        guard let section = sectionForIndexPath(indexPath) else { return }

        if section == .requests {
            DispatchQueue.main.async { [weak self] in
                self?.onShowRequests?()
            }
            return
        }

        guard let dialog = dialogForIndexPath(indexPath) else { return }
        DispatchQueue.main.async { [weak self] in
            self?.onSelectDialog?(dialog)
        }
    }

    // MARK: - Context Menu (Long Press)

    func collectionView(
        _ collectionView: UICollectionView,
        contextMenuConfigurationForItemAt indexPath: IndexPath,
        point: CGPoint
    ) -> UIContextMenuConfiguration? {
        guard let section = sectionForIndexPath(indexPath),
              section != .requests,
              let dialog = dialogForIndexPath(indexPath) else { return nil }

        return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
            guard let self else { return nil }

            let pinTitle = dialog.isPinned ? "Unpin" : "Pin"
            let pinImage = UIImage(systemName: dialog.isPinned ? "pin.slash" : "pin")
            let pinAction = UIAction(title: pinTitle, image: pinImage) { [weak self] _ in
                DispatchQueue.main.async { self?.onTogglePin?(dialog) }
            }

            var actions: [UIAction] = [pinAction]

            if !dialog.isSavedMessages {
                let muteTitle = dialog.isMuted ? "Unmute" : "Mute"
                let muteImage = UIImage(systemName: dialog.isMuted ? "bell" : "bell.slash")
                let muteAction = UIAction(title: muteTitle, image: muteImage) { [weak self] _ in
                    DispatchQueue.main.async { self?.onToggleMute?(dialog) }
                }
                actions.append(muteAction)
            }

            if dialog.unreadCount > 0 {
                let readAction = UIAction(
                    title: "Mark as Read",
                    image: UIImage(systemName: "checkmark.message")
                ) { [weak self] _ in
                    DispatchQueue.main.async { self?.onMarkAsRead?(dialog) }
                }
                actions.append(readAction)
            }

            let deleteAction = UIAction(
                title: "Delete",
                image: UIImage(systemName: "trash"),
                attributes: .destructive
            ) { [weak self] _ in
                DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
            }
            actions.append(deleteAction)

            return UIMenu(children: actions)
        }
    }
}

// MARK: - UICollectionViewDataSourcePrefetching

extension ChatListCollectionController: UICollectionViewDataSourcePrefetching {

    /// PERF: Shared serial queue limits concurrent avatar decrypt to 1 at a time.
    /// Previous implementation launched unbounded concurrent tasks (50+) during scroll.
    private static let avatarPrefetchQueue: OperationQueue = {
        let q = OperationQueue()
        q.maxConcurrentOperationCount = 3
        q.qualityOfService = .utility
        q.name = "com.rosetta.avatar-prefetch"
        return q
    }()

    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            guard let itemId = dataSource.itemIdentifier(for: indexPath),
                  let dialog = dialogMap[itemId],
                  !dialog.isSavedMessages else { continue }
            let key = dialog.opponentKey
            Self.avatarPrefetchQueue.addOperation {
                _ = AvatarRepository.shared.loadAvatar(publicKey: key)
            }
        }
    }
}

// MARK: - Request Chats Cell

/// Simple cell for "Request Chats" row at the top (like Telegram's Archived Chats).
final class ChatListRequestsCell: UICollectionViewCell {

    private let avatarCircle = UIView()
    private let iconView = UIImageView()
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private let separatorView = UIView()

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

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupSubviews() {
        backgroundColor = .clear
        contentView.backgroundColor = .clear

        avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue)
        avatarCircle.layer.cornerRadius = 30
        avatarCircle.clipsToBounds = true
        contentView.addSubview(avatarCircle)

        iconView.image = UIImage(systemName: "tray.and.arrow.down")?.withConfiguration(
            UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
        )
        iconView.tintColor = .white
        iconView.contentMode = .center
        contentView.addSubview(iconView)

        titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
        titleLabel.text = "Request Chats"
        contentView.addSubview(titleLabel)

        subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
        contentView.addSubview(subtitleLabel)

        separatorView.isUserInteractionEnabled = false
        contentView.addSubview(separatorView)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        let h = contentView.bounds.height
        let w = contentView.bounds.width

        let avatarY = floor((h - 60) / 2)
        avatarCircle.frame = CGRect(x: 10, y: avatarY, width: 60, height: 60)
        iconView.frame = avatarCircle.frame

        titleLabel.frame = CGRect(x: 80, y: 14, width: w - 96, height: 22)
        subtitleLabel.frame = CGRect(x: 80, y: 36, width: w - 96, height: 20)

        let sepH = 1.0 / UIScreen.main.scale
        separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
    }

    func configure(count: Int, showBottomSeparator: Bool) {
        let isDark = traitCollection.userInterfaceStyle == .dark
        titleLabel.textColor = isDark ? .white : .black
        subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
        subtitleLabel.textColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
        separatorView.backgroundColor = isDark
            ? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
            : UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1)
        separatorView.isHidden = !showBottomSeparator
    }

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

// MARK: - ChatListCell Self-Sizing Override

extension ChatListCell {
    override func preferredLayoutAttributesFitting(
        _ layoutAttributes: UICollectionViewLayoutAttributes
    ) -> UICollectionViewLayoutAttributes {
        let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
        attrs.size.height = ChatListCell.CellLayout.itemHeight
        return attrs
    }
}
