import AudioToolbox
import AVFAudio
@preconcurrency import AVFoundation
import Lottie
import UIKit

// MARK: - ComposerViewDelegate

@MainActor
protocol ComposerViewDelegate: AnyObject {
    func composerDidTapSend(_ composer: ComposerView)
    func composerDidTapAttach(_ composer: ComposerView)
    func composerTextDidChange(_ composer: ComposerView, text: String)
    func composerFocusDidChange(_ composer: ComposerView, isFocused: Bool)
    func composerHeightDidChange(_ composer: ComposerView, height: CGFloat)
    func composerDidCancelReply(_ composer: ComposerView)
    func composerDidCancelForward(_ composer: ComposerView)
    func composerUserDidType(_ composer: ComposerView)
    func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat)

    // Voice recording
    func composerDidStartRecording(_ composer: ComposerView)
    func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool)
    func composerDidCancelRecording(_ composer: ComposerView)
    func composerDidLockRecording(_ composer: ComposerView)
}

// MARK: - ComposerView

/// Pure UIKit composer — replaces the SwiftUI UIHostingController.
/// Eliminates UIHostingController sizing bugs during keyboard transitions.
/// Frame-based layout (Telegram pattern), no Auto Layout inside.
final class ComposerView: UIView, UITextViewDelegate {

    struct VoiceRecordingRuntimeGuards {
        var isCallIdle: () -> Bool
        var requestMicrophonePermission: () async -> Bool
        var hasSufficientDiskSpace: (Int64) -> Bool

        static let live = VoiceRecordingRuntimeGuards(
            isCallIdle: { CallManager.shared.uiState.phase == .idle },
            requestMicrophonePermission: { await AudioRecorder.requestMicrophonePermission() },
            hasSufficientDiskSpace: { minBytes in
                Self.defaultHasSufficientDiskSpace(minBytes: minBytes)
            }
        )

        private static func defaultHasSufficientDiskSpace(minBytes: Int64) -> Bool {
            let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
            let keys: Set<URLResourceKey> = [
                .volumeAvailableCapacityForImportantUsageKey,
                .volumeAvailableCapacityKey
            ]
            guard let values = try? home.resourceValues(forKeys: keys) else { return true }
            let important = values.volumeAvailableCapacityForImportantUsage ?? Int64.max
            let generic = Int64(values.volumeAvailableCapacity ?? Int.max)
            let available = min(important, generic)
            return available >= minBytes
        }
    }

    weak var delegate: ComposerViewDelegate?

    // MARK: - Public State

    var currentText: String { textView.text ?? "" }

    private(set) var currentHeight: CGFloat = 0

    /// When true, blocks becomeFirstResponder via textViewShouldBeginEditing.
    /// Set by NativeMessageListController during swipe-back to prevent UIKit
    /// from auto-restoring first responder on transition cancellation.
    var isFocusBlocked = false

    // MARK: - Mention Autocomplete

    var isGroupChat: Bool = false
    var groupDialogKey: String = ""
    /// Autocomplete table — added as subview of inputContainer (unified glass).
    private var mentionTableView: UITableView?
    private var mentionCandidates: [MentionCandidate] = []
    private var mentionCandidatesCache: [MentionCandidate]?
    /// Height of the autocomplete section inside inputContainer.
    private var mentionAutocompleteHeight: CGFloat = 0
    /// Resolved members from PacketSearch (not dependent on DialogRepository having a dialog).
    private var resolvedMembers: [String: (username: String, title: String)] = [:]
    private var mentionSearchHandlerId: UUID?

    private let mentionRowHeight: CGFloat = 42
    private let mentionMaxRows: CGFloat = 5

    // MARK: - Subviews

    // Attach button (glass circle, 42×42)
    private let attachButton = UIButton(type: .system)
    private let attachGlass = TelegramGlassUIView(frame: .zero)

    // Input container (glass rounded rect)
    private let inputContainer = UIView()
    private let inputGlass = TelegramGlassUIView(frame: .zero)

    // Reply bar
    private let replyBar = UIView()
    private let replyBlueBar = UIView()
    private let replyTitleLabel = UILabel()
    private let replySenderLabel = UILabel()
    private let replyPreviewLabel = UILabel()
    private let replyCancelButton = UIButton(type: .system)
    private var isReplyVisible = false

    // Forward bar
    private let forwardBar = UIView()
    private let forwardBlueBar = UIView()
    private let forwardTitleLabel = UILabel()
    private let forwardSubtitleLabel = UILabel()
    private let forwardCancelButton = UIButton(type: .system)
    private(set) var isForwardVisible = false

    // Text input (reuses ChatInputTextView from ChatTextInput.swift)
    private let textView = ChatInputTextView()

    // Emoji button
    private let emojiButton = UIButton(type: .system)

    // Send button (blue capsule, 38×36)
    private let sendButton = UIButton(type: .system)
    private let sendCapsule = UIView()

    // Mic button (glass circle, 42×42) — custom control for recording gestures
    private let micButton = RecordingMicButton(frame: .zero)
    private let micGlass = TelegramGlassUIView(frame: .zero)
    private var attachIconLayer: CAShapeLayer?
    private var emojiIconLayer: CAShapeLayer?
    private var sendIconLayer: CAShapeLayer?
    private var micIconView: UIImageView?

    // MARK: - Layout Constants

    private let horizontalPadding: CGFloat = 16
    private let buttonSize: CGFloat = 42
    private let innerSpacing: CGFloat = 6
    private let innerPadding: CGFloat = 3
    private let minInputContainerHeight: CGFloat = 42
    private let sendButtonWidth: CGFloat = 38
    private let sendButtonHeight: CGFloat = 36
    private let textViewMinHeight: CGFloat = 36
    private let maxLines: CGFloat = 5
    private let topPadding: CGFloat = 6
    private let bottomPadding: CGFloat = 12

    // MARK: - State

    private var textViewHeight: CGFloat = 36
    private var isMultiline = false
    private var isSendVisible = false
    private var isUpdatingText = false

    // MARK: - Voice Recording

    private let audioRecorder = AudioRecorder()
    private var recordingOverlay: VoiceRecordingOverlay?
    private var recordingPanel: VoiceRecordingPanel?
    private var recordingLockView: RecordingLockView?
    private var recordingPreviewPanel: RecordingPreviewPanel?
    private var recordingStartTask: Task<Void, Never>?
    private var recordingSendAccessibilityButton: UIButton?
    private var isPreviewReplacingInputRow = false
    private(set) var isRecording = false
    private(set) var isRecordingLocked = false
    private(set) var recordingFlowState: VoiceRecordingFlowState = .idle

    // Voice recording result (populated on stopRecording, read by delegate)
    private(set) var lastRecordedURL: URL?
    private(set) var lastRecordedDuration: TimeInterval = 0
    private(set) var lastRecordedWaveform: [Float] = []
    private(set) var lastVoiceSendTransitionSource: VoiceSendTransitionSource?
    /// True when voice send was initiated via mic release (deferred collapse).
    /// False for preview panel send (immediate collapse).
    private(set) var voiceSendNeedsDeferred = false

    private let minVoiceDuration = VoiceRecordingParityConstants.minVoiceDuration
    private let minFreeDiskBytes = VoiceRecordingParityConstants.minFreeDiskBytes
    var runtimeGuards: VoiceRecordingRuntimeGuards = .live

    private enum VoiceSessionCleanupMode {
        case preserveRecordedDraft
        case discardRecording
    }

    private enum VoiceSessionDismissStyle {
        case standard
        case cancel
    }

    // MARK: - Init

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

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

    // MARK: - Setup

    private func setupSubviews() {
        // --- Attach button ---
        attachGlass.isCircle = true
        attachGlass.isUserInteractionEnabled = false
        attachButton.addSubview(attachGlass)
        let attachIcon = makeIconLayer(
            pathData: TelegramIconPath.paperclip,
            viewBox: CGSize(width: 21, height: 24),
            targetSize: CGSize(width: 21, height: 24),
            color: .label
        )
        attachButton.layer.addSublayer(attachIcon)
        attachIconLayer = attachIcon
        attachButton.tag = 1 // for icon centering in layoutSubviews
        attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
        addSubview(attachButton)

        // --- Input container ---
        inputContainer.backgroundColor = .clear
        inputContainer.clipsToBounds = true
        inputGlass.fixedCornerRadius = 21
        inputGlass.isUserInteractionEnabled = false
        inputContainer.addSubview(inputGlass)
        addSubview(inputContainer)

        // --- Reply bar (hidden by default) ---
        replyBar.alpha = 0
        replyBar.isHidden = true

        replyBlueBar.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
        replyBlueBar.layer.cornerRadius = 1.0
        replyBar.addSubview(replyBlueBar)

        replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
        replyTitleLabel.textColor = .label
        replyTitleLabel.text = "Reply to "
        replyBar.addSubview(replyTitleLabel)

        replySenderLabel.font = .systemFont(ofSize: 14, weight: .medium)
        replySenderLabel.textColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
        replyBar.addSubview(replySenderLabel)

        replyPreviewLabel.font = .systemFont(ofSize: 14, weight: .regular)
        replyPreviewLabel.textColor = .label
        replyPreviewLabel.lineBreakMode = .byTruncatingTail
        replyBar.addSubview(replyPreviewLabel)

        let xImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium))
        replyCancelButton.setImage(xImage, for: .normal)
        replyCancelButton.tintColor = .secondaryLabel
        replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside)
        replyBar.addSubview(replyCancelButton)

        inputContainer.addSubview(replyBar)

        // --- Forward bar (hidden by default) ---
        forwardBar.alpha = 0
        forwardBar.isHidden = true

        forwardBlueBar.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
        forwardBlueBar.layer.cornerRadius = 1.0
        forwardBar.addSubview(forwardBlueBar)

        forwardTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
        forwardTitleLabel.textColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
        forwardBar.addSubview(forwardTitleLabel)

        forwardSubtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
        forwardSubtitleLabel.textColor = .secondaryLabel
        forwardSubtitleLabel.lineBreakMode = .byTruncatingTail
        forwardBar.addSubview(forwardSubtitleLabel)

        let fwdXImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium))
        forwardCancelButton.setImage(fwdXImage, for: .normal)
        forwardCancelButton.tintColor = .secondaryLabel
        forwardCancelButton.addTarget(self, action: #selector(forwardCancelTapped), for: .touchUpInside)
        forwardBar.addSubview(forwardCancelButton)

        inputContainer.addSubview(forwardBar)

        // --- Text view ---
        textView.delegate = self
        textView.font = .systemFont(ofSize: 17, weight: .regular)
        textView.textColor = .label
        textView.backgroundColor = .clear
        textView.tintColor = UIColor(red: 36/255.0, green: 138/255.0, blue: 230/255.0, alpha: 1)
        textView.isScrollEnabled = false
        textView.textContainerInset = UIEdgeInsets(top: 7, left: 2, bottom: 7, right: 0)
        textView.textContainer.lineFragmentPadding = 0
        textView.autocapitalizationType = .sentences
        textView.autocorrectionType = .default
        textView.keyboardAppearance = .default
        textView.returnKeyType = .default

        textView.placeholderLabel.text = "Message"
        textView.placeholderLabel.font = .systemFont(ofSize: 17, weight: .regular)
        textView.placeholderLabel.textColor = .placeholderText
        textView.accessibilityIdentifier = "voice.composer.textView"

        textView.trackingView.onHeightChange = { [weak self] height in
            guard let self else { return }
            self.delegate?.composerKeyboardHeightDidChange(self, height: height)
        }

        inputContainer.addSubview(textView)

        // --- Emoji button ---
        let emojiIcon = makeIconLayer(
            pathData: TelegramIconPath.emojiMoon,
            viewBox: CGSize(width: 19, height: 19),
            targetSize: CGSize(width: 19, height: 19),
            color: .secondaryLabel
        )
        emojiButton.layer.addSublayer(emojiIcon)
        emojiIconLayer = emojiIcon
        emojiButton.tag = 2
        emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside)
        inputContainer.addSubview(emojiButton)

        // --- Send button ---
        sendCapsule.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
        sendCapsule.isUserInteractionEnabled = false
        sendButton.addSubview(sendCapsule)

        let sendIcon = makeIconLayer(
            pathData: TelegramIconPath.sendPlane,
            viewBox: CGSize(width: 22, height: 19),
            targetSize: CGSize(width: 22, height: 19),
            color: .white
        )
        sendButton.layer.addSublayer(sendIcon)
        sendIconLayer = sendIcon
        sendButton.tag = 3
        sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
        sendButton.alpha = 0
        sendButton.transform = CGAffineTransform(scaleX: 0.74, y: 0.74)
        inputContainer.addSubview(sendButton)

        // --- Mic button ---
        micGlass.isCircle = true
        micGlass.isUserInteractionEnabled = false
        micButton.addSubview(micGlass)
        let micIconView = UIImageView(image: VoiceRecordingAssets.image(.iconMicrophone, templated: true))
        micIconView.contentMode = .center
        micIconView.tintColor = .label
        micIconView.isUserInteractionEnabled = false
        micButton.addSubview(micIconView)
        self.micIconView = micIconView
        micButton.tag = 4
        micButton.recordingDelegate = self
        micButton.isAccessibilityElement = true
        micButton.accessibilityLabel = "Voice message"
        micButton.accessibilityHint = "Hold to record voice message. Slide left to cancel or up to lock."
        micButton.accessibilityIdentifier = "voice.mic.button"
        addSubview(micButton)

        updateThemeColors()

        if #available(iOS 17.0, *) {
            registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: ComposerView, previousTraitCollection: UITraitCollection) in
                guard self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle else { return }
                self.updateThemeColors()
            }
        }
    }

    // MARK: - Public API

    func setText(_ text: String) {
        guard text != textView.text else { return }
        isUpdatingText = true
        textView.text = text
        textView.placeholderLabel.isHidden = !text.isEmpty
        isUpdatingText = false
        recalculateTextHeight()
        updateSendMicVisibility(animated: false)
    }

    func setReply(senderName: String?, previewText: String?) {
        let shouldShow = senderName != nil

        // Always update label text when reply is active (fixes race where
        // syncComposerState runs before SwiftUI body re-evaluates with new state)
        if shouldShow {
            replySenderLabel.text = senderName
            replyPreviewLabel.text = previewText ?? ""
        }

        guard shouldShow != isReplyVisible else { return }
        isReplyVisible = shouldShow

        if shouldShow {
            replyBar.isHidden = false
        }

        UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) {
            self.replyBar.alpha = shouldShow ? 1 : 0
            self.replyBar.transform = shouldShow ? .identity : CGAffineTransform(translationX: 0, y: 20)
        } completion: { _ in
            if !shouldShow {
                self.replyBar.isHidden = true
                self.replyBar.transform = .identity
            }
        }

        setNeedsLayout()
        // Report height change after layout
        DispatchQueue.main.async { [weak self] in
            self?.layoutIfNeeded()
            self?.reportHeightIfChanged()
        }
    }

    func setForward(count: Int, senderNames: [String], previewText: String? = nil) {
        let shouldShow = count > 0

        if shouldShow {
            if count == 1 {
                forwardTitleLabel.text = "Forward Message"
                let name = senderNames.first ?? ""
                let preview = previewText ?? ""
                forwardSubtitleLabel.text = preview.isEmpty ? name : "\(name): \(preview)"
            } else {
                forwardTitleLabel.text = "Forward \(count) Messages"
                let names = senderNames.joined(separator: ", ")
                forwardSubtitleLabel.text = "From: \(names)"
            }
        }

        guard shouldShow != isForwardVisible else { return }
        isForwardVisible = shouldShow

        if shouldShow {
            forwardBar.isHidden = false
        }

        UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) {
            self.forwardBar.alpha = shouldShow ? 1 : 0
            self.forwardBar.transform = shouldShow ? .identity : CGAffineTransform(translationX: 0, y: 20)
        } completion: { _ in
            if !shouldShow {
                self.forwardBar.isHidden = true
                self.forwardBar.transform = .identity
            }
        }

        updateSendMicVisibility(animated: true)
        setNeedsLayout()
        DispatchQueue.main.async { [weak self] in
            self?.layoutIfNeeded()
            self?.reportHeightIfChanged()
        }
    }

    func setFocused(_ focused: Bool) {
        if focused, !textView.isFirstResponder {
            DispatchQueue.main.async { [weak self] in
                self?.textView.becomeFirstResponder()
            }
        } else if !focused, textView.isFirstResponder {
            DispatchQueue.main.async { [weak self] in
                self?.textView.resignFirstResponder()
            }
        }
    }

    func consumeVoiceSendTransitionSource() -> VoiceSendTransitionSource? {
        defer { lastVoiceSendTransitionSource = nil }
        return lastVoiceSendTransitionSource
    }

#if DEBUG
    func debugSetRecordingStopAccessibilityAreaEnabled(_ isEnabled: Bool) {
        updateRecordingSendAccessibilityArea(isEnabled: isEnabled)
    }

    func debugShowPreviewReplacingInputRow(fileURL: URL, duration: TimeInterval, waveform: [Float]) {
        lastRecordedURL = fileURL
        lastRecordedDuration = duration
        lastRecordedWaveform = waveform

        setPreviewRowReplacement(true)
        recordingPreviewPanel?.removeFromSuperview()
        let preview = RecordingPreviewPanel(
            frame: inputContainer.bounds,
            fileURL: fileURL,
            duration: duration,
            waveform: waveform
        )
        preview.delegate = self
        inputContainer.addSubview(preview)
        recordingPreviewPanel = preview
        isRecording = false
        isRecordingLocked = false
        setRecordingFlowState(.draftPreview)
    }

    var debugPreviewPanelFrame: CGRect? {
        guard let recordingPreviewPanel else { return nil }
        return recordingPreviewPanel.convert(recordingPreviewPanel.bounds, to: self)
    }

    var debugIsPreviewReplacingInputRow: Bool {
        isPreviewReplacingInputRow
    }

    func debugForceVoiceSessionState(flow: VoiceRecordingFlowState, mic: VoiceRecordingState) {
        setRecordingFlowState(flow)
        isRecording = flow != .idle
        isRecordingLocked = flow == .recordingLocked || flow == .waitingForPreview || flow == .draftPreview
        micButton.debugSetRecordingState(mic)
    }

    func debugFinalizeVoiceSession(skipAudioCleanup: Bool = true) {
        resetVoiceSessionState(skipAudioCleanup: skipAudioCleanup)
        recordingOverlay?.dismiss()
        recordingOverlay = nil
        recordingLockView?.removeFromSuperview()
        recordingLockView = nil
        recordingPanel?.removeFromSuperview()
        recordingPanel = nil
        recordingPreviewPanel?.removeFromSuperview()
        recordingPreviewPanel = nil
    }

    var debugMicRecordingState: VoiceRecordingState {
        micButton.recordingState
    }
#endif

    private func setRecordingFlowState(_ state: VoiceRecordingFlowState) {
        recordingFlowState = state
    }

    // MARK: - Hit Testing (RecordMore floats above inputContainer)

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if let preview = recordingPreviewPanel {
            let previewPoint = convert(point, to: preview)
            if preview.point(inside: previewPoint, with: event) {
                if let hit = preview.hitTest(previewPoint, with: event) {
                    return hit
                }
            }
        }
        return super.hitTest(point, with: event)
    }

    // MARK: - Layout

    override func layoutSubviews() {
        super.layoutSubviews()
        let w = bounds.width
        guard w > 0 else { return }

        // Reply / forward bar height (8 top + 36 bar + 2 bottom = tight gap to text)
        // Mutually exclusive — only one is visible at a time
        let replyH: CGFloat = isReplyVisible ? 46 : 0
        let forwardH: CGFloat = isForwardVisible ? 46 : 0
        let accessoryH = replyH + forwardH

        // Text row height = textViewHeight (clamped)
        let textRowH = textViewHeight

        // Input container inner height = mention autocomplete + padding + accessory + text row + padding
        let inputInnerH = mentionAutocompleteHeight + innerPadding + accessoryH + textRowH + innerPadding
        let inputContainerH = max(minInputContainerHeight, inputInnerH)

        // Main bar height
        let mainBarH = max(buttonSize, inputContainerH)

        // Total height
        let totalH = topPadding + mainBarH + bottomPadding

        // Attach button
        let attachX = horizontalPadding
        let attachY = topPadding + mainBarH - buttonSize
        attachButton.frame = CGRect(x: attachX, y: attachY, width: buttonSize, height: buttonSize)
        attachGlass.frame = attachButton.bounds

        // Center icon in attach button
        centerIconLayer(in: attachButton, iconSize: CGSize(width: 21, height: 24))

        // Mic button
        let showMic = !isSendVisible && !isPreviewReplacingInputRow
        let micX = w - horizontalPadding - buttonSize
        let micY = topPadding + mainBarH - buttonSize
        micButton.frame = CGRect(x: micX, y: micY, width: buttonSize, height: buttonSize)
        micGlass.frame = micButton.bounds
        if let micIconView {
            let iconSize = micIconView.image?.size ?? CGSize(width: 30, height: 30)
            micIconView.frame = CGRect(
                x: floor((micButton.bounds.width - iconSize.width) / 2.0),
                y: floor((micButton.bounds.height - iconSize.height) / 2.0),
                width: iconSize.width,
                height: iconSize.height
            )
        }

        // Input container
        let inputX = isPreviewReplacingInputRow ? horizontalPadding : (attachX + buttonSize + innerSpacing)
        let micWidth: CGFloat = showMic ? (buttonSize + innerSpacing) : 0
        let inputW = w - inputX - horizontalPadding - micWidth
        let inputY = topPadding + mainBarH - inputContainerH
        inputContainer.frame = CGRect(x: inputX, y: inputY, width: inputW, height: inputContainerH)
        inputGlass.frame = inputContainer.bounds

        let cornerRadius: CGFloat = isMultiline ? 16 : 21
        inputContainer.layer.cornerRadius = cornerRadius
        inputContainer.layer.cornerCurve = .continuous
        inputGlass.fixedCornerRadius = cornerRadius
        inputGlass.applyCornerRadius()

        // Mention autocomplete table (inside inputContainer, at the top)
        if let tv = mentionTableView {
            tv.frame = CGRect(x: 0, y: 0, width: inputW, height: mentionAutocompleteHeight)
            tv.isHidden = mentionAutocompleteHeight < 1
        }

        // Forward bar inside input container (shifted down by autocomplete height)
        let replyX: CGFloat = 6
        let replyW = inputW - replyX - 4
        forwardBar.frame = CGRect(x: replyX, y: mentionAutocompleteHeight + innerPadding, width: replyW, height: forwardH)
        layoutForwardBar(width: replyW, height: forwardH)

        // Reply bar inside input container (shifted down by autocomplete + forward)
        replyBar.frame = CGRect(x: replyX, y: mentionAutocompleteHeight + innerPadding + forwardH, width: replyW, height: replyH)
        layoutReplyBar(width: replyW, height: replyH)

        // Text view inside input container (shifted down by autocomplete + accessory bars)
        let textX: CGFloat = innerPadding + 6
        let textY = mentionAutocompleteHeight + innerPadding + accessoryH
        let sendExtraW: CGFloat = isSendVisible ? sendButtonWidth : 0
        let emojiW: CGFloat = 20
        let emojiTrailing: CGFloat = 8 + sendExtraW
        let textW = inputW - textX - emojiW - emojiTrailing - innerPadding
        textView.frame = CGRect(x: textX, y: textY, width: max(0, textW), height: textRowH)

        // Emoji button
        let emojiX = textX + textW
        let emojiY = textY + textRowH - 36
        emojiButton.frame = CGRect(x: emojiX, y: emojiY, width: emojiW, height: 36)
        centerIconLayer(in: emojiButton, iconSize: CGSize(width: 19, height: 19))

        // Send button
        let sendX = inputW - innerPadding - sendButtonWidth
        let sendY = textY + textRowH - sendButtonHeight
        sendButton.frame = CGRect(x: sendX, y: sendY, width: sendButtonWidth, height: sendButtonHeight)
        sendCapsule.frame = sendButton.bounds
        sendCapsule.layer.cornerRadius = sendButtonHeight / 2
        centerIconLayer(in: sendButton, iconSize: CGSize(width: 22, height: 19))

        if recordingSendAccessibilityButton != nil {
            updateRecordingSendAccessibilityArea(isEnabled: true)
        }
        if let recordingPreviewPanel {
            recordingPreviewPanel.frame = inputContainer.bounds
        }

        // Report height
        if abs(totalH - currentHeight) > 0.5 {
            currentHeight = totalH
            delegate?.composerHeightDidChange(self, height: totalH)
        }
    }

    private func layoutForwardBar(width: CGFloat, height: CGFloat) {
        guard height > 0 else { return }
        let barX: CGFloat = 5
        let barY: CGFloat = 8
        forwardBlueBar.frame = CGRect(x: barX, y: barY, width: 2, height: 36)

        let labelX: CGFloat = barX + 2 + 8
        forwardTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: width - labelX - 44, height: 17)
        forwardSubtitleLabel.frame = CGRect(x: labelX, y: barY + 19, width: width - labelX - 44, height: 17)
        forwardCancelButton.frame = CGRect(x: width - 44, y: (height - 44) / 2, width: 44, height: 44)
    }

    private func layoutReplyBar(width: CGFloat, height: CGFloat) {
        guard height > 0 else { return }
        // barX=5 aligns blue line with text cursor: replyX(6)+5 = 11 = textX(9)+inset(2)
        let barX: CGFloat = 5
        let barY: CGFloat = 8
        replyBlueBar.frame = CGRect(x: barX, y: barY, width: 2, height: 36)

        let labelX: CGFloat = barX + 2 + 8 // blue line + spacing
        let titleSize = replyTitleLabel.sizeThatFits(CGSize(width: 200, height: 20))
        replyTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: titleSize.width, height: 17)

        let senderX = labelX + titleSize.width
        let senderW = width - senderX - 44
        replySenderLabel.frame = CGRect(x: senderX, y: barY + 2, width: max(0, senderW), height: 17)

        replyPreviewLabel.frame = CGRect(x: labelX, y: barY + 19, width: width - labelX - 44, height: 17)

        replyCancelButton.frame = CGRect(x: width - 44, y: (height - 44) / 2, width: 44, height: 44)
    }

    // MARK: - Icon Helpers

    private func makeIconLayer(
        pathData: String,
        viewBox: CGSize,
        targetSize: CGSize,
        color: UIColor
    ) -> CAShapeLayer {
        var parser = SVGPathParser(pathData: pathData)
        let cgPath = parser.parse()

        let layer = CAShapeLayer()
        let sx = targetSize.width / viewBox.width
        let sy = targetSize.height / viewBox.height
        layer.path = cgPath.copy(using: [CGAffineTransform(scaleX: sx, y: sy)])
        layer.fillColor = color.cgColor
        layer.bounds = CGRect(origin: .zero, size: targetSize)
        return layer
    }

    private func centerIconLayer(in button: UIView, iconSize: CGSize) {
        for sublayer in button.layer.sublayers ?? [] {
            guard let shape = sublayer as? CAShapeLayer else { continue }
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            shape.position = CGPoint(x: button.bounds.midX, y: button.bounds.midY)
            CATransaction.commit()
            break
        }
    }

    private func updateThemeColors() {
        attachIconLayer?.fillColor = UIColor.label.cgColor
        emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor
        sendIconLayer?.fillColor = UIColor.white.cgColor
        micIconView?.tintColor = .label

        replyTitleLabel.textColor = .label
        replyPreviewLabel.textColor = .label
        replyCancelButton.tintColor = .secondaryLabel
        forwardSubtitleLabel.textColor = .secondaryLabel
        forwardCancelButton.tintColor = .secondaryLabel
        textView.textColor = .label
        textView.placeholderLabel.textColor = .placeholderText
    }

    // MARK: - Text Height

    private func recalculateTextHeight() {
        let lineHeight = textView.font?.lineHeight ?? 20
        let insets = textView.textContainerInset
        let maxTextH = lineHeight * maxLines
        let maxTotalH = maxTextH + insets.top + insets.bottom

        let fittingSize = textView.sizeThatFits(
            CGSize(width: textView.bounds.width > 0 ? textView.bounds.width : (bounds.width - 150), height: .greatestFiniteMagnitude)
        )
        let newH = max(textViewMinHeight, min(fittingSize.height, maxTotalH))

        // Enable/disable scrolling
        let shouldScroll = fittingSize.height > maxTotalH
        if textView.isScrollEnabled != shouldScroll {
            textView.isScrollEnabled = shouldScroll
        }

        // Check multiline
        let singleLineH = lineHeight + insets.top + insets.bottom
        let threshold = singleLineH + lineHeight * 0.5
        let newMultiline = fittingSize.height > threshold
        if newMultiline != isMultiline {
            isMultiline = newMultiline
            UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
                self.setNeedsLayout()
                self.layoutIfNeeded()
            }
        }

        if abs(newH - textViewHeight) > 0.5 {
            textViewHeight = newH
            setNeedsLayout()
        }
    }

    private func reportHeightIfChanged() {
        let replyH: CGFloat = isReplyVisible ? 46 : 0
        let inputInnerH = mentionAutocompleteHeight + innerPadding + replyH + textViewHeight + innerPadding
        let inputContainerH = max(minInputContainerHeight, inputInnerH)
        let mainBarH = max(buttonSize, inputContainerH)
        let totalH = topPadding + mainBarH + bottomPadding

        if abs(totalH - currentHeight) > 0.5 {
            currentHeight = totalH
            delegate?.composerHeightDidChange(self, height: totalH)
        }
    }

    // MARK: - Send / Mic Visibility

    private func updateSendMicVisibility(animated: Bool) {
        let hasContent = !(textView.text ?? "").isEmpty || isForwardVisible
        guard hasContent != isSendVisible else { return }
        isSendVisible = hasContent

        let duration: TimeInterval = animated ? 0.28 : 0
        let damping: CGFloat = 0.9

        UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: .beginFromCurrentState) {
            self.sendButton.alpha = hasContent ? 1 : 0
            self.sendButton.transform = hasContent ? .identity : CGAffineTransform(scaleX: 0.74, y: 0.74)

            self.micButton.alpha = hasContent ? 0 : 1
            self.micButton.transform = hasContent ? CGAffineTransform(scaleX: 0.42, y: 0.78) : .identity

            self.setNeedsLayout()
            self.layoutIfNeeded()
        }
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        if #available(iOS 17.0, *) { return }
        guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
        updateThemeColors()
    }

    // MARK: - UITextViewDelegate

    func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
        !isFocusBlocked
    }

    func textViewDidBeginEditing(_ textView: UITextView) {
        delegate?.composerFocusDidChange(self, isFocused: true)
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        delegate?.composerFocusDidChange(self, isFocused: false)
    }

    func textView(
        _ textView: UITextView,
        shouldChangeTextIn range: NSRange,
        replacementText text: String
    ) -> Bool {
        guard !text.isEmpty, text != "\n" else { return true }
        delegate?.composerUserDidType(self)
        return true
    }

    func textViewDidChange(_ textView: UITextView) {
        guard !isUpdatingText else { return }
        self.textView.placeholderLabel.isHidden = !(textView.text ?? "").isEmpty
        delegate?.composerTextDidChange(self, text: textView.text ?? "")
        recalculateTextHeight()
        updateSendMicVisibility(animated: true)
        updateMentionAutocomplete()
    }

    // MARK: - Actions

    @objc private func attachTapped() {
        delegate?.composerDidTapAttach(self)
    }

    @objc private func sendTapped() {
        delegate?.composerDidTapSend(self)
    }

    @objc private func emojiTapped() {
        // Emoji button = focus trigger (system emoji via 🌐 key)
        if !textView.isFirstResponder {
            textView.becomeFirstResponder()
        }
    }

    // MARK: - Mention Autocomplete Logic

    private func updateMentionAutocomplete() {
        guard isGroupChat else {
            hideMentionAutocomplete()
            return
        }

        guard let query = extractMentionQuery() else {
            hideMentionAutocomplete()
            return
        }

        let candidates = loadAndFilterCandidates(query: query)
        if candidates.isEmpty {
            hideMentionAutocomplete()
            return
        }

        showMentionAutocomplete(candidates: candidates)
    }

    /// Extracts the @query at the cursor. Returns nil if no active mention trigger.
    private func extractMentionQuery() -> String? {
        let text = textView.text ?? ""
        guard !text.isEmpty else { return nil }
        let cursorLocation = textView.selectedRange.location
        guard cursorLocation > 0, cursorLocation <= (text as NSString).length else { return nil }

        let nsText = text as NSString
        // Scan backward from cursor to find "@"
        var atIndex: Int?
        for i in stride(from: cursorLocation - 1, through: 0, by: -1) {
            let char = nsText.character(at: i)
            if char == Character("@").asciiValue! {
                // "@" must be at start or preceded by whitespace/newline
                if i == 0 {
                    atIndex = i
                } else {
                    let prevChar = nsText.character(at: i - 1)
                    if let scalar = Unicode.Scalar(prevChar),
                       CharacterSet.whitespacesAndNewlines.contains(scalar) {
                        atIndex = i
                    }
                }
                break
            }
            // Stop if we hit whitespace before finding "@"
            // Unicode.Scalar(char) returns nil for surrogate pairs (emoji) — treat as non-whitespace
            if let scalar = Unicode.Scalar(char),
               CharacterSet.whitespacesAndNewlines.contains(scalar) {
                break
            }
        }

        guard let idx = atIndex else { return nil }
        let queryStart = idx + 1 // after "@"
        let queryLength = cursorLocation - queryStart
        if queryLength < 0 { return nil }
        if queryLength == 0 { return "" } // just "@" typed, show all
        return nsText.substring(with: NSRange(location: queryStart, length: queryLength))
    }

    /// Loads group members (cached) and filters by query.
    private func loadAndFilterCandidates(query: String) -> [MentionCandidate] {
        if mentionCandidatesCache == nil {
            mentionCandidatesCache = resolveMentionCandidates()
        }
        guard let all = mentionCandidatesCache else { return [] }

        let lowQuery = query.lowercased()
        var result: [MentionCandidate] = []

        // @all special entry
        if lowQuery.isEmpty || "all".hasPrefix(lowQuery) {
            result.append(.allMembers())
        }

        for candidate in all {
            if lowQuery.isEmpty
                || candidate.username.lowercased().hasPrefix(lowQuery)
                || candidate.title.lowercased().hasPrefix(lowQuery) {
                result.append(candidate)
            }
        }
        return result
    }

    /// Resolves group members from GroupRepository + DialogRepository + resolvedMembers cache.
    /// Uses direct PacketSearch with own handler — doesn't depend on DialogRepository
    /// having a dialog entry (updateUserInfo silently drops unknown users).
    private func resolveMentionCandidates() -> [MentionCandidate] {
        let account = SessionManager.shared.currentPublicKey
        guard let cached = GroupRepository.shared.cachedMembers(
            account: account, groupDialogKey: groupDialogKey
        ) else { return [] }

        let myKey = account
        var candidates: [MentionCandidate] = []
        var unknownKeys: [String] = []

        for memberKey in cached.memberKeys {
            if memberKey == myKey { continue }

            // 1. Check DialogRepository (users with prior direct chat)
            let dialog = DialogRepository.shared.dialogs[memberKey]
            let dialogUsername = dialog?.opponentUsername ?? ""
            let dialogTitle = dialog?.opponentTitle ?? ""

            if !dialogUsername.isEmpty {
                candidates.append(MentionCandidate(
                    username: dialogUsername,
                    title: dialogTitle.isEmpty ? dialogUsername : dialogTitle,
                    publicKey: memberKey, isAll: false
                ))
                continue
            }

            // 2. Check resolvedMembers (from PacketSearch responses)
            if let resolved = resolvedMembers[memberKey], !resolved.username.isEmpty {
                candidates.append(MentionCandidate(
                    username: resolved.username,
                    title: resolved.title.isEmpty ? resolved.username : resolved.title,
                    publicKey: memberKey, isAll: false
                ))
                continue
            }

            unknownKeys.append(memberKey)
        }

        if !unknownKeys.isEmpty {
            fetchUnknownMembers(unknownKeys)
        }

        return candidates.sorted { $0.username.lowercased() < $1.username.lowercased() }
    }

    /// Sends PacketSearch for unknown members using a dedicated search channel.
    /// Registers own handler to capture responses directly — bypasses DialogRepository
    /// (which silently drops users without existing dialog entries).
    private func fetchUnknownMembers(_ keys: [String]) {
        guard let privKeyHash = SessionManager.shared.privateKeyHash else { return }

        // Register one-shot handler per batch
        let searchId = UUID()
        var remaining = keys.count

        let handlerId = ProtocolManager.shared.addSearchResultHandler(channel: .ui(searchId)) { [weak self] packet in
            Task { @MainActor [weak self] in
                guard let self else { return }
                for user in packet.users where !user.username.isEmpty {
                    self.resolvedMembers[user.publicKey] = (username: user.username, title: user.title)
                }
                remaining -= 1
                if remaining <= 0 {
                    // All responses received — rebuild cache
                    if let hid = self.mentionSearchHandlerId {
                        ProtocolManager.shared.removeSearchResultHandler(hid)
                        self.mentionSearchHandlerId = nil
                    }
                    self.mentionCandidatesCache = nil
                    self.updateMentionAutocomplete()
                }
            }
        }
        mentionSearchHandlerId = handlerId

        for key in keys {
            var packet = PacketSearch()
            packet.search = key
            packet.privateKey = privKeyHash
            ProtocolManager.shared.sendSearchPacket(packet, channel: .ui(searchId))
        }
    }

    /// Ensures the mention table view is created and added inside inputContainer.
    private func ensureMentionTableView() {
        guard mentionTableView == nil else { return }
        let tv = UITableView(frame: .zero, style: .plain)
        tv.dataSource = self
        tv.delegate = self
        tv.backgroundColor = .clear
        tv.separatorStyle = .none
        tv.showsVerticalScrollIndicator = false
        tv.register(MentionCell.self, forCellReuseIdentifier: MentionCell.reuseID)
        tv.rowHeight = mentionRowHeight
        // Insert below text views but above glass
        inputContainer.insertSubview(tv, aboveSubview: inputGlass)
        mentionTableView = tv
    }

    private func showMentionAutocomplete(candidates: [MentionCandidate]) {
        ensureMentionTableView()
        mentionCandidates = candidates

        let newHeight = min(CGFloat(candidates.count) * mentionRowHeight, mentionMaxRows * mentionRowHeight)
        let changed = abs(mentionAutocompleteHeight - newHeight) > 0.5

        mentionAutocompleteHeight = newHeight
        mentionTableView?.reloadData()

        if changed {
            // Trigger re-layout — inputContainer will grow upward to include autocomplete rows
            UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0) {
                self.recalculateTextHeight()
                self.layoutSubviews()
                self.delegate?.composerHeightDidChange(self, height: self.currentHeight)
            }
        }
    }

    private func hideMentionAutocomplete() {
        guard mentionAutocompleteHeight > 0 else { return }
        mentionAutocompleteHeight = 0
        mentionCandidates = []

        UIView.animate(withDuration: 0.2) {
            self.recalculateTextHeight()
            self.layoutSubviews()
            self.delegate?.composerHeightDidChange(self, height: self.currentHeight)
        }
    }

    /// Inserts the selected mention into the text view.
    func insertMention(username: String) {
        guard let query = extractMentionQuery() else { return }
        let text = textView.text ?? ""
        let nsText = text as NSString
        let cursorLocation = textView.selectedRange.location

        let atPosition = cursorLocation - query.count - 1
        guard atPosition >= 0 else { return }

        let replaceRange = NSRange(location: atPosition, length: query.count + 1)
        let replacement = "@\(username) "
        let newText = nsText.replacingCharacters(in: replaceRange, with: replacement)

        textView.text = newText
        textView.selectedRange = NSRange(location: atPosition + replacement.count, length: 0)
        textViewDidChange(textView)
    }

    /// Preloads group member info via PacketSearch for members not in DialogRepository.
    /// Called eagerly when entering a group chat so member usernames are available by
    /// the time the user types "@".
    func preloadMentionMembers() {
        guard isGroupChat, !groupDialogKey.isEmpty else { return }
        let account = SessionManager.shared.currentPublicKey
        guard let cached = GroupRepository.shared.cachedMembers(
            account: account, groupDialogKey: groupDialogKey
        ) else { return }

        let unknownKeys = cached.memberKeys.filter { key in
            key != account
                && (DialogRepository.shared.dialogs[key]?.opponentUsername ?? "").isEmpty
                && resolvedMembers[key] == nil
        }
        if !unknownKeys.isEmpty {
            fetchUnknownMembers(unknownKeys)
        }
    }

    /// Clears cached candidates and removes search handler.
    func clearMentionCache() {
        mentionCandidatesCache = nil
        resolvedMembers.removeAll()
        if let hid = mentionSearchHandlerId {
            ProtocolManager.shared.removeSearchResultHandler(hid)
            mentionSearchHandlerId = nil
        }
        hideMentionAutocomplete()
    }

    @objc private func replyCancelTapped() {
        delegate?.composerDidCancelReply(self)
    }

    @objc private func forwardCancelTapped() {
        delegate?.composerDidCancelForward(self)
    }
}

// MARK: - RecordingMicButtonDelegate

extension ComposerView: RecordingMicButtonDelegate {

    func micButtonRecordingArmed(_ button: RecordingMicButton) {
        setRecordingFlowState(.armed)
    }

    func micButtonRecordingArmingCancelled(_ button: RecordingMicButton) {
        if recordingFlowState == .armed {
            setRecordingFlowState(.idle)
        }
    }

    func micButtonRecordingBegan(_ button: RecordingMicButton) {
        // Show recording UI immediately — before async checks (Telegram parity).
        isRecording = true
        isRecordingLocked = false
        setRecordingFlowState(.recordingUnlocked)
        presentRecordingChrome(locked: false, animatePanel: true)

        // Haptic 100ms after chrome — overlay is at alpha ~0.7, visually present.
        // Fired outside the Task so AVAudioSession can't suppress it, and
        // button state guards can't skip it.
        let hapticGenerator = UIImpactFeedbackGenerator(style: .medium)
        hapticGenerator.prepare()
        print("[HAPTIC] prepare() called, scheduling impactOccurred in 100ms")
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
            let isStillRecording = self?.isRecording == true
            print("[HAPTIC] firing impactOccurred — isRecording=\(isStillRecording) flowState=\(String(describing: self?.recordingFlowState))")
            hapticGenerator.impactOccurred()
            AudioServicesPlaySystemSound(1519)
            print("[HAPTIC] impactOccurred + SystemSound(1519) DONE")
        }

        recordingStartTask?.cancel()
        recordingStartTask = Task { @MainActor [weak self] in
            guard let self else { return }
            guard self.runtimeGuards.isCallIdle() else {
                self.rollbackRecordingChrome(for: button)
                return
            }
            guard self.runtimeGuards.hasSufficientDiskSpace(self.minFreeDiskBytes) else {
                self.rollbackRecordingChrome(for: button)
                return
            }

            let granted = await self.runtimeGuards.requestMicrophonePermission()
            guard !Task.isCancelled else { return }
            guard granted else {
                self.rollbackRecordingChrome(for: button)
                return
            }
            guard button.recordingState == .recording else { return }
            guard self.audioRecorder.startRecording() else {
                self.rollbackRecordingChrome(for: button)
                return
            }

            self.configureRecorderLevelUpdates()
            self.delegate?.composerDidStartRecording(self)
        }
    }

    func micButtonRecordingFinished(_ button: RecordingMicButton) {
        print("[VOICE_SEND] micButtonRecordingFinished — flowState=\(recordingFlowState)")
        guard recordingFlowState == .recordingUnlocked else {
            print("[VOICE_SEND] micButtonRecordingFinished — GUARD FAILED, flowState=\(recordingFlowState)")
            button.resetState()
            return
        }
        finishRecordingAndSend(sourceView: micButton)
        button.resetState()
    }

    func micButtonRecordingCancelled(_ button: RecordingMicButton) {
        cancelRecordingWithDismissAnimation()
        button.resetState()
        delegate?.composerDidCancelRecording(self)
    }

    func micButtonRecordingLocked(_ button: RecordingMicButton) {
        guard recordingFlowState == .recordingUnlocked else { return }
        isRecordingLocked = true
        setRecordingFlowState(.recordingLocked)

        recordingPanel?.showCancelButton()
        recordingLockView?.showStopButton { [weak self] in
            self?.showRecordingPreview()
            self?.micButton.resetState()
        }
        recordingOverlay?.transitionToLocked(onTapStop: { [weak self] in
            self?.finishRecordingAndSend(sourceView: self?.micButton)
            self?.micButton.resetState()
        })
        updateRecordingSendAccessibilityArea(isEnabled: true)

        delegate?.composerDidLockRecording(self)
    }

    func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat) {
        recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY)
        recordingPanel?.updateCancelTranslation(distanceX)
        let lockness = VoiceRecordingParityMath.lockness(distanceY: distanceY)
        recordingLockView?.updateLockness(lockness, dragOffsetY: distanceY)
    }

    func showRecordingPreview() {
        guard recordingFlowState == .recordingLocked || recordingFlowState == .recordingUnlocked else { return }
        setRecordingFlowState(.waitingForPreview)

        audioRecorder.onLevelUpdate = nil

        // Dismiss recording UI immediately
        recordingOverlay?.dismiss()
        recordingOverlay = nil
        recordingLockView?.dismiss()
        recordingLockView = nil
        recordingPanel?.animateOut { [weak self] in
            self?.recordingPanel = nil
        }
        updateRecordingSendAccessibilityArea(isEnabled: false)

        if audioRecorder.hasPreviousSegments {
            // Multi-segment (after "record more") — async merge then show preview
            audioRecorder.pauseRecordingForPreview { [weak self] snapshot in
                guard let self, let snapshot else {
                    self?.dismissOverlayAndRestore()
                    return
                }
                self.presentPreviewPanel(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
            }
        } else {
            // Single segment — sync path
            let paused = audioRecorder.pauseRecordingForPreview() ?? audioRecorder.currentRecordingSnapshot()
            guard let snapshot = paused else {
                dismissOverlayAndRestore()
                return
            }
            presentPreviewPanel(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
        }
    }

    private func presentPreviewPanel(url: URL, duration: TimeInterval, waveform: [Float]) {
        lastRecordedURL = url
        lastRecordedDuration = duration
        lastRecordedWaveform = waveform

        if VoiceRecordingParityMath.shouldDiscard(duration: duration) {
            dismissOverlayAndRestore()
            return
        }

        setPreviewRowReplacement(true)
        micButton.resetState()
        let preview = RecordingPreviewPanel(
            frame: inputContainer.bounds,
            fileURL: url,
            duration: duration,
            waveform: waveform
        )
        preview.delegate = self
        inputContainer.addSubview(preview)
        preview.animateIn()
        recordingPreviewPanel = preview
        isRecording = false
        isRecordingLocked = false
        setRecordingFlowState(.draftPreview)
    }

    private func finishRecordingAndSend(sourceView: UIView?) {
        print("[VOICE_SEND] finishRecordingAndSend START — flowState=\(recordingFlowState) isRecording=\(isRecording) isLocked=\(isRecordingLocked) isPreview=\(isPreviewReplacingInputRow)")
        audioRecorder.onFinished = { [weak self] url, duration, waveform in
            self?.lastRecordedURL = url
            self?.lastRecordedDuration = duration
            self?.lastRecordedWaveform = waveform
            print("[VOICE_SEND] onFinished callback — url=\(url.lastPathComponent) dur=\(duration)")
        }
        audioRecorder.onLevelUpdate = nil
        audioRecorder.stopRecording()

        guard !VoiceRecordingParityMath.shouldDiscard(duration: lastRecordedDuration) else {
            print("[VOICE_SEND] DISCARDED — too short \(lastRecordedDuration)")
            dismissOverlayAndRestore()
            return
        }

        // Deferred collapse: don't dismiss overlay here — delegate will call
        // performDeferredVoiceSendCollapse() when the message cell appears in
        // the collection view. This syncs composer collapse with bubble appearance
        // (Telegram pattern), eliminating the visual "jerk".
        voiceSendNeedsDeferred = true
        lastVoiceSendTransitionSource = nil
        print("[VOICE_SEND] deferred collapse — calling delegate WITHOUT dismiss")
        delegate?.composerDidFinishRecording(self, sendImmediately: true)
        print("[VOICE_SEND] finishRecordingAndSend DONE")
    }

    /// Called by NativeMessageListController when the voice message cell appears
    /// in the collection view. Performs the overlay dismiss + chrome restore that
    /// was deferred from finishRecordingAndSend.
    func performDeferredVoiceSendCollapse() {
        voiceSendNeedsDeferred = false
        dismissOverlayAndRestore(skipAudioCleanup: true)
    }

    private func presentRecordingChrome(locked: Bool, animatePanel: Bool) {
        guard let window else { return }
        hideComposerChrome()

        let overlay = VoiceRecordingOverlay()
        overlay.present(anchorView: micButton, in: window)
        recordingOverlay = overlay

        let panelX = horizontalPadding
        let panelW = micButton.frame.minX - innerSpacing - horizontalPadding
        let panel = VoiceRecordingPanel(frame: CGRect(
            x: panelX,
            y: inputContainer.frame.origin.y,
            width: panelW,
            height: inputContainer.frame.height
        ))
        panel.delegate = self
        addSubview(panel)
        if animatePanel {
            panel.animateIn(panelWidth: panelW)
        }
        if locked {
            panel.showCancelButton()
            overlay.transitionToLocked(onTapStop: { [weak self] in
                self?.finishRecordingAndSend(sourceView: self?.micButton)
                self?.micButton.resetState()
            })
        } else {
            let lockView = RecordingLockView(frame: .zero)
            let micCenter = convert(micButton.center, to: window)
            lockView.present(anchorCenter: micCenter, in: window)
            recordingLockView = lockView
        }
        recordingPanel = panel
    }

    private func configureRecorderLevelUpdates() {
        audioRecorder.onLevelUpdate = { [weak self] duration, level in
            self?.recordingOverlay?.addMicLevel(CGFloat(level))
            self?.recordingPanel?.updateDuration(duration)
        }
    }

    private func hideComposerChrome() {
        UIView.animate(withDuration: 0.15) {
            self.inputContainer.alpha = 0
            self.attachButton.alpha = 0
            self.micButton.alpha = 0
            self.micGlass.alpha = 0
            self.micIconView?.alpha = 0
        }
    }

    private func restoreComposerChrome() {
        print("[VOICE_SEND] restoreComposerChrome — inputContainer.alpha=\(inputContainer.alpha) attachButton.alpha=\(attachButton.alpha) micButton.alpha=\(micButton.alpha)")
        UIView.animate(withDuration: 0.15, animations: {
            self.inputContainer.alpha = 1
            self.attachButton.alpha = 1
            self.micButton.alpha = self.isSendVisible ? 0 : 1
            self.micButton.transform = self.isSendVisible ? CGAffineTransform(scaleX: 0.42, y: 0.78) : .identity
            self.micButton.isUserInteractionEnabled = true
            self.micGlass.alpha = 1
            self.micIconView?.alpha = 1
        }, completion: { _ in
            print("[VOICE_SEND] restoreComposerChrome ANIMATION DONE")
        })
        updateSendMicVisibility(animated: false)
    }

    private func setPreviewRowReplacement(_ enabled: Bool) {
        guard isPreviewReplacingInputRow != enabled else { return }
        isPreviewReplacingInputRow = enabled

        textView.isHidden = enabled
        textView.isUserInteractionEnabled = !enabled
        emojiButton.isHidden = enabled
        emojiButton.isUserInteractionEnabled = !enabled
        sendButton.isHidden = enabled
        sendButton.isUserInteractionEnabled = !enabled
        replyBar.isHidden = enabled ? true : !isReplyVisible
        inputGlass.alpha = enabled ? 0 : 1
        micButton.isUserInteractionEnabled = !enabled

        if enabled {
            inputContainer.alpha = 1
            inputContainer.clipsToBounds = false  // RecordMore floats above panel
            attachButton.alpha = 0
            micButton.alpha = 0
            micGlass.alpha = 0
            micIconView?.alpha = 0
            bringSubviewToFront(inputContainer)
        } else {
            inputContainer.clipsToBounds = true
            replyBar.alpha = isReplyVisible ? 1 : 0
            bringSubviewToFront(micButton)
        }

        setNeedsLayout()
        layoutIfNeeded()
    }

    private func resetVoiceSessionState(skipAudioCleanup: Bool) {
        let cleanupMode: VoiceSessionCleanupMode = skipAudioCleanup ? .preserveRecordedDraft : .discardRecording
        resetVoiceSessionState(cleanup: cleanupMode)
    }

    private func resetVoiceSessionState(cleanup: VoiceSessionCleanupMode) {
        isRecording = false
        isRecordingLocked = false
        voiceSendNeedsDeferred = false
        setRecordingFlowState(.idle)
        recordingStartTask?.cancel()
        recordingStartTask = nil
        audioRecorder.onLevelUpdate = nil
        switch cleanup {
        case .discardRecording:
            audioRecorder.cancelRecording()
        case .preserveRecordedDraft:
            // Keep already captured file URL for send transition, but always
            // return recorder FSM to idle to allow immediate next recording.
            audioRecorder.reset()
        }
        updateRecordingSendAccessibilityArea(isEnabled: false)
        setPreviewRowReplacement(false)
        micButton.resetState()
    }

    private func failRecordingStart(for button: RecordingMicButton) {
        let feedback = UINotificationFeedbackGenerator()
        feedback.notificationOccurred(.warning)
        setRecordingFlowState(.idle)
        button.resetState()
    }

    /// Dismiss recording chrome that was shown optimistically before async checks.
    private func rollbackRecordingChrome(for button: RecordingMicButton) {
        isRecording = false
        isRecordingLocked = false
        recordingOverlay?.dismiss()
        recordingOverlay = nil
        recordingLockView?.removeFromSuperview()
        recordingLockView = nil
        recordingPanel?.removeFromSuperview()
        recordingPanel = nil
        restoreComposerChrome()
        failRecordingStart(for: button)
    }

    private func updateRecordingSendAccessibilityArea(isEnabled: Bool) {
        if !isEnabled {
            recordingSendAccessibilityButton?.removeFromSuperview()
            recordingSendAccessibilityButton = nil
            return
        }
        guard let window else { return }
        let button: UIButton
        if let existing = recordingSendAccessibilityButton {
            button = existing
        } else {
            button = UIButton(type: .custom)
            button.backgroundColor = .clear
            button.isAccessibilityElement = true
            button.accessibilityLabel = "Stop recording"
            button.accessibilityHint = "Stops recording and opens voice preview."
            button.accessibilityIdentifier = "voice.recording.stopArea"
            button.addTarget(self, action: #selector(accessibilityStopRecordingTapped), for: .touchUpInside)
            recordingSendAccessibilityButton = button
            window.addSubview(button)
        }
        let micCenter = convert(micButton.center, to: window)
        let hitSize = VoiceRecordingParityConstants.sendAccessibilityHitSize
        button.frame = CGRect(
            x: micCenter.x - hitSize / 2.0,
            y: micCenter.y - hitSize / 2.0,
            width: hitSize,
            height: hitSize
        )
    }

    @objc private func accessibilityStopRecordingTapped() {
        finishRecordingAndSend(sourceView: micButton)
        micButton.resetState()
    }

    private func finalizeVoiceSession(cleanup: VoiceSessionCleanupMode, dismissStyle: VoiceSessionDismissStyle) {
        print("[VOICE_SEND] finalizeVoiceSession START — cleanup=\(cleanup) dismissStyle=\(dismissStyle)")
        print("[VOICE_SEND]   previewPanel=\(recordingPreviewPanel != nil) overlay=\(recordingOverlay != nil) panel=\(recordingPanel != nil) lockView=\(recordingLockView != nil)")
        print("[VOICE_SEND]   inputContainer.alpha=\(inputContainer.alpha) inputGlass.alpha=\(inputGlass.alpha) isPreview=\(isPreviewReplacingInputRow)")
        print("[VOICE_SEND]   subviews: \(subviews.map { type(of: $0) })")

        // Remove preview panel immediately BEFORE resetting state to avoid
        // race condition where input is restored while panel is still animating.
        recordingPreviewPanel?.stopPlayback()
        recordingPreviewPanel?.removeFromSuperview()
        recordingPreviewPanel = nil

        // Hide inputContainer before state reset so the glass background
        // doesn't flash empty (inputGlass.alpha goes 0→1 instantly in
        // setPreviewRowReplacement(false), but buttons are still alpha=0).
        // restoreComposerChrome() will animate everything in together.
        inputContainer.alpha = 0

        resetVoiceSessionState(cleanup: cleanup)
        print("[VOICE_SEND]   after resetState — inputContainer.alpha=\(inputContainer.alpha) inputGlass.alpha=\(inputGlass.alpha) isPreview=\(isPreviewReplacingInputRow)")

        switch dismissStyle {
        case .standard:
            recordingOverlay?.dismiss()
            // Remove recording panel immediately to avoid lingering timer bar
            // during composer chrome restoration.
            recordingPanel?.removeFromSuperview()
            recordingPanel = nil
        case .cancel:
            recordingOverlay?.dismissCancel()
            recordingPanel?.animateOutCancel { [weak self] in
                self?.recordingPanel = nil
            }
        }
        recordingOverlay = nil

        recordingLockView?.dismiss()
        recordingLockView = nil

        restoreComposerChrome()

        // For cancel: play bin animation inside attach button, then restore icon
        // Slide-to-cancel (discardRecording) = red bin, Preview delete (preserveRecordedDraft) = white bin
        if dismissStyle == .cancel {
            let useRedBin = cleanup == .discardRecording
            playBinAnimationInAttachButton(useRedBin: useRedBin)
        }
    }

    private func playBinAnimationInAttachButton(useRedBin: Bool = true) {
        // Hide paperclip icon, play bin Lottie inside attach button, then restore
        attachIconLayer?.opacity = 0

        // Slide-to-cancel: BinRed (red animation, no tint)
        // Preview delete: BinBlue with panelControlColor tint (white in dark theme)
        let assetName = useRedBin ? VoiceRecordingLottieAsset.binRed.rawValue : VoiceRecordingLottieAsset.binBlue.rawValue
        guard let animation = LottieAnimation.named(assetName) else {
            // No Lottie asset — just fade icon back
            CATransaction.begin()
            CATransaction.setAnimationDuration(0.25)
            attachIconLayer?.opacity = 1
            CATransaction.commit()
            return
        }

        let config = LottieConfiguration(renderingEngine: .mainThread)
        let binView = LottieAnimationView(animation: animation, configuration: config)
        binView.frame = attachButton.bounds
        binView.contentMode = .scaleAspectFit
        binView.backgroundBehavior = .pauseAndRestore
        binView.loopMode = .playOnce

        // Apply theme tint only for BinBlue (preview delete — white in dark, black in light)
        // BinRed (slide-to-cancel) uses its original red color, no tint needed.
        if !useRedBin {
            var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
            UIColor.label.getRed(&r, green: &g, blue: &b, alpha: &a)
            binView.setValueProvider(
                ColorValueProvider(LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))),
                keypath: AnimationKeypath(keypath: "**.Color")
            )
        }

        attachButton.addSubview(binView)

        binView.play { [weak self] _ in
            binView.removeFromSuperview()
            // Fade paperclip icon back in
            let fadeIn = CABasicAnimation(keyPath: "opacity")
            fadeIn.fromValue = 0
            fadeIn.toValue = 1
            fadeIn.duration = 0.2
            self?.attachIconLayer?.add(fadeIn, forKey: "fadeIn")
            self?.attachIconLayer?.opacity = 1
        }
    }

    private func clearLastRecordedDraftFile() {
        if let url = lastRecordedURL {
            try? FileManager.default.removeItem(at: url)
        }
        lastRecordedURL = nil
        lastRecordedDuration = 0
        lastRecordedWaveform = []
        lastVoiceSendTransitionSource = nil
    }

    private func cancelRecordingWithDismissAnimation() {
        clearLastRecordedDraftFile()
        finalizeVoiceSession(cleanup: .discardRecording, dismissStyle: .cancel)
    }

    // MARK: - Swipe-Back Recording Handling

    /// Pauses recording when interactive swipe-back begins.
    func pauseRecordingForSwipeBack() {
        guard isRecording else { return }
        audioRecorder.onLevelUpdate = nil
        audioRecorder.pauseRecordingForPreview()
    }

    /// Resumes recording when swipe-back is cancelled (user returned to chat).
    func resumeRecordingAfterSwipeBack() {
        guard recordingFlowState == .recordingUnlocked || recordingFlowState == .recordingLocked else { return }
        audioRecorder.resumeRecording()
        configureRecorderLevelUpdates()
    }

    /// Called when the view controller is being popped (swipe-back completed or programmatic).
    /// Immediately stops recording and cleans up UI without animations.
    func cancelRecordingForNavigation() {
        guard recordingFlowState != .idle else { return }
        clearLastRecordedDraftFile()
        recordingPreviewPanel?.stopPlayback()
        recordingPreviewPanel?.removeFromSuperview()
        recordingPreviewPanel = nil
        recordingOverlay?.dismiss()
        recordingOverlay = nil
        recordingPanel?.removeFromSuperview()
        recordingPanel = nil
        recordingLockView?.dismiss()
        recordingLockView = nil
        resetVoiceSessionState(cleanup: .discardRecording)
        inputContainer.alpha = 1
        attachButton.alpha = 1
        micButton.alpha = isSendVisible ? 0 : 1
        micGlass.alpha = isSendVisible ? 0 : 1
        micIconView?.alpha = isSendVisible ? 0 : 1
        delegate?.composerDidCancelRecording(self)
    }

    private func dismissOverlayAndRestore(skipAudioCleanup: Bool = false) {
        let cleanupMode: VoiceSessionCleanupMode = skipAudioCleanup ? .preserveRecordedDraft : .discardRecording
        if cleanupMode == .discardRecording {
            clearLastRecordedDraftFile()
        }
        finalizeVoiceSession(cleanup: cleanupMode, dismissStyle: .standard)
    }

    private func captureVoiceSendTransition(from sourceView: UIView?) -> VoiceSendTransitionSource? {
        guard let sourceView, let window else { return nil }
        guard let snapshot = sourceView.snapshotView(afterScreenUpdates: true) else { return nil }
        let frame = sourceView.convert(sourceView.bounds, to: window)
        snapshot.frame = frame
        snapshot.layer.cornerRadius = sourceView.layer.cornerRadius
        snapshot.layer.cornerCurve = .continuous
        snapshot.clipsToBounds = true
        return VoiceSendTransitionSource(
            snapshotView: snapshot,
            sourceFrameInWindow: frame,
            cornerRadius: sourceView.layer.cornerRadius
        )
    }

    private func resumeRecordingFromPreview() {
        let trimRange = recordingPreviewPanel?.selectedTrimRange
        // Remove preview panel immediately before layout changes
        recordingPreviewPanel?.stopPlayback()
        recordingPreviewPanel?.removeFromSuperview()
        recordingPreviewPanel = nil
        setPreviewRowReplacement(false)
        micButton.resetState()
        guard audioRecorder.resumeRecording(trimRange: trimRange) else {
            dismissOverlayAndRestore()
            return
        }
        isRecording = true
        isRecordingLocked = true
        setRecordingFlowState(.recordingLocked)
        presentRecordingChrome(locked: true, animatePanel: false)
        configureRecorderLevelUpdates()
        updateRecordingSendAccessibilityArea(isEnabled: true)
    }

    private func clampTrimRange(_ trimRange: ClosedRange<TimeInterval>, duration: TimeInterval) -> ClosedRange<TimeInterval> {
        VoiceRecordingParityMath.clampTrimRange(trimRange, duration: duration)
    }

    private func trimWaveform(
        _ waveform: [Float],
        totalDuration: TimeInterval,
        trimRange: ClosedRange<TimeInterval>
    ) -> [Float] {
        guard let sliceRange = VoiceRecordingParityMath.waveformSliceRange(
            sampleCount: waveform.count,
            totalDuration: totalDuration,
            trimRange: trimRange
        ) else {
            return waveform
        }
        return Array(waveform[sliceRange])
    }

    private func exportTrimmedAudio(url: URL, trimRange: ClosedRange<TimeInterval>) async -> URL? {
        let asset = AVURLAsset(url: url)
        guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else {
            return nil
        }
        final class ExportSessionBox: @unchecked Sendable {
            let session: AVAssetExportSession
            init(_ session: AVAssetExportSession) {
                self.session = session
            }
        }
        let box = ExportSessionBox(export)
        let outputURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("rosetta_voice_trim_\(UUID().uuidString).m4a")
        try? FileManager.default.removeItem(at: outputURL)

        box.session.outputURL = outputURL
        box.session.outputFileType = .m4a
        box.session.timeRange = CMTimeRange(
            start: CMTime(seconds: trimRange.lowerBound, preferredTimescale: 600),
            end: CMTime(seconds: trimRange.upperBound, preferredTimescale: 600)
        )

        return await withCheckedContinuation { continuation in
            box.session.exportAsynchronously {
                if box.session.status == .completed {
                    continuation.resume(returning: outputURL)
                } else {
                    continuation.resume(returning: nil)
                }
            }
        }
    }

    private func finalizePreviewSend(trimRange: ClosedRange<TimeInterval>, sourceView: UIView?) {
        print("[VOICE_SEND] finalizePreviewSend START — trimRange=\(trimRange) flowState=\(recordingFlowState) isPreview=\(isPreviewReplacingInputRow)")
        Task { @MainActor [weak self] in
            guard let self else { return }

            audioRecorder.onFinished = { [weak self] url, duration, waveform in
                self?.lastRecordedURL = url
                self?.lastRecordedDuration = duration
                self?.lastRecordedWaveform = waveform
                print("[VOICE_SEND] preview onFinished — url=\(url.lastPathComponent) dur=\(duration)")
            }
            audioRecorder.stopRecording()

            guard let url = lastRecordedURL else {
                print("[VOICE_SEND] preview — NO URL, dismissing")
                dismissOverlayAndRestore(skipAudioCleanup: true)
                return
            }

            var finalURL = url
            var finalDuration = lastRecordedDuration
            var finalWaveform = lastRecordedWaveform

            let normalizedTrim = clampTrimRange(trimRange, duration: lastRecordedDuration)
            let shouldTrim =
                normalizedTrim.lowerBound > 0.01 ||
                normalizedTrim.upperBound < lastRecordedDuration - 0.01
            print("[VOICE_SEND] preview — shouldTrim=\(shouldTrim) normalizedTrim=\(normalizedTrim)")
            if shouldTrim,
               let trimmedURL = await exportTrimmedAudio(url: url, trimRange: normalizedTrim) {
                finalURL = trimmedURL
                finalDuration = normalizedTrim.upperBound - normalizedTrim.lowerBound
                finalWaveform = trimWaveform(
                    lastRecordedWaveform,
                    totalDuration: lastRecordedDuration,
                    trimRange: normalizedTrim
                )
                try? FileManager.default.removeItem(at: url)
            }

            guard !VoiceRecordingParityMath.shouldDiscard(duration: finalDuration) else {
                print("[VOICE_SEND] preview DISCARDED — too short \(finalDuration)")
                try? FileManager.default.removeItem(at: finalURL)
                dismissOverlayAndRestore()
                return
            }

            lastRecordedURL = finalURL
            lastRecordedDuration = finalDuration
            lastRecordedWaveform = finalWaveform
            // Skip transition animation — snapshot of entire preview panel (382×42pt)
            // appears on window after CDN upload, causing "ghost panel" over composer.
            lastVoiceSendTransitionSource = nil
            print("[VOICE_SEND] preview dismissOverlayAndRestore START")
            dismissOverlayAndRestore(skipAudioCleanup: true)
            print("[VOICE_SEND] preview dismissOverlayAndRestore DONE — calling delegate")
            delegate?.composerDidFinishRecording(self, sendImmediately: true)
            print("[VOICE_SEND] preview finalizePreviewSend DONE")
        }
    }
}

// MARK: - VoiceRecordingPanelDelegate

extension ComposerView: VoiceRecordingPanelDelegate {
    func recordingPanelDidTapCancel(_ panel: VoiceRecordingPanel) {
        cancelRecordingWithDismissAnimation()
        micButton.resetState()
        delegate?.composerDidCancelRecording(self)
    }
}

// MARK: - RecordingPreviewPanelDelegate

extension ComposerView: RecordingPreviewPanelDelegate {
    func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>) {
        print("[VOICE_SEND] previewPanelDidTapSend — trimRange=\(trimRange)")
        finalizePreviewSend(trimRange: trimRange, sourceView: panel)
    }

    func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) {
        audioRecorder.cancelRecording()
        clearLastRecordedDraftFile()
        finalizeVoiceSession(cleanup: .preserveRecordedDraft, dismissStyle: .cancel)
        delegate?.composerDidCancelRecording(self)
    }

    private static let trimWarningShownKey = "voice_trim_resume_warning_shown"

    func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>, isTrimmed: Bool) {
        let alreadyShown = UserDefaults.standard.bool(forKey: Self.trimWarningShownKey)
        if isTrimmed, !alreadyShown {
            let alert = UIAlertController(
                title: "Trim to selected range?",
                message: "Audio outside that range will be discarded, and recording will start immediately.",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
            alert.addAction(UIAlertAction(title: "Proceed", style: .default) { [weak self] _ in
                UserDefaults.standard.set(true, forKey: Self.trimWarningShownKey)
                self?.resumeRecordingFromPreview()
            })
            if let vc = presentingViewController() {
                vc.present(alert, animated: true)
            }
        } else {
            resumeRecordingFromPreview()
        }
    }

    private func presentingViewController() -> UIViewController? {
        var responder: UIResponder? = self
        while let next = responder?.next {
            if let vc = next as? UIViewController { return vc }
            responder = next
        }
        return nil
    }
}

// MARK: - Mention Table DataSource & Delegate

extension ComposerView: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        mentionCandidates.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: MentionCell.reuseID, for: indexPath) as! MentionCell
        let candidate = mentionCandidates[indexPath.row]
        let isLast = indexPath.row == mentionCandidates.count - 1
        cell.configure(candidate: candidate, showSeparator: !isLast)
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let candidate = mentionCandidates[indexPath.row]
        insertMention(username: candidate.username)
        hideMentionAutocomplete()
    }
}
