import UIKit
import Combine
import SwiftUI
import os

// MARK: - ChatDetailViewController

/// Pure UIKit replacement for SwiftUI ChatDetailView.
/// Hosts NativeMessageListController as child VC, adds custom toolbar + edge effects.
/// Phase 1: shell + child VC + state + callbacks + Combine subscriptions.
final class ChatDetailViewController: UIViewController {

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

    // MARK: - Route & ViewModel

    let route: ChatRoute
    private let viewModel: ChatDetailViewModel
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Child VC

    private var messageListController: NativeMessageListController!
    private let cellActions = MessageCellActions()

    // MARK: - State (mirrors SwiftUI @State)

    private var messageText = ""
    private var isViewActive = false
    private var isInputFocused = false
    private var isAtBottom = true
    private var composerHeight: CGFloat = 56
    private var firstUnreadMessageId: String?
    private var replyingToMessage: ChatMessage?
    private var scrollToMessageId: String?
    private var highlightedMessageId: String?
    private var scrollToBottomTrigger: UInt = 0
    private var pendingAttachments: [PendingAttachment] = []
    private var forwardingMessage: ChatMessage?
    private var forwardingMessages: [ChatMessage] = []
    private var pendingForwardData: [ReplyMessageData]?
    private var messageToDelete: ChatMessage?
    private var isMultiSelectMode = false
    private var selectedMessageIds: Set<String> = []
    private var mentionSearchHandlerId: UUID?

    // MARK: - Selection Header (replaces nav chrome in-place)

    private var selectionDeleteButton: UIControl?
    private var selectionDeleteGlass: TelegramGlassUIView?
    private var selectionPillGlass: TelegramGlassUIView?
    private var selectionPillLabel: UILabel?
    private var selectionForwardCircle: UIView?
    private var selectionForwardButton: UIControl?
    private var selectionBottomToolbar: UIView?

    // MARK: - Cached

    private let currentPublicKey = SessionManager.shared.currentPublicKey

    // MARK: - Message fingerprint (for diffing)

    private var lastMessageFingerprint = ""
    private var lastNewestMessageId: String?
    private var lastScrollTargetId: String?
    private var lastScrollTrigger: UInt = 0

    // MARK: - Toolbar UI

    private let headerBarHeight: CGFloat = 44
    private var backButton: UIControl!
    private var titlePill: UIControl!
    private var avatarButton: UIControl!

    // MARK: - Wallpaper

    private let wallpaperImageView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFill
        iv.clipsToBounds = true
        return iv
    }()

    // MARK: - Edge Effects

    private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
    private let bottomEdgeGradientView: UIView = {
        let v = UIView()
        v.isUserInteractionEnabled = false
        return v
    }()
    private let bottomGradientLayer = CAGradientLayer()

    // MARK: - Init

    init(route: ChatRoute, pendingForwardData: [ReplyMessageData]? = nil) {
        self.route = route
        self.pendingForwardData = pendingForwardData
        self.viewModel = ChatDetailViewModel(dialogKey: route.publicKey)
        super.init(nibName: nil, bundle: nil)
        hidesBottomBarWhenPushed = true
    }

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

    // MARK: - Lifecycle

    override func viewDidLoad() {
        let vdlStart = CACurrentMediaTime()
        super.viewDidLoad()
        view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }

        // Capture first unread BEFORE creating message list — Telegram parity:
        // separator is in the initial snapshot, no pop-in.
        firstUnreadMessageId = viewModel.messages.first(where: {
            !$0.isRead && $0.fromPublicKey != currentPublicKey
        })?.id

        setupWallpaper()
        setupMessageListController()
        setupNavigationChrome()
        setupEdgeEffects()
        wireCellActions()
        wireViewModelSubscriptions()
        let vdlMs = (CACurrentMediaTime() - vdlStart) * 1000
        Self.perfLog.notice("⚡ viewDidLoad \(String(format: "%.0f", vdlMs), privacy: .public)ms | msgs=\(self.viewModel.messages.count, privacy: .public)")

        // Show forward preview bar if VC was opened with pending forward data
        if pendingForwardData != nil {
            syncComposerForwardState()
        }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appDidBecomeActive),
            name: UIApplication.didBecomeActiveNotification,
            object: nil
        )
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Nav bar hidden BEFORE animation starts (no flash).
        // Also handled by ChatListRootViewController willShow delegate.
        navigationController?.setNavigationBarHidden(true, animated: animated)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        let appearStart = CACurrentMediaTime()
        setupFullWidthSwipeBack()
        activateChat()
        let appearMs = (CACurrentMediaTime() - appearStart) * 1000
        Self.perfLog.notice("⚡ viewDidAppear+activateChat \(String(format: "%.0f", appearMs), privacy: .public)ms")
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        // Cancel voice recording during navigation pop to prevent UI freeze
        if let composer = messageListController?.composerView,
           composer.recordingFlowState != .idle {
            if let coordinator = transitionCoordinator, coordinator.isInteractive {
                composer.pauseRecordingForSwipeBack()
                coordinator.notifyWhenInteractionChanges { [weak composer] context in
                    guard let composer else { return }
                    if context.isCancelled {
                        composer.resumeRecordingAfterSwipeBack()
                    } else {
                        composer.cancelRecordingForNavigation()
                    }
                }
            } else {
                composer.cancelRecordingForNavigation()
            }
        }

        deactivateChat()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        layoutCustomHeader()
        updateEdgeEffectFrames()
    }

    // MARK: - Full-Width Swipe Back

    private var addedSwipeBackGesture = false

    private func setupFullWidthSwipeBack() {
        guard !addedSwipeBackGesture else { return }
        addedSwipeBackGesture = true

        guard let nav = navigationController,
              let edgeGesture = nav.interactivePopGestureRecognizer,
              let targets = edgeGesture.value(forKey: "targets") as? NSArray,
              targets.count > 0 else { return }

        edgeGesture.isEnabled = true

        let fullWidthGesture = UIPanGestureRecognizer()
        fullWidthGesture.setValue(targets, forKey: "targets")
        fullWidthGesture.delegate = self
        nav.view.addGestureRecognizer(fullWidthGesture)
    }

    // MARK: - Navigation Bar

    func navigationController(
        _ navigationController: UINavigationController,
        willShow viewController: UIViewController,
        animated: Bool
    ) {
        let hide = viewController === self || viewController is OpponentProfileViewController
        navigationController.setNavigationBarHidden(hide, animated: animated)
    }

    // MARK: - Child VC Setup

    private func setupMessageListController() {
        let config = NativeMessageListController.Config(
            maxBubbleWidth: calculateMaxBubbleWidth(),
            currentPublicKey: currentPublicKey,
            highlightedMessageId: nil,
            isSavedMessages: route.isSavedMessages,
            isSystemAccount: route.isSystemAccount,
            isGroupChat: route.isGroup,
            opponentPublicKey: route.publicKey,
            opponentTitle: route.title,
            opponentUsername: route.username,
            actions: cellActions,
            firstUnreadMessageId: firstUnreadMessageId
        )

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

        // Setup composer
        if !route.isSystemAccount {
            controller.setupComposer()
        }

        // Wire callbacks
        wireMessageListCallbacks(controller)

        // Add as child VC
        addChild(controller)
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(controller.view)
        NSLayoutConstraint.activate([
            controller.view.topAnchor.constraint(equalTo: view.topAnchor),
            controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        controller.didMove(toParent: self)

        // Force view load
        controller.loadViewIfNeeded()

        messageListController = controller

        // Seed ALL messages to avoid Combine re-delivery freeze.
        // Seeding 50 saved ~50ms but caused 1s Combine freeze (200 new IDs without layout).
        // Seeding all 200: viewDidLoad ~180ms but Combine fingerprint matches → skipped.
        // Net: 180ms total vs 130ms + 1000ms = 1130ms. Seeding all is 6x faster.
        let initialMessages = viewModel.messages.filter { !$0.text.hasPrefix("$a=") }
        if !initialMessages.isEmpty {
            let seedStart = CACurrentMediaTime()
            controller.updateEmptyState(isEmpty: false, info: makeEmptyChatInfo())
            controller.update(messages: initialMessages)
            lastMessageFingerprint = messageFingerprint(viewModel.messages)
            let seedMs = (CACurrentMediaTime() - seedStart) * 1000
            Self.perfLog.notice("⚡ seed \(initialMessages.count, privacy: .public) msgs → update() \(String(format: "%.0f", seedMs), privacy: .public)ms")
        } else {
            Self.perfLog.notice("⚡ seed: no cached msgs → skeleton")
        }

        // Fix date pill sticky offset for floating header
        messageListController.topStickyOffset = headerBarHeight

        // Date pill overlay stays inside NativeMessageListController (between
        // collectionView and composer) so pills scroll under the composer glass.
        // No reparenting needed — pills are naturally covered by the composer.
    }

    // MARK: - Toolbar Setup (glass capsules as direct subviews)

    private func setupNavigationChrome() {
        // Back button
        let back = ChatDetailBackButton()
        back.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
        back.layer.zPosition = 55
        view.addSubview(back)
        backButton = back

        // Title pill
        let title = ChatDetailTitlePill(route: route, viewModel: viewModel)
        title.addTarget(self, action: #selector(titleTapped), for: .touchUpInside)
        title.layer.zPosition = 55
        view.addSubview(title)
        titlePill = title

        // Avatar
        let avatar = ChatDetailAvatarButton(route: route)
        avatar.addTarget(self, action: #selector(avatarTapped), for: .touchUpInside)
        avatar.layer.zPosition = 55
        view.addSubview(avatar)
        avatarButton = avatar
    }

    private func layoutCustomHeader() {
        let safeTop = view.safeAreaInsets.top
        let centerY = safeTop + headerBarHeight * 0.5

        // Back button (left) — 8pt from edge
        let sideMargin: CGFloat = 8
        let backSize = backButton.intrinsicContentSize
        backButton.frame = CGRect(
            x: sideMargin,
            y: centerY - backSize.height * 0.5,
            width: backSize.width,
            height: backSize.height
        )

        // Avatar (right) — same margin as back button
        let avatarOuterSize: CGFloat = 44
        let avatarRight: CGFloat = sideMargin
        avatarButton.frame = CGRect(
            x: view.bounds.width - avatarRight - avatarOuterSize,
            y: centerY - avatarOuterSize * 0.5,
            width: avatarOuterSize,
            height: avatarOuterSize
        )

        // Title pill (content-sized, centered between back and avatar)
        let titleHeight: CGFloat = 44
        let hPad: CGFloat = 16 // horizontal padding inside pill
        let titlePillRef = titlePill as! ChatDetailTitlePill
        let contentWidth = titlePillRef.contentWidth()
        let pillWidth = max(120, contentWidth + hPad * 2)

        // Available zone between back and avatar
        let zoneLeft = backButton.frame.maxX + 8
        let zoneRight = avatarButton.frame.minX - 8
        let zoneWidth = zoneRight - zoneLeft
        // Clamp pill to available zone, center within it
        let clampedWidth = min(pillWidth, zoneWidth)
        let pillX = zoneLeft + (zoneWidth - clampedWidth) / 2
        titlePill.frame = CGRect(
            x: pillX,
            y: centerY - titleHeight * 0.5,
            width: clampedWidth,
            height: titleHeight
        )
    }

    // MARK: - Wallpaper

    private func setupWallpaper() {
        wallpaperImageView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(wallpaperImageView)
        NSLayoutConstraint.activate([
            wallpaperImageView.topAnchor.constraint(equalTo: view.topAnchor),
            wallpaperImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            wallpaperImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            wallpaperImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        updateWallpaperImage()

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(wallpaperDidChange),
            name: UserDefaults.didChangeNotification,
            object: nil
        )
    }

    private func updateWallpaperImage() {
        let wallpaperId = UserDefaults.standard.string(forKey: "rosetta_wallpaper_id") ?? "default"
        let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId })
            ?? WallpaperOption.allOptions[0]

        switch option.style {
        case .none:
            wallpaperImageView.image = nil
        case .image(let assetName):
            wallpaperImageView.image = UIImage(named: assetName)
        }
    }

    @objc private func wallpaperDidChange() {
        updateWallpaperImage()
    }

    // MARK: - Edge Effects

    private func setupEdgeEffects() {
        topEdgeEffectView.isUserInteractionEnabled = false
        topEdgeEffectView.layer.zPosition = 40
        let isDark = traitCollection.userInterfaceStyle == .dark
        topEdgeEffectView.setTintColor(isDark ? .black : .white)
        view.addSubview(topEdgeEffectView)

        // Bottom gradient: simple tint fade (no blur) above composer area
        bottomEdgeGradientView.layer.zPosition = 40
        bottomGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
        bottomGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
        updateBottomGradientColors()
        bottomEdgeGradientView.layer.addSublayer(bottomGradientLayer)
        view.addSubview(bottomEdgeGradientView)
    }

    private func updateEdgeEffectFrames() {
        let edgeHeight = view.safeAreaInsets.top + headerBarHeight + 14
        topEdgeEffectView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: edgeHeight)
        topEdgeEffectView.update(size: topEdgeEffectView.bounds.size, edgeSize: 54, contentAlpha: 0.85)

        // Bottom gradient: tint fade below composer, in bottom safe area
        let bottomSafe = view.safeAreaInsets.bottom
        if bottomSafe > 0 {
            bottomEdgeGradientView.isHidden = false
            bottomEdgeGradientView.frame = CGRect(
                x: 0,
                y: view.bounds.height - bottomSafe,
                width: view.bounds.width,
                height: bottomSafe
            )
            bottomGradientLayer.frame = bottomEdgeGradientView.bounds
        } else {
            bottomEdgeGradientView.isHidden = true
        }
    }

    private func updateBottomGradientColors() {
        let bg = traitCollection.userInterfaceStyle == .dark
            ? UIColor.black
            : UIColor.white
        bottomGradientLayer.colors = [
            bg.withAlphaComponent(0).cgColor,
            bg.withAlphaComponent(1).cgColor,
        ]
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
            let isDark = traitCollection.userInterfaceStyle == .dark
            topEdgeEffectView.setTintColor(isDark ? .black : .white)
            updateBottomGradientColors()
            updateWallpaperImage()
        }
    }

    // MARK: - Callback Wiring (NativeMessageListController)

    private func wireMessageListCallbacks(_ controller: NativeMessageListController) {
        controller.onScrollToBottomVisibilityChange = { [weak self] atBottom in
            guard let self else { return }
            self.isAtBottom = atBottom
            SessionManager.shared.resetIdleTimer()
            self.updateReadEligibility()
            if atBottom {
                self.markDialogAsRead()
            }
        }
        controller.onPaginationTrigger = { [weak self] in
            Task { await self?.viewModel.loadMore() }
        }
        controller.onBottomPaginationTrigger = { [weak self] in
            Task { await self?.viewModel.loadNewer() }
        }
        controller.onJumpToBottom = { [weak self] in
            self?.viewModel.jumpToBottom()
            // Force scroll after Combine delivers refreshed messages
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                self?.messageListController?.scrollToBottom(animated: true)
            }
        }
        controller.onTapBackground = { [weak self] in
            self?.view.endEditing(true)
        }
        controller.onComposerHeightChange = { [weak self] h in
            self?.composerHeight = h
        }
        controller.onKeyboardDidHide = { [weak self] in
            self?.isInputFocused = false
        }
        controller.onComposerSend = { [weak self] in
            self?.sendCurrentMessage()
        }
        controller.onComposerAttach = { [weak self] in
            self?.showAttachmentPanel()
        }
        controller.onComposerTextChange = { [weak self] text in
            self?.messageText = text
        }
        controller.onComposerFocusChange = { [weak self] focused in
            self?.isInputFocused = focused
        }
        controller.onComposerReplyCancel = { [weak self] in
            self?.replyingToMessage = nil
            self?.syncComposerReplyState()
        }
        controller.onComposerForwardCancel = { [weak self] in
            self?.showForwardCancelConfirmation()
        }
        controller.onComposerTyping = { [weak self] in
            self?.handleComposerUserTyping()
        }
    }

    // MARK: - Combine Subscriptions

    private func wireViewModelSubscriptions() {
        viewModel.$messages
            .receive(on: DispatchQueue.main)
            .sink { [weak self] messages in
                self?.handleMessagesUpdate(messages)
            }
            .store(in: &cancellables)
    }

    private func handleMessagesUpdate(_ messages: [ChatMessage]) {
        guard let controller = messageListController else { return }

        controller.hasMoreMessages = viewModel.hasMoreMessages
        controller.hasNewerMessages = viewModel.hasNewerMessages

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

        // Empty state: based on visible (non-service) messages
        controller.updateEmptyState(isEmpty: visibleMessages.isEmpty, info: makeEmptyChatInfo())

        let fingerprint = messageFingerprint(messages)
        guard fingerprint != lastMessageFingerprint else { return }
        let updateStart = CACurrentMediaTime()

        let wasAtBottom = isAtBottom
        let oldNewestId = lastNewestMessageId
        let newNewestId = visibleMessages.last?.id
        let lastIsOutgoing = visibleMessages.last?.isFromMe(myPublicKey: currentPublicKey) == true

        controller.update(messages: visibleMessages)
        let updateMs = (CACurrentMediaTime() - updateStart) * 1000
        Self.perfLog.notice("⚡ handleMsgsUpdate: \(visibleMessages.count, privacy: .public) msgs in \(String(format: "%.0f", updateMs), privacy: .public)ms")
        lastMessageFingerprint = fingerprint
        lastNewestMessageId = newNewestId

        if (wasAtBottom || lastIsOutgoing),
           newNewestId != oldNewestId, newNewestId != nil {
            DispatchQueue.main.async {
                controller.scrollToBottom(animated: true)
            }
            // Mark incoming auto-scrolled messages as read (SwiftUI onNewMessageAutoScroll parity)
            if isViewActive && !lastIsOutgoing
                && !route.isSavedMessages && !route.isSystemAccount {
                updateReadEligibility()
                markDialogAsRead()
            }
        }
    }

    private func messageFingerprint(_ messages: [ChatMessage]) -> 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)"
    }

    private func makeEmptyChatInfo() -> EmptyChatInfo {
        // Use GroupMetadata title for groups (DB is authoritative, route.title may be stale)
        let title: String
        if route.isGroup,
           let meta = GroupRepository.shared.groupMetadata(
               account: SessionManager.shared.currentPublicKey,
               groupDialogKey: route.publicKey
           ), !meta.title.isEmpty {
            title = meta.title
        } else {
            title = dialog?.opponentTitle ?? route.title
        }
        let initials: String
        if route.isGroup {
            initials = RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
        } else {
            initials = RosettaColors.initials(name: title, publicKey: route.publicKey)
        }
        let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey)
        let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
        let subtitle = dialog?.isOnline == true ? "online" : ""

        return EmptyChatInfo(
            title: title,
            subtitle: subtitle,
            initials: initials,
            colorIndex: colorIndex,
            isOnline: dialog?.isOnline ?? false,
            isSavedMessages: route.isSavedMessages,
            avatarImage: avatar,
            isGroup: route.isGroup
        )
    }

    // MARK: - Cell Actions

    private func wireCellActions() {
        cellActions.onReply = { [weak self] msg in
            self?.replyingToMessage = msg
            self?.isInputFocused = true
            self?.syncComposerReplyState()
        }
        cellActions.onForward = { [weak self] msg in
            self?.forwardingMessage = msg
            self?.showForwardPicker()
        }
        cellActions.onDelete = { [weak self] msg in
            self?.messageToDelete = msg
            self?.showDeleteAlert()
        }
        cellActions.onCopy = { text in
            UIPasteboard.general.string = text
        }
        cellActions.onImageTap = { [weak self] attId, frame, sourceView in
            self?.openImageViewer(attachmentId: attId, sourceFrame: frame, sourceView: sourceView)
        }
        ImageViewerPresenter.shared.onShowInChat = { [weak self] messageId in
            self?.scrollToMessage(id: messageId)
        }
        cellActions.onScrollToMessage = { [weak self] msgId in
            guard let self else { return }
            Task { @MainActor in
                guard await self.viewModel.ensureMessageLoaded(messageId: msgId) else { return }
                self.scrollToMessage(id: msgId)
            }
        }
        cellActions.onRetry = { [weak self] msg in self?.retryMessage(msg) }
        cellActions.onRemove = { [weak self] msg in self?.removeMessage(msg) }
        cellActions.onCall = { [weak self] peerKey in
            guard let self else { return }
            let peerTitle = self.dialog?.opponentTitle ?? self.route.title
            let peerUsername = self.dialog?.opponentUsername ?? self.route.username
            let result = CallManager.shared.startOutgoingCall(
                toPublicKey: peerKey, title: peerTitle, username: peerUsername
            )
            if case .alreadyInCall = result {
                self.showAlert(title: "Call Error", message: "You are already in another call.")
            }
        }
        cellActions.onGroupInviteTap = { [weak self] invite in
            guard let self else { return }
            if let parsed = GroupRepository.shared.parseInviteString(invite) {
                self.showGroupInviteAlert(invite: invite, title: parsed.title)
            }
        }
        cellActions.onEnterSelection = { [weak self] msg in
            guard let self else { return }
            self.isMultiSelectMode = true
            self.selectedMessageIds = [msg.id]
            self.messageListController?.setSelectionMode(true, animated: true)
            self.messageListController?.updateSelectedIds(self.selectedMessageIds)
            self.showSelectionHeader(animated: true)
            self.showSelectionBottomToolbar(animated: true)
            self.updateSelectionPillLabel()
        }
        cellActions.onToggleSelection = { [weak self] msgId in
            guard let self else { return }
            if self.selectedMessageIds.contains(msgId) {
                self.selectedMessageIds.remove(msgId)
            } else {
                self.selectedMessageIds.insert(msgId)
            }
            self.messageListController?.updateSelectedIds(self.selectedMessageIds)
            self.updateSelectionPillLabel()
        }
        cellActions.onMentionTap = { [weak self] username, completion in
            self?.handleMentionTap(username: username, completion: completion)
        }
        cellActions.onAvatarTap = { [weak self] senderKey in
            self?.handleAvatarTap(senderKey: senderKey)
        }
        cellActions.onGroupInviteOpen = { dialogKey in
            let title = DialogRepository.shared.dialogs[dialogKey]?.opponentTitle ?? "Group"
            let route = ChatRoute(groupDialogKey: dialogKey, title: title)
            NotificationCenter.default.post(name: .openChatFromNotification, object: route)
        }
    }

    // MARK: - Chat Lifecycle

    private func activateChat() {
        isViewActive = true

        // firstUnreadMessageId already captured in viewDidLoad (before first render).
        // Re-capture only if nil (e.g. new messages arrived between viewDidLoad and viewDidAppear).
        if firstUnreadMessageId == nil {
            firstUnreadMessageId = viewModel.messages.first(where: {
                !$0.isRead && $0.fromPublicKey != currentPublicKey
            })?.id
        }

        // Restore draft
        let draft = DraftManager.shared.getDraft(for: route.publicKey)
        if !draft.isEmpty {
            messageText = draft
            messageListController?.composerView?.setText(draft)
        }

        MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
        SessionManager.shared.resetIdleTimer()
        updateReadEligibility()

        // Deferred activation (300ms — matches push animation)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
            guard let self, self.isViewActive else { return }
            self.activateDialog()
            self.clearDeliveredNotifications(for: self.route.publicKey)
            self.markDialogAsRead()
            DialogRepository.shared.setMention(opponentKey: self.route.publicKey, hasMention: false)
            self.requestUserInfoIfNeeded()
            self.updateReadEligibility()
            self.markDialogAsRead()

            if !self.route.isSystemAccount {
                SessionManager.shared.subscribeToOnlineStatus(publicKey: self.route.publicKey)
                if !self.route.isSavedMessages {
                    SessionManager.shared.forceRefreshUserInfo(publicKey: self.route.publicKey)
                }
            }
        }
    }

    private func deactivateChat() {
        if let hid = mentionSearchHandlerId {
            ProtocolManager.shared.removeSearchResultHandler(hid)
            mentionSearchHandlerId = nil
        }
        firstUnreadMessageId = nil
        markDialogAsRead()
        isViewActive = false
        updateReadEligibility()
        MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
        SessionManager.shared.stopIdleTimer()
        DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
    }

    // MARK: - Helpers (ported from ChatDetailView)

    private var dialog: Dialog? {
        DialogRepository.shared.dialogs[route.publicKey]
    }

    private func calculateMaxBubbleWidth() -> CGFloat {
        let screenWidth = UIScreen.main.bounds.width
        let listInsets: CGFloat = 20
        let bubbleMargins: CGFloat = 16
        let available = max(40, screenWidth - listInsets - bubbleMargins)
        let compactInset: CGFloat = 36
        let freeFill: CGFloat = screenWidth > 680 ? 0.65 : 0.85
        return max(40, min(available - compactInset, available * freeFill))
    }

    private func syncComposerReplyState() {
        guard let composer = messageListController?.composerView else { return }
        if let reply = replyingToMessage {
            let senderName = senderDisplayName(for: reply.fromPublicKey)
            let preview = replyPreviewText(for: reply)
            composer.setReply(senderName: senderName, previewText: preview)
        } else {
            composer.setReply(senderName: nil, previewText: nil)
        }
    }

    private func syncComposerForwardState() {
        guard let composer = messageListController?.composerView else { return }
        if let data = pendingForwardData, !data.isEmpty {
            let uniqueNames = Array(Set(data.map(\.senderName)).filter { !$0.isEmpty }).sorted()
            let previewText: String? = data.count == 1 ? data.first?.message : nil
            composer.setForward(count: data.count, senderNames: uniqueNames, previewText: previewText)
        } else {
            composer.setForward(count: 0, senderNames: [])
        }
    }

    private func showForwardCancelConfirmation() {
        guard let data = pendingForwardData, !data.isEmpty else { return }
        let count = data.count
        let uniqueNames = Array(Set(data.map(\.senderName)).filter { !$0.isEmpty }).sorted()
        let namesStr = uniqueNames.joined(separator: ", ")

        let title = "\(count) Message\(count > 1 ? "s" : "")"
        let message = "What would you like to do with \(count) message\(count > 1 ? "s" : "") from \(namesStr)?"

        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Cancel Forwarding", style: .destructive) { [weak self] _ in
            self?.pendingForwardData = nil
            self?.syncComposerForwardState()
        })
        alert.addAction(UIAlertAction(title: "Keep", style: .cancel))
        present(alert, animated: true)
    }

    // MARK: - Navigation Actions

    @objc private func backTapped() {
        navigationController?.popViewController(animated: true)
    }

    @objc private func titleTapped() {
        openProfile()
    }

    @objc private func avatarTapped() {
        openProfile()
    }

    private func openProfile() {
        view.endEditing(true)
        if route.isGroup {
            // Group info — still raw UIHostingController (Phase 2 will wrap this too)
            navigationController?.setNavigationBarHidden(false, animated: false)
            let clearAppearance = UINavigationBarAppearance()
            clearAppearance.configureWithTransparentBackground()
            clearAppearance.shadowColor = .clear
            navigationController?.navigationBar.standardAppearance = clearAppearance
            navigationController?.navigationBar.scrollEdgeAppearance = clearAppearance
            let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
            let hosting = UIHostingController(rootView: groupInfo)
            hosting.navigationItem.hidesBackButton = true
            let backView = ChatDetailBackButton()
            backView.addTarget(hosting, action: #selector(UIViewController.rosettaPopSelf), for: .touchUpInside)
            hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
            navigationController?.pushViewController(hosting, animated: true)
        } else if !route.isSystemAccount {
            navigationController?.setNavigationBarHidden(true, animated: false)
            let profileVC = OpponentProfileViewController(route: route)
            navigationController?.pushViewController(profileVC, animated: true)
        }
    }

    private func handleMentionTap(username: String, completion: @escaping (Bool) -> Void) {
        guard username.lowercased() != "all" else { return }

        let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? ""
        if !myUsername.isEmpty && username.lowercased() == myUsername {
            if let saved = DialogRepository.shared.sortedDialogs.first(where: { $0.isSavedMessages }) {
                let vc = ChatDetailViewController(route: ChatRoute(dialog: saved))
                navigationController?.pushViewController(vc, animated: true)
            }
            completion(true)
            return
        }

        // Local lookup
        if let dialog = DialogRepository.shared.sortedDialogs.first(where: {
            $0.opponentUsername.lowercased() == username.lowercased()
        }) {
            let vc = ChatDetailViewController(route: ChatRoute(dialog: dialog))
            navigationController?.pushViewController(vc, animated: true)
            completion(true)
            return
        }

        // Server fallback — resolve unknown username
        guard let hash = SessionManager.shared.privateKeyHash else {
            completion(false)
            return
        }

        if let old = mentionSearchHandlerId {
            ProtocolManager.shared.removeSearchResultHandler(old)
            mentionSearchHandlerId = nil
        }

        let searchId = UUID()
        let handlerId = ProtocolManager.shared.addSearchResultHandler(channel: .ui(searchId)) {
            [weak self] packet in
            Task { @MainActor [weak self] in
                guard let self else { return }
                if let hid = self.mentionSearchHandlerId {
                    ProtocolManager.shared.removeSearchResultHandler(hid)
                    self.mentionSearchHandlerId = nil
                }
                if let user = packet.users.first(where: {
                    $0.username.lowercased() == username.lowercased()
                }) {
                    let route = ChatRoute(
                        publicKey: user.publicKey,
                        title: user.title,
                        username: user.username,
                        verified: user.verified
                    )
                    let vc = ChatDetailViewController(route: route)
                    self.navigationController?.pushViewController(vc, animated: true)
                    completion(true)
                } else {
                    completion(false)
                }
            }
        }
        mentionSearchHandlerId = handlerId

        var packet = PacketSearch()
        packet.privateKey = hash
        packet.search = username
        ProtocolManager.shared.sendSearchPacket(packet, channel: .ui(searchId))
    }

    private func handleAvatarTap(senderKey: String) {
        let profileRoute: ChatRoute
        if let dialog = DialogRepository.shared.sortedDialogs.first(where: { $0.opponentKey == senderKey }) {
            profileRoute = ChatRoute(dialog: dialog)
        } else {
            profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0)
        }
        let profileVC = OpponentProfileViewController(route: profileRoute)
        navigationController?.pushViewController(profileVC, animated: true)
    }

    // MARK: - Sheets & Alerts

    private func showAttachmentPanel() {
        let panel = AttachmentPanelView(
            onSend: { [weak self] attachments, caption in
                guard let self else { return }
                let trimmed = caption.trimmingCharacters(in: .whitespaces)
                if !trimmed.isEmpty { self.messageText = trimmed }
                self.handleAttachmentsSend(attachments)
            },
            onSendAvatar: { [weak self] in
                self?.sendAvatarToChat()
            },
            hasAvatar: avatarAvailableForSending(),
            onSetAvatar: { [weak self] in
                guard let self else { return }
                if self.route.isGroup {
                    self.showAlert(title: "No Group Avatar", message: "Set a group photo first to share it with members.")
                } else {
                    self.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
                }
            }
        )
        let hosting = UIHostingController(rootView: panel)
        if let sheet = hosting.sheetPresentationController {
            sheet.detents = [.medium(), .large()]
            sheet.prefersGrabberVisible = true
        }
        present(hosting, animated: true)
    }

    private func showForwardPicker() {
        // Support both single message (context menu) and multi-select forward
        let messagesToForward: [ChatMessage]
        if !forwardingMessages.isEmpty {
            messagesToForward = forwardingMessages
            forwardingMessages = []
        } else if let single = forwardingMessage {
            messagesToForward = [single]
            forwardingMessage = nil
        } else {
            return
        }

        // Pre-process forward data before navigation
        var allForwardData: [ReplyMessageData] = []
        for message in messagesToForward {
            if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
               let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
                allForwardData.append(contentsOf: innerMessages)
            } else {
                allForwardData.append(buildReplyData(from: message))
            }
        }

        let picker = ForwardChatPickerView { [weak self] targetRoutes in
            guard let self, let targetRoute = targetRoutes.first else { return }

            if targetRoutes.count == 1 {
                // Single target — navigate with forward preview in composer
                self.dismiss(animated: true) {
                    self.navigateToForward(route: targetRoute, forwardData: allForwardData)
                }
            } else {
                // Multiple targets — send immediately
                self.dismiss(animated: true)
                self.sendForwardData(allForwardData, toRoutes: targetRoutes)
            }
        }
        let hosting = UIHostingController(rootView: picker)
        if let sheet = hosting.sheetPresentationController {
            sheet.detents = [.medium(), .large()]
            sheet.prefersGrabberVisible = true
        }
        present(hosting, animated: true)
    }

    private func navigateToForward(route targetRoute: ChatRoute, forwardData: [ReplyMessageData]) {
        if targetRoute.publicKey == route.publicKey {
            // Same chat — show forward preview in current composer
            pendingForwardData = forwardData
            syncComposerForwardState()
        } else {
            // Different chat — replace self in nav stack (no chat list flash)
            guard let nav = navigationController else { return }
            let detail = ChatDetailViewController(route: targetRoute, pendingForwardData: forwardData)
            var viewControllers = nav.viewControllers
            if let idx = viewControllers.lastIndex(where: { $0 === self }) {
                viewControllers[idx] = detail
            } else {
                viewControllers.append(detail)
            }
            nav.setViewControllers(viewControllers, animated: false)
        }
    }

    private func sendForwardData(_ data: [ReplyMessageData], toRoutes routes: [ChatRoute]) {
        for targetRoute in routes {
            Task { @MainActor in
                do {
                    try await SessionManager.shared.sendMessageWithReply(
                        text: "",
                        replyMessages: data,
                        toPublicKey: targetRoute.publicKey,
                        opponentTitle: targetRoute.title,
                        opponentUsername: targetRoute.username
                    )
                } catch {
                    showAlert(title: "Forward Error", message: error.localizedDescription)
                }
            }
        }
    }

    private func showDeleteAlert() {
        let alert = UIAlertController(
            title: "Delete Message",
            message: "Are you sure you want to delete this message? This action cannot be undone.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
            guard let self, let message = self.messageToDelete else { return }
            self.removeMessage(message)
            self.messageToDelete = nil
            if self.isMultiSelectMode {
                self.exitSelectionMode(animated: true)
            }
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
            self?.messageToDelete = nil
        })
        present(alert, animated: true)
    }

    private func showGroupInviteAlert(invite: String, title: String) {
        let alert = UIAlertController(
            title: "Join Group",
            message: "Join \"\(title)\"?",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in
            Task {
                do {
                    let route = try await GroupService.shared.joinGroup(inviteString: invite)
                    NotificationCenter.default.post(
                        name: .openChatFromNotification,
                        object: ChatRoute(groupDialogKey: route.publicKey, title: route.title)
                    )
                } catch {
                    // Error handling
                }
            }
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }

    private func showAlert(title: String, message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .cancel))
        present(alert, animated: true)
    }

    // MARK: - Selection Header (replaces nav chrome in-place)

    /// Replaces backButton/titlePill/avatarButton with delete/pill/checkmark in the same header bar.
    private func showSelectionHeader(animated: Bool) {
        guard selectionDeleteButton == nil else { return }
        let safeTop = view.safeAreaInsets.top
        let centerY = safeTop + headerBarHeight * 0.5
        let sideMargin: CGFloat = 8
        let btnSize: CGFloat = 44

        // --- Delete button (left, replaces backButton) ---
        let delGlass = TelegramGlassUIView(frame: .zero)
        delGlass.isCircle = true
        delGlass.layer.zPosition = 55
        delGlass.frame = CGRect(x: sideMargin, y: centerY - btnSize * 0.5, width: btnSize, height: btnSize)
        view.addSubview(delGlass)
        selectionDeleteGlass = delGlass

        let delBtn = UIControl()
        delBtn.layer.zPosition = 56
        delBtn.frame = delGlass.frame
        let trashIcon = UIImageView(image: UIImage(named: "SelectionTrash")?.withRenderingMode(.alwaysTemplate))
        trashIcon.tintColor = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(white: 0, alpha: 0.85) }
        trashIcon.contentMode = .scaleAspectFit
        trashIcon.frame = delBtn.bounds.insetBy(dx: 10, dy: 10)
        trashIcon.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        trashIcon.isUserInteractionEnabled = false
        delBtn.addSubview(trashIcon)
        delBtn.addTarget(self, action: #selector(selectionDeleteTapped), for: .touchUpInside)
        view.addSubview(delBtn)
        selectionDeleteButton = delBtn

        // --- Forward/checkmark button (right, replaces avatarButton) ---
        let avatarOuterSize: CGFloat = 44
        let fwdCircle = UIView()
        fwdCircle.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
        fwdCircle.layer.cornerRadius = btnSize / 2
        fwdCircle.isUserInteractionEnabled = false
        fwdCircle.layer.zPosition = 55
        fwdCircle.frame = CGRect(
            x: view.bounds.width - sideMargin - avatarOuterSize,
            y: centerY - btnSize * 0.5,
            width: btnSize,
            height: btnSize
        )
        view.addSubview(fwdCircle)
        selectionForwardCircle = fwdCircle

        // Checkmark path (same formula as NativeMessageCell — proven centered)
        let checkInset: CGFloat = 2.0 - (1.0 / UIScreen.main.scale)
        let checkScale = (btnSize - checkInset * 2) / 18.0
        let cx = btnSize / 2
        let cy2 = btnSize / 2
        let sx = cx - (4.0 - 0.3333) * checkScale
        let sy = cy2 + 0.5 * checkScale
        let checkPath = UIBezierPath()
        checkPath.move(to: CGPoint(x: sx, y: sy))
        checkPath.addLine(to: CGPoint(x: sx + 2.5 * checkScale, y: sy + 3.0 * checkScale))
        checkPath.addLine(to: CGPoint(x: sx + 2.5 * checkScale + 4.6667 * checkScale, y: sy + 3.0 * checkScale - 6.0 * checkScale))
        let checkLayer = CAShapeLayer()
        checkLayer.path = checkPath.cgPath
        checkLayer.strokeColor = UIColor.white.cgColor
        checkLayer.fillColor = UIColor.clear.cgColor
        checkLayer.lineWidth = 1.5
        checkLayer.lineCap = .round
        checkLayer.lineJoin = .round
        checkLayer.frame = CGRect(x: 0, y: 0, width: btnSize, height: btnSize)
        fwdCircle.layer.addSublayer(checkLayer)

        // --- "N Selected" pill (center, replaces titlePill — content-sized) ---
        let pillGlass = TelegramGlassUIView(frame: .zero)
        pillGlass.layer.zPosition = 55
        let pillHeight: CGFloat = 44
        // Measure text to get content-sized pill
        let initialText = "\(selectedMessageIds.count) Selected"
        let pillFont = UIFont.systemFont(ofSize: 17, weight: .medium)
        let textWidth = (initialText as NSString).size(withAttributes: [.font: pillFont]).width
        let pillPadding: CGFloat = 32 // 16pt each side
        let pillWidth = textWidth + pillPadding
        // Center pill between delete button and checkmark button
        let zoneCenterX = (delGlass.frame.maxX + fwdCircle.frame.minX) / 2
        let pillX = zoneCenterX - pillWidth / 2
        pillGlass.frame = CGRect(x: pillX, y: centerY - pillHeight * 0.5, width: pillWidth, height: pillHeight)
        view.addSubview(pillGlass)
        selectionPillGlass = pillGlass

        let label = UILabel()
        label.font = pillFont
        label.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
        label.textAlignment = .center
        label.text = initialText
        label.frame = pillGlass.frame
        label.layer.zPosition = 56
        label.isUserInteractionEnabled = false
        view.addSubview(label)
        selectionPillLabel = label

        let fwdBtn = UIControl()
        fwdBtn.layer.zPosition = 56
        fwdBtn.frame = fwdCircle.frame
        fwdBtn.addTarget(self, action: #selector(selectionForwardTapped), for: .touchUpInside)
        view.addSubview(fwdBtn)
        selectionForwardButton = fwdBtn

        // Hide normal nav chrome
        let selectionViews: [UIView] = [delGlass, delBtn, pillGlass, label, fwdCircle, fwdBtn]
        if animated {
            backButton.alpha = 1
            titlePill.alpha = 1
            avatarButton.alpha = 1
            for v in selectionViews { v.alpha = 0 }
            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
                self.backButton.alpha = 0
                self.titlePill.alpha = 0
                self.avatarButton.alpha = 0
                for v in selectionViews { v.alpha = 1 }
            }
        } else {
            backButton.alpha = 0
            titlePill.alpha = 0
            avatarButton.alpha = 0
        }
    }

    private func hideSelectionHeader(animated: Bool) {
        let selectionViews: [UIView?] = [
            selectionDeleteGlass, selectionDeleteButton,
            selectionPillGlass, selectionPillLabel,
            selectionForwardCircle, selectionForwardButton,
        ]
        let existingViews = selectionViews.compactMap { $0 }
        guard !existingViews.isEmpty else { return }

        if animated {
            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) {
                for v in existingViews { v.alpha = 0 }
                self.backButton.alpha = 1
                self.titlePill.alpha = 1
                self.avatarButton.alpha = 1
            } completion: { _ in
                for v in existingViews { v.removeFromSuperview() }
            }
        } else {
            for v in existingViews { v.removeFromSuperview() }
            backButton.alpha = 1
            titlePill.alpha = 1
            avatarButton.alpha = 1
        }
        selectionDeleteButton = nil
        selectionDeleteGlass = nil
        selectionPillGlass = nil
        selectionPillLabel = nil
        selectionForwardCircle = nil
        selectionForwardButton = nil
    }

    private func updateSelectionPillLabel() {
        let count = selectedMessageIds.count
        let text = "\(count) Selected"
        selectionPillLabel?.text = text

        // Resize pill to fit new text
        guard let pillGlass = selectionPillGlass, let label = selectionPillLabel,
              let fwdCircle = selectionForwardCircle, let delGlass = selectionDeleteGlass else { return }
        let pillFont = UIFont.systemFont(ofSize: 17, weight: .medium)
        let textWidth = (text as NSString).size(withAttributes: [.font: pillFont]).width
        let pillPadding: CGFloat = 32
        let pillWidth = textWidth + pillPadding
        let zoneCenterX = (delGlass.frame.maxX + fwdCircle.frame.minX) / 2
        let pillX = zoneCenterX - pillWidth / 2
        let pillHeight: CGFloat = 44
        let safeTop = view.safeAreaInsets.top
        let centerY = safeTop + headerBarHeight * 0.5
        let newFrame = CGRect(x: pillX, y: centerY - pillHeight * 0.5, width: pillWidth, height: pillHeight)
        UIView.animate(withDuration: 0.15) {
            pillGlass.frame = newFrame
            label.frame = newFrame
        }
    }

    // MARK: - Selection Bottom Toolbar (Forward + Share, replaces composer)

    private func showSelectionBottomToolbar(animated: Bool) {
        guard selectionBottomToolbar == nil else { return }
        let safeBottom = view.safeAreaInsets.bottom
        let toolbarH: CGFloat = 56
        let totalH = toolbarH + safeBottom
        let finalY = view.bounds.height - totalH

        let toolbar = UIView()
        toolbar.frame = CGRect(x: 0, y: animated ? view.bounds.height : finalY, width: view.bounds.width, height: totalH)
        toolbar.layer.zPosition = 50

        let iconColor = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(white: 0, alpha: 0.85) }
        let btnSize: CGFloat = 42
        let spacing: CGFloat = 12
        // Align forward button to right edge, matching scroll-to-bottom position (17pt trailing)
        let rightPadding: CGFloat = 17

        // Forward button (right — under scroll-to-bottom)
        let fwdGlass = TelegramGlassUIView(frame: .zero)
        fwdGlass.isCircle = true
        let fwdX = toolbar.bounds.width - rightPadding - btnSize
        fwdGlass.frame = CGRect(x: fwdX, y: (toolbarH - btnSize) / 2, width: btnSize, height: btnSize)
        toolbar.addSubview(fwdGlass)

        let fwdBtn = UIButton(type: .system)
        fwdBtn.setImage(UIImage(named: "SelectionForward")?.withRenderingMode(.alwaysTemplate), for: .normal)
        fwdBtn.tintColor = iconColor
        fwdBtn.frame = fwdGlass.frame
        fwdBtn.addTarget(self, action: #selector(selectionBottomForwardTapped), for: .touchUpInside)
        toolbar.addSubview(fwdBtn)

        // Share button (left edge, same padding as header sideMargin)
        let shareGlass = TelegramGlassUIView(frame: .zero)
        shareGlass.isCircle = true
        let leftPadding: CGFloat = 16
        shareGlass.frame = CGRect(x: leftPadding, y: (toolbarH - btnSize) / 2, width: btnSize, height: btnSize)
        toolbar.addSubview(shareGlass)

        let shareBtn = UIButton(type: .system)
        shareBtn.setImage(UIImage(named: "SelectionShare")?.withRenderingMode(.alwaysTemplate), for: .normal)
        shareBtn.tintColor = iconColor
        shareBtn.frame = shareGlass.frame
        shareBtn.addTarget(self, action: #selector(selectionShareTapped), for: .touchUpInside)
        toolbar.addSubview(shareBtn)

        view.addSubview(toolbar)
        selectionBottomToolbar = toolbar

        if animated {
            UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0, options: []) {
                toolbar.frame.origin.y = finalY
            }
        }
    }

    private func hideSelectionBottomToolbar(animated: Bool) {
        guard let toolbar = selectionBottomToolbar else { return }
        if animated {
            UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) {
                toolbar.frame.origin.y = self.view.bounds.height
            } completion: { _ in
                toolbar.removeFromSuperview()
            }
        } else {
            toolbar.removeFromSuperview()
        }
        selectionBottomToolbar = nil
    }

    @objc private func selectionShareTapped() {
        let messages = viewModel.messages
            .filter { selectedMessageIds.contains($0.id) }
            .sorted { $0.timestamp < $1.timestamp }
        let texts = messages.map { $0.text }.filter { !$0.isEmpty }
        guard !texts.isEmpty else { return }
        let activity = UIActivityViewController(
            activityItems: [texts.joined(separator: "\n\n")],
            applicationActivities: nil
        )
        present(activity, animated: true)
    }

    @objc private func selectionBottomForwardTapped() {
        forwardSelectedMessages()
    }

    private func exitSelectionMode(animated: Bool) {
        isMultiSelectMode = false
        selectedMessageIds.removeAll()
        messageListController?.setSelectionMode(false, animated: animated)
        hideSelectionHeader(animated: animated)
        hideSelectionBottomToolbar(animated: animated)
    }

    @objc private func selectionDeleteTapped() {
        deleteSelectedMessages()
    }

    @objc private func selectionForwardTapped() {
        exitSelectionMode(animated: true)
    }

    private func forwardSelectedMessages() {
        let messages = viewModel.messages
            .filter { selectedMessageIds.contains($0.id) }
            .sorted { $0.timestamp < $1.timestamp }
        guard !messages.isEmpty else { return }
        forwardingMessages = messages
        exitSelectionMode(animated: true)
        showForwardPicker()
    }

    private func deleteSelectedMessages() {
        let count = selectedMessageIds.count
        let alert = UIAlertController(
            title: "Delete \(count) Message\(count == 1 ? "" : "s")",
            message: "This action cannot be undone.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
            guard let self else { return }
            let messages = self.viewModel.messages.filter { self.selectedMessageIds.contains($0.id) }
            for msg in messages {
                self.removeMessage(msg)
            }
            self.exitSelectionMode(animated: true)
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }


    // MARK: - Scroll

    private func scrollToMessage(id: String) {
        messageListController?.scrollToMessage(id: id, animated: true)
        // Highlight animation
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.035) { [weak self] in
            self?.messageListController?.animateHighlight(messageId: id)
        }
    }

    // MARK: - Message Actions (ported from ChatDetailView)

    private func sendCurrentMessage() {
        let trimmed = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
        let attachments = pendingAttachments
        let replyMessage = replyingToMessage
        let forwardData = pendingForwardData

        guard !trimmed.isEmpty || !attachments.isEmpty || forwardData != nil else { return }

        messageText = ""
        pendingAttachments = []
        pendingForwardData = nil
        DraftManager.shared.deleteDraft(for: route.publicKey)

        Task { @MainActor in
            do {
                if let fwdData = forwardData, !fwdData.isEmpty {
                    // Telegram parity: text and forward are separate messages
                    if !trimmed.isEmpty {
                        try await SessionManager.shared.sendMessage(
                            text: trimmed,
                            toPublicKey: route.publicKey,
                            opponentTitle: route.title,
                            opponentUsername: route.username
                        )
                    }
                    try await SessionManager.shared.sendMessageWithReply(
                        text: "",
                        replyMessages: fwdData,
                        toPublicKey: route.publicKey,
                        opponentTitle: route.title,
                        opponentUsername: route.username
                    )
                } else if !attachments.isEmpty {
                    _ = try await SessionManager.shared.sendMessageWithAttachments(
                        text: trimmed,
                        attachments: attachments,
                        toPublicKey: route.publicKey,
                        opponentTitle: route.title,
                        opponentUsername: route.username
                    )
                } else if let replyMsg = replyMessage {
                    let replyData = buildReplyData(from: replyMsg)
                    try await SessionManager.shared.sendMessageWithReply(
                        text: trimmed,
                        replyMessages: [replyData],
                        toPublicKey: route.publicKey,
                        opponentTitle: route.title,
                        opponentUsername: route.username
                    )
                } else {
                    try await SessionManager.shared.sendMessage(
                        text: trimmed,
                        toPublicKey: route.publicKey,
                        opponentTitle: route.title,
                        opponentUsername: route.username
                    )
                }
                messageListController?.composerView?.setText("")
                replyingToMessage = nil
                syncComposerReplyState()
                syncComposerForwardState()
            } catch {
                showAlert(title: "Send Error", message: error.localizedDescription)
            }
        }
    }

    private func retryMessage(_ message: ChatMessage) {
        let text = message.text
        let toKey = message.toPublicKey
        MessageRepository.shared.deleteMessage(id: message.id)
        DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
        Task {
            try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
        }
    }

    private func removeMessage(_ message: ChatMessage) {
        MessageRepository.shared.deleteMessage(id: message.id)
        DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
    }

    private func handleAttachmentsSend(_ attachments: [PendingAttachment]) {
        pendingAttachments = attachments
        sendCurrentMessage()
    }

    /// Desktop parity: in groups only admin can send, and only group avatar.
    /// In 1:1 chats — sends personal avatar.
    private func sendAvatarToChat() {
        let sourceKey = route.isGroup ? route.publicKey : nil
        Task { @MainActor in
            do {
                try await SessionManager.shared.sendAvatar(
                    toPublicKey: route.publicKey,
                    opponentTitle: route.title,
                    opponentUsername: route.username,
                    avatarSourceKey: sourceKey
                )
            } catch {
                showAlert(title: "Send Error", message: error.localizedDescription)
            }
        }
    }

    /// Returns true if avatar tab should show "Send" (not "Set").
    /// Groups: only admin + group has avatar. Personal: user has avatar.
    private func avatarAvailableForSending() -> Bool {
        if route.isGroup {
            let cached = GroupRepository.shared.cachedMembers(
                account: currentPublicKey,
                groupDialogKey: route.publicKey
            )
            guard cached?.adminKey == currentPublicKey else { return false }
            return AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
        }
        return AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil
    }

    private func handleComposerUserTyping() {
        guard !route.isSavedMessages, !route.isSystemAccount else { return }
        SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
    }

    // MARK: - Read Receipts & Dialog

    private func markDialogAsRead() {
        guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return }
        MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
        DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
        if !route.isSystemAccount {
            SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
        }
    }

    private func updateReadEligibility() {
        MessageRepository.shared.setDialogReadEligible(route.publicKey, isEligible: isViewActive && isAtBottom)
    }

    @objc private func appDidBecomeActive() {
        guard isViewActive else { return }
        SessionManager.shared.resetIdleTimer()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
            guard let self, self.isViewActive else { return }
            self.updateReadEligibility()
            self.markDialogAsRead()
        }
    }

    private func activateDialog() {
        if DialogRepository.shared.dialogs[route.publicKey] != nil {
            DialogRepository.shared.ensureDialog(
                opponentKey: route.publicKey,
                title: route.title,
                username: route.username,
                verified: route.verified,
                myPublicKey: currentPublicKey
            )
        }
        MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
        updateReadEligibility()
    }

    private func requestUserInfoIfNeeded() {
        SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
    }

    private func clearDeliveredNotifications(for senderKey: String) {
        UNUserNotificationCenter.current().getDeliveredNotifications { notifs in
            let ids = notifs
                .filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
                .map(\.request.identifier)
            if !ids.isEmpty {
                UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
            }
        }
    }

    // MARK: - Image Viewer

    private func openImageViewer(attachmentId: String, sourceFrame: CGRect, sourceView: UIView?) {
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)

        var allImages: [ViewableImageInfo] = []
        for message in viewModel.messages {
            let senderName = senderDisplayName(for: message.fromPublicKey)
            let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)

            for attachment in message.attachments where attachment.type == .image {
                allImages.append(ViewableImageInfo(
                    attachmentId: attachment.id,
                    messageId: message.id,
                    senderName: senderName,
                    timestamp: timestamp,
                    caption: message.text
                ))
            }

            for attachment in message.attachments where attachment.type == .messages {
                if let replyMessages = parseReplyBlob(attachment.blob) {
                    for reply in replyMessages {
                        let fwdSenderName = senderDisplayName(for: reply.publicKey)
                        let fwdTimestamp = Date(timeIntervalSince1970: Double(reply.timestamp) / 1000)
                        for att in reply.attachments where att.type == AttachmentType.image.rawValue {
                            allImages.append(ViewableImageInfo(
                                attachmentId: att.id,
                                messageId: message.id,
                                senderName: fwdSenderName,
                                timestamp: fwdTimestamp,
                                caption: reply.message
                            ))
                        }
                    }
                }
            }
        }
        let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
        let state = ImageViewerState(images: allImages, initialIndex: index, sourceFrame: sourceFrame)
        ImageViewerPresenter.shared.present(state: state, sourceView: sourceView)
    }

    private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
        guard !blob.isEmpty, let data = blob.data(using: .utf8) else { return nil }
        return try? JSONDecoder().decode([ReplyMessageData].self, from: data)
    }

    // MARK: - Forward

    /// Batch-forwards multiple messages as a SINGLE packet (Android parity).
    /// All selected messages → one JSON array in one .messages attachment.
    private func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
        var allForwardData: [ReplyMessageData] = []
        for message in messages {
            // Unwrap nested forwards (flatten)
            if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
               let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
                allForwardData.append(contentsOf: innerMessages)
            } else {
                allForwardData.append(buildReplyData(from: message))
            }
        }

        let targetKey = targetRoute.publicKey
        let targetTitle = targetRoute.title
        let targetUsername = targetRoute.username

        Task { @MainActor in
            do {
                try await SessionManager.shared.sendMessageWithReply(
                    text: "",
                    replyMessages: allForwardData,
                    toPublicKey: targetKey,
                    opponentTitle: targetTitle,
                    opponentUsername: targetUsername
                )
            } catch {
                showAlert(title: "Forward Error", message: error.localizedDescription)
            }
        }
    }

    // MARK: - Helpers (ported)

    private func senderDisplayName(for publicKey: String) -> String {
        if publicKey == currentPublicKey {
            return AccountManager.shared.currentAccount?.displayName
                ?? SessionManager.shared.displayName
        }
        return DialogRepository.shared.dialogs[publicKey]?.opponentTitle
            ?? String(publicKey.prefix(8))
    }

    private func replyPreviewText(for message: ChatMessage) -> String {
        let attachmentLabel: String? = {
            for att in message.attachments {
                switch att.type {
                case .image: return "Photo"
                case .file:
                    let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
                    if !parsed.fileName.isEmpty { return parsed.fileName }
                    return att.id.isEmpty ? "File" : att.id
                case .avatar: return "Avatar"
                case .messages: return "Forwarded message"
                case .call: return "Call"
                case .voice: return "Voice message"
                @unknown default: return "Attachment"
                }
            }
            return nil
        }()

        let visibleText: String = {
            let stripped = message.text
                .trimmingCharacters(in: .whitespacesAndNewlines)
                .filter { !$0.isASCII || $0.asciiValue! >= 0x20 }
            if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
            return stripped
        }()
        let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText)

        if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded }
        if let label = attachmentLabel { return label }
        if !visibleTextDecoded.isEmpty { return visibleTextDecoded }
        return ""
    }

    private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
        let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
            guard att.type != .messages else { return nil }
            return ReplyAttachmentData(
                id: att.id,
                type: att.type.rawValue,
                preview: att.preview,
                blob: "",
                transport: ReplyAttachmentTransport(
                    transport_tag: att.transportTag,
                    transport_server: att.transportServer
                )
            )
        }

        if replyAttachments.isEmpty,
           let msgAtt = message.attachments.first(where: { $0.type == .messages }),
           let innerMessages = parseReplyBlob(msgAtt.blob),
           let firstInner = innerMessages.first {
            return firstInner
        }

        let cleanText = MessageCellView.isGarbageText(message.text) ? "" : message.text

        let hexKey: String
        if let password = message.attachmentPassword, password.hasPrefix("rawkey:") {
            hexKey = String(password.dropFirst("rawkey:".count))
        } else {
            hexKey = ""
        }

        return ReplyMessageData(
            message_id: message.id,
            publicKey: message.fromPublicKey,
            message: cleanText,
            timestamp: message.timestamp,
            attachments: replyAttachments,
            chacha_key_plain: hexKey,
            forwarded: true,
            senderName: senderDisplayName(for: message.fromPublicKey)
        )
    }
}

// MARK: - Toolbar Components

/// Back button — 44×44 glass capsule with Telegram SVG chevron (filled, not stroked).
/// Uses exact `TelegramIconPath.backChevron` SVG path data via `SVGPathParser`.
final class ChatDetailBackButton: UIControl {

    private let glassView = TelegramGlassUIView(frame: .zero)
    private let chevronLayer = CAShapeLayer()
    private static let viewBox = CGSize(width: 10.7, height: 19.63)

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

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

    private func setupUI() {
        glassView.isUserInteractionEnabled = false
        addSubview(glassView)

        chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
        layer.addSublayer(chevronLayer)
    }

    private func updateChevronPath() {
        guard bounds.width > 0 else { return }
        let iconSize = CGSize(width: 11, height: 20)
        let origin = CGPoint(
            x: (bounds.width - iconSize.width) / 2,
            y: (bounds.height - iconSize.height) / 2
        )

        // Parse exact Telegram SVG path
        var parser = SVGPathParser(pathData: TelegramIconPath.backChevron)
        let rawPath = parser.parse()

        // Scale from viewBox to icon size, then translate to center
        let vb = Self.viewBox
        var transform = CGAffineTransform(translationX: origin.x, y: origin.y)
            .scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height)
        let scaledPath = rawPath.copy(using: &transform)

        chevronLayer.path = scaledPath
        chevronLayer.frame = bounds
    }

    override var intrinsicContentSize: CGSize {
        CGSize(width: 44, height: 44)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        glassView.frame = bounds
        glassView.fixedCornerRadius = bounds.height * 0.5
        glassView.updateGlass()
        updateChevronPath()
    }

    override var isHighlighted: Bool {
        didSet { alpha = isHighlighted ? 0.6 : 1.0 }
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
            chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
        }
    }
}

/// Title pill with glass capsule — name + subtitle
private final class ChatDetailTitlePill: UIControl {

    private let glassView = TelegramGlassUIView(frame: .zero)
    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private let typingDotsView = TypingDotsView()

    private let route: ChatRoute
    private weak var viewModel: ChatDetailViewModel?
    private var cancellables = Set<AnyCancellable>()

    // PERF: Dialog observation throttle (prevents burst updateContent during sync)
    private var pendingContentUpdate: DispatchWorkItem?
    private var lastContentUpdateTime: CFAbsoluteTime = 0

    init(route: ChatRoute, viewModel: ChatDetailViewModel) {
        self.route = route
        self.viewModel = viewModel
        super.init(frame: .zero)
        setupUI()
        observeChanges()
        updateContent()
    }

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

    private func setupUI() {
        glassView.isUserInteractionEnabled = false
        addSubview(glassView)

        titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
        titleLabel.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
        titleLabel.textAlignment = .center
        titleLabel.lineBreakMode = .byTruncatingTail
        addSubview(titleLabel)

        subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
        subtitleLabel.textAlignment = .center
        subtitleLabel.lineBreakMode = .byTruncatingTail
        addSubview(subtitleLabel)

        typingDotsView.isUserInteractionEnabled = false
        typingDotsView.isHidden = true
        addSubview(typingDotsView)
    }

    private func observeChanges() {
        // Observe typing state changes
        viewModel?.$isTyping
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.updateContent() }
            .store(in: &cancellables)

        viewModel?.$typingSenderNames
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.updateContent() }
            .store(in: &cancellables)

        // Observe dialog changes for online status (@Observable pattern)
        observeDialogChanges()
    }

    private func observeDialogChanges() {
        withObservationTracking {
            _ = DialogRepository.shared.dialogs
        } onChange: { [weak self] in
            Task { @MainActor [weak self] in
                self?.scheduleContentUpdate()
                self?.observeDialogChanges()
            }
        }
    }

    /// PERF: 100ms throttle — same pattern as ChatListRootViewController.scheduleRender().
    /// Prevents burst updateContent() calls during sync (ensureDialog fires per-message).
    private func scheduleContentUpdate() {
        pendingContentUpdate?.cancel()
        let now = CFAbsoluteTimeGetCurrent()
        if now - lastContentUpdateTime >= 0.1 {
            lastContentUpdateTime = now
            updateContent()
        } else {
            let delay = 0.1 - (now - lastContentUpdateTime)
            let work = DispatchWorkItem { [weak self] in
                guard let self else { return }
                self.lastContentUpdateTime = CFAbsoluteTimeGetCurrent()
                self.updateContent()
            }
            pendingContentUpdate = work
            DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
        }
    }

    private func updateContent() {
        let dialog = DialogRepository.shared.dialogs[route.publicKey]

        // Title
        var title = route.title
        if route.isSavedMessages { title = "Saved Messages" }
        else if route.isGroup {
            if let meta = GroupRepository.shared.groupMetadata(
                account: SessionManager.shared.currentPublicKey,
                groupDialogKey: route.publicKey
            ), !meta.title.isEmpty { title = meta.title }
        }
        if title.isEmpty, let d = dialog, !d.opponentTitle.isEmpty { title = d.opponentTitle }
        if title.isEmpty { title = String(route.publicKey.prefix(12)) }
        titleLabel.text = title

        // Subtitle
        var subtitle = "offline"
        var subtitleColor = UIColor.secondaryLabel
        if route.isSavedMessages { subtitle = "" }
        else if route.isSystemAccount { subtitle = "official account" }
        else if route.isGroup {
            let names = viewModel?.typingSenderNames ?? []
            if !names.isEmpty {
                subtitle = names.count == 1 ? "\(names[0]) typing" : "\(names[0]) and \(names.count - 1) typing"
                subtitleColor = UIColor(RosettaColors.primaryBlue)
            } else { subtitle = "group" }
        } else if viewModel?.isTyping == true {
            subtitle = "typing"
            subtitleColor = UIColor(RosettaColors.primaryBlue)
        } else if dialog?.isOnline == true {
            subtitle = "online"
            subtitleColor = UIColor(RosettaColors.online)
        }
        subtitleLabel.text = subtitle
        subtitleLabel.textColor = subtitleColor

        // Show/hide typing dots animation
        let isTypingActive = (viewModel?.isTyping == true)
            || !(viewModel?.typingSenderNames ?? []).isEmpty
        if isTypingActive {
            typingDotsView.dotColor = subtitleColor
            typingDotsView.isHidden = false
            typingDotsView.startAnimating()
        } else {
            typingDotsView.isHidden = true
            typingDotsView.stopAnimating()
        }
        setNeedsLayout()
        superview?.setNeedsLayout()
    }

    /// Returns the natural content width (max of title/subtitle label widths).
    func contentWidth() -> CGFloat {
        let titleW = titleLabel.intrinsicContentSize.width
        let subtitleW = subtitleLabel.intrinsicContentSize.width
        let dotsExtra: CGFloat = typingDotsView.isHidden ? 0 : 24
        return max(titleW, subtitleW + dotsExtra)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        glassView.frame = bounds
        glassView.fixedCornerRadius = bounds.height * 0.5
        glassView.updateGlass()

        // VStack(spacing: 1) centered, 16pt H-padding
        let hPad: CGFloat = 16
        let contentW = bounds.width - hPad * 2
        let hasSubtitle = !(subtitleLabel.text?.isEmpty ?? true)

        if hasSubtitle {
            // Title 18pt line + 1pt spacing + subtitle 15pt line = 34pt total
            let titleH: CGFloat = 18
            let subtitleH: CGFloat = 15
            let spacing: CGFloat = 1
            let totalH = titleH + spacing + subtitleH
            let topY = (bounds.height - totalH) / 2
            titleLabel.frame = CGRect(x: hPad, y: topY, width: contentW, height: titleH)

            let subtitleY = topY + titleH + spacing
            if !typingDotsView.isHidden {
                // Dots (24×14) + subtitle text, centered horizontally
                let dotsW: CGFloat = 24
                let dotsH: CGFloat = 14
                let textW = subtitleLabel.intrinsicContentSize.width
                let totalW = dotsW + textW
                let startX = (bounds.width - totalW) / 2

                typingDotsView.frame = CGRect(x: startX, y: subtitleY + (subtitleH - dotsH) / 2 + 1, width: dotsW, height: dotsH)
                subtitleLabel.frame = CGRect(x: startX + dotsW, y: subtitleY, width: textW, height: subtitleH)
                subtitleLabel.textAlignment = .left
            } else {
                typingDotsView.frame = .zero
                subtitleLabel.frame = CGRect(x: hPad, y: subtitleY, width: contentW, height: subtitleH)
                subtitleLabel.textAlignment = .center
            }
        } else {
            titleLabel.frame = CGRect(x: hPad, y: 0, width: contentW, height: bounds.height)
        }
    }

    override var isHighlighted: Bool {
        didSet { alpha = isHighlighted ? 0.6 : 1.0 }
    }
}

/// Avatar button with glass circle
private final class ChatDetailAvatarButton: UIControl {

    private let glassView = TelegramGlassUIView(frame: .zero)
    private let avatarBackgroundView = UIView()
    private let avatarImageView = UIImageView()
    private let initialsLabel = UILabel()
    private let bookmarkIcon = UIImageView()
    private let route: ChatRoute

    init(route: ChatRoute) {
        self.route = route
        super.init(frame: .zero)
        setupUI()
        updateAvatar()

        NotificationCenter.default.addObserver(
            self, selector: #selector(avatarChanged),
            name: .init("AvatarRepositoryDidChange"), object: nil
        )
    }

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

    private func setupUI() {
        // All subviews must NOT intercept touches — UIControl handles them
        glassView.isUserInteractionEnabled = false
        addSubview(glassView)

        avatarBackgroundView.isUserInteractionEnabled = false
        avatarBackgroundView.clipsToBounds = true
        addSubview(avatarBackgroundView)

        avatarImageView.isUserInteractionEnabled = false
        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.clipsToBounds = true
        addSubview(avatarImageView)

        initialsLabel.isUserInteractionEnabled = false
        initialsLabel.font = .systemFont(ofSize: 16, weight: .medium)
        initialsLabel.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
        initialsLabel.textAlignment = .center
        addSubview(initialsLabel)

        bookmarkIcon.isUserInteractionEnabled = false
        bookmarkIcon.contentMode = .center
        bookmarkIcon.tintColor = .white
        bookmarkIcon.isHidden = true
        addSubview(bookmarkIcon)
    }

    @objc private func avatarChanged() {
        updateAvatar()
    }

    private func updateAvatar() {
        let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)

        // Saved Messages: always show bookmark icon on blue background
        if route.isSavedMessages {
            avatarImageView.isHidden = true
            initialsLabel.isHidden = true
            bookmarkIcon.isHidden = false
            bookmarkIcon.image = UIImage(systemName: "bookmark.fill")?.withConfiguration(
                UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
            )
            avatarBackgroundView.backgroundColor = UIColor(RosettaColors.primaryBlue)
            return
        }

        bookmarkIcon.isHidden = true

        if let avatar {
            avatarImageView.image = avatar
            avatarImageView.isHidden = false
            initialsLabel.isHidden = true
        } else {
            avatarImageView.isHidden = true
            initialsLabel.isHidden = false

            // Use GroupMetadata title (DB) for groups — route.title may be stale
            var title = route.title.isEmpty ? String(route.publicKey.prefix(8)) : route.title
            if route.isGroup,
               let meta = GroupRepository.shared.groupMetadata(
                   account: SessionManager.shared.currentPublicKey,
                   groupDialogKey: route.publicKey
               ), !meta.title.isEmpty {
                title = meta.title
            }

            let displayInitial: String = route.isGroup
                ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
                : RosettaColors.initials(name: title, publicKey: route.publicKey)
            initialsLabel.text = displayInitial

            // Mantine "light" variant (ChatListCell parity)
            let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey)
            let colorPair = RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count]
            let isDark = traitCollection.userInterfaceStyle == .dark
            let mantineDarkBody = UIColor(red: 0x1A / 255, green: 0x1B / 255, blue: 0x1E / 255, alpha: 1)
            let baseColor = isDark ? mantineDarkBody : .white
            let tintUIColor = UIColor(colorPair.tint)
            let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
            avatarBackgroundView.backgroundColor = baseColor.chatDetailBlended(with: tintUIColor, alpha: tintAlpha)

            // Emoji as first char → larger font, no bold rounded
            if displayInitial.unicodeScalars.first?.properties.isEmojiPresentation == true
                || (displayInitial.unicodeScalars.first?.properties.isEmoji == true
                    && displayInitial.unicodeScalars.count > 1) {
                initialsLabel.font = .systemFont(ofSize: 38 * 0.5)
            } else {
                initialsLabel.font = UIFont.systemFont(ofSize: 38 * 0.38, weight: .bold).chatDetailRounded()
            }
            initialsLabel.textColor = isDark ? UIColor(colorPair.text) : tintUIColor
        }
    }

    override var intrinsicContentSize: CGSize {
        CGSize(width: 44, height: 44)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        // Glass circle (full 44×44)
        glassView.frame = bounds
        glassView.fixedCornerRadius = bounds.height * 0.5
        glassView.updateGlass()

        // Avatar (38×38 centered inside 44×44)
        let avatarDiam: CGFloat = 38
        let pad = (bounds.width - avatarDiam) / 2
        let avatarFrame = CGRect(x: pad, y: pad, width: avatarDiam, height: avatarDiam)

        avatarBackgroundView.frame = avatarFrame
        avatarBackgroundView.layer.cornerRadius = avatarDiam * 0.5

        avatarImageView.frame = avatarFrame
        avatarImageView.layer.cornerRadius = avatarDiam * 0.5

        initialsLabel.frame = avatarFrame
        bookmarkIcon.frame = avatarFrame
    }

    override var isHighlighted: Bool {
        didSet { alpha = isHighlighted ? 0.6 : 1.0 }
    }
}

// MARK: - UIFont/UIColor Helpers (ChatListCell parity)

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

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

// MARK: - UIGestureRecognizerDelegate (Full-Width Swipe Back)

extension ChatDetailViewController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
        let velocity = pan.velocity(in: pan.view)
        return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
    }

    func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
    ) -> Bool {
        false
    }
}

// MARK: - UIViewController Pop Helper (for UIBarButtonItem target/action)

extension UIViewController {
    @objc func rosettaPopSelf() {
        navigationController?.popViewController(animated: true)
    }
}
