//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import SignalServiceKit

public protocol BodyRangesTextViewDelegate: UITextViewDelegate {
    func textViewDidBeginTypingMention(_ textView: BodyRangesTextView)
    func textViewDidEndTypingMention(_ textView: BodyRangesTextView)

    func textViewMentionPickerParentView(_ textView: BodyRangesTextView) -> UIView?
    func textViewMentionPickerReferenceView(_ textView: BodyRangesTextView) -> UIView?
    // It doesn't matter what this key is; but when it changes cached mention names will be discarded.
    // Typically, we want this to change in new thread contexts and such.
    func textViewMentionCacheInvalidationKey(_ textView: BodyRangesTextView) -> String
    func textViewMentionPickerPossibleAddresses(_ textView: BodyRangesTextView, tx: DBReadTransaction) -> [SignalServiceAddress]

    func textViewDisplayConfiguration(_ textView: BodyRangesTextView) -> HydratedMessageBody.DisplayConfiguration
    func mentionPickerStyle(_ textView: BodyRangesTextView) -> MentionPickerStyle
}

open class BodyRangesTextView: OWSTextView, EditableMessageBodyDelegate {

    public weak var mentionDelegate: BodyRangesTextViewDelegate? {
        didSet { updateMentionState() }
    }

    public override var delegate: UITextViewDelegate? {
        didSet {
            if let delegate = delegate {
                owsAssertDebug(delegate === self)
            }
        }
    }

    private let customLayoutManager: NSLayoutManager

    public init() {
        let editableBody = EditableMessageBodyTextStorage(db: DependenciesBridge.shared.db)
        self.editableBody = editableBody
        let container = NSTextContainer()
        let layoutManager = NSLayoutManager()
        self.customLayoutManager = layoutManager
        layoutManager.textStorage = editableBody
        layoutManager.addTextContainer(container)
        container.replaceLayoutManager(layoutManager)
        super.init(frame: .zero, textContainer: container)
        updateTextContainerInset()
        delegate = self
        editableBody.editableBodyDelegate = self
        textAlignment = .natural
    }

    public override var layoutManager: NSLayoutManager {
        return customLayoutManager
    }

    deinit {
        pickerView?.removeFromSuperview()
    }

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

    // MARK: -

    public func insertTypedMention(address: SignalServiceAddress) {
        guard case .typingMention(let range) = state else {
            return owsFailDebug("Can't finish typing when no mention in progress")
        }

        replaceCharacters(
            in: NSRange(
                location: range.location - Mention.prefix.count,
                length: range.length + Mention.prefix.count
            ),
            withMentionAddress: address
        )
    }

    public func replaceCharacters(
        in range: NSRange,
        withMentionAddress mentionAddress: SignalServiceAddress
    ) {
        guard let mentionDelegate = mentionDelegate else {
            return owsFailDebug("Can't replace characters without delegate")
        }
        guard let mentionAci = mentionAddress.aci else {
            return owsFailDebug("Can't insert a mention without an ACI")
        }

        let body = MessageBody(
            text: "@",
            ranges: MessageBodyRanges(mentions: [NSRange(location: 0, length: 1): mentionAci], styles: [])
        )
        let (hydrated, possibleAddresses) = DependenciesBridge.shared.db.read { tx in
            return (
                body.hydrating(mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: tx)),
                mentionDelegate.textViewMentionPickerPossibleAddresses(self, tx: tx)
            )
        }
        let hydratedPlaintext = hydrated.asPlaintext()

        if possibleAddresses.contains(mentionAddress) {
            editableBody.beginEditing()
            editableBody.replaceCharacters(in: range, withMentionAci: mentionAci, txProvider: DependenciesBridge.shared.db.readTxProvider)
            editableBody.endEditing()
        } else {
            // If we shouldn't resolve the mention, insert the plaintext representation.
            editableBody.beginEditing()
            editableBody.replaceCharacters(in: range, with: hydratedPlaintext, selectedRange: selectedRange)
            editableBody.endEditing()
        }
    }

    public var currentlyTypingMentionText: String? {
        guard case .typingMention(let range) = state else { return nil }
        guard (editableBody.hydratedPlaintext as NSString).length >= range.location + range.length else { return nil }
        guard range.length > 0 else { return "" }

        return (editableBody.hydratedPlaintext as NSString).substring(with: range)
    }

    public var defaultAttributes: [NSAttributedString.Key: Any] {
        var defaultAttributes = [NSAttributedString.Key: Any]()
        if let font = font { defaultAttributes[.font] = font }
        if let textColor = textColor { defaultAttributes[.foregroundColor] = textColor }
        return defaultAttributes
    }

    public var isEmpty: Bool {
        return editableBody.isEmpty
    }

    public var isWhitespaceOrEmpty: Bool {
        return editableBody.hydratedPlaintext.filterForDisplay.isEmpty
    }

    @available(*, unavailable)
    public override var text: String! {
        get {
            return textStorage.string
        }
        set {
            // Ignore setters; this is illegal
        }
    }

    @available(*, unavailable)
    public override var attributedText: NSAttributedString! {
        get {
            return textStorage.attributedString()
        }
        set {
            // Ignore setters; this is illegal
        }
    }

    public override var textColor: UIColor? {
        didSet {
            editableBody.didUpdateTheming()
        }
    }

    private let editableBody: EditableMessageBodyTextStorage

    public var messageBodyForSending: MessageBody {
        return editableBody.messageBody.filterStringForDisplay()
    }

    open func setMessageBody(_ messageBody: MessageBody?, txProvider: EditableMessageBodyTextStorage.ReadTxProvider) {
        editableBody.beginEditing()
        editableBody.setMessageBody(messageBody, txProvider: txProvider)
        editableBody.endEditing()
    }

    public func scrollToBottom() {
        let length = (editableBody.attributedString.string as NSString).length
        if length == 0 {
            return
        }
        scrollRangeToVisible(NSRange(location: length - 1, length: 1))
    }

    public func stopTypingMention() {
        state = .notTypingMention
    }

    public func reloadMentionState() {
        stopTypingMention()
        updateMentionState()
    }

    // MARK: - Mention State

    private enum State: Equatable {
        case typingMention(range: NSRange)
        case notTypingMention
    }
    private var state: State = .notTypingMention {
        didSet {
            switch state {
            case .notTypingMention:
                if oldValue != .notTypingMention { didEndTypingMention() }
            case .typingMention:
                if oldValue == .notTypingMention {
                    didBeginTypingMention()
                } else {
                    guard let currentlyTypingMentionText = currentlyTypingMentionText else {
                        return owsFailDebug("unexpectedly missing mention text while typing a mention")
                    }

                    didUpdateMentionText(currentlyTypingMentionText)
                }
            }
        }
    }

    private weak var pickerView: MentionPicker?
    private weak var pickerViewTopConstraint: NSLayoutConstraint?
    private func didBeginTypingMention() {
        guard let mentionDelegate = mentionDelegate else { return }

        mentionDelegate.textViewDidBeginTypingMention(self)

        pickerView?.removeFromSuperview()

        let mentionableAddresses = databaseStorage.read { tx in
            return mentionDelegate.textViewMentionPickerPossibleAddresses(self, tx: tx.asV2Read)
        }

        guard !mentionableAddresses.isEmpty else { return }

        guard let pickerReferenceView = mentionDelegate.textViewMentionPickerReferenceView(self),
            let pickerParentView = mentionDelegate.textViewMentionPickerParentView(self) else { return }

        let pickerView = MentionPicker(
            mentionableAddresses: mentionableAddresses,
            style: mentionDelegate.mentionPickerStyle(self)
        ) { [weak self] selectedAddress in
            self?.insertTypedMention(address: selectedAddress)
        }
        self.pickerView = pickerView

        pickerParentView.insertSubview(pickerView, belowSubview: pickerReferenceView)
        pickerView.autoPinWidthToSuperview()
        pickerView.autoPinEdge(toSuperviewSafeArea: .top, withInset: 0, relation: .greaterThanOrEqual)

        let animationTopConstraint = pickerView.autoPinEdge(.top, to: .top, of: pickerReferenceView)

        guard let currentlyTypingMentionText = currentlyTypingMentionText,
            pickerView.mentionTextChanged(currentlyTypingMentionText) else {
                pickerView.removeFromSuperview()
                self.pickerView = nil
                state = .notTypingMention
                return
        }

        ImpactHapticFeedback.impactOccurred(style: .light)

        pickerParentView.layoutIfNeeded()

        // Slide up.
        UIView.animate(withDuration: 0.25) {
            pickerView.alpha = 1
            animationTopConstraint.isActive = false
            self.pickerViewTopConstraint = pickerView.autoPinEdge(.bottom, to: .top, of: pickerReferenceView)
            pickerParentView.layoutIfNeeded()
        }
    }

    private func didEndTypingMention() {
        mentionDelegate?.textViewDidEndTypingMention(self)

        guard let pickerView = pickerView else { return }

        self.pickerView = nil

        let pickerViewTopConstraint = self.pickerViewTopConstraint
        self.pickerViewTopConstraint = nil

        guard let mentionDelegate = mentionDelegate,
            let pickerReferenceView = mentionDelegate.textViewMentionPickerReferenceView(self),
            let pickerParentView = mentionDelegate.textViewMentionPickerParentView(self) else {
                pickerView.removeFromSuperview()
                return
        }

        let style = mentionDelegate.mentionPickerStyle(self)

        // Slide down.
        UIView.animate(withDuration: 0.25, animations: {
            pickerViewTopConstraint?.isActive = false
            pickerView.autoPinEdge(.top, to: .top, of: pickerReferenceView)
            pickerParentView.layoutIfNeeded()

            switch style {
            case .composingAttachment:
                pickerView.alpha = 0
            case .groupReply, .`default`:
                break
            }
        }) { _ in
            pickerView.removeFromSuperview()
        }
    }

    private func didUpdateMentionText(_ text: String) {
        if let pickerView = pickerView, !pickerView.mentionTextChanged(text) {
            state = .notTypingMention
        }
    }

    private func shouldUpdateMentionText(in range: NSRange, changedText text: String) -> Bool {
        let mentionRanges = editableBody.mentionRanges

        if range.length > 0 {
            // Locate any mentions in the edited range.
            // TODO[TextFormatting]: update styles as needed
            for mentionRange in mentionRanges {
                // Mention ranges are ordered; once we are past the range
                // we are looking for no need to look more.
                if mentionRange.location > range.upperBound {
                    break
                }
            }
        } else if
            range.location > 0,
            mentionRanges.first(where: { mentionRange in
              mentionRange.upperBound == range.location
            }) != nil {
            // If there is a mention to the left, the typing attributes will
            // be the mention's attributes. We don't want that, so we need
            // to reset them here.
            typingAttributes = defaultAttributes
        }

        return true
    }

    private func updateMentionState() {
        // If we don't yet have a delegate, we can ignore any updates.
        // We'll check again when the delegate is assigned.
        guard mentionDelegate != nil else { return }

        let bodyLength = (editableBody.hydratedPlaintext as NSString).length
        guard
            selectedRange.length == 0,
            selectedRange.location > 0,
            bodyLength > 0,
            selectedRange.upperBound <= bodyLength
        else {
            state = .notTypingMention
            return
        }

        var location = selectedRange.location

        while location > 0 {
            let possiblePrefix = editableBody.hydratedPlaintext.substring(
                withRange: NSRange(location: location - Mention.prefix.count, length: Mention.prefix.count)
            )

            let mentionRanges = editableBody.mentionRanges

            // If the previous character is part of a mention, we're not typing a mention
            if mentionRanges.first(where: { $0.contains(location) }) != nil {
                state = .notTypingMention
                return
            }

            // If we find whitespace before the selected range, we're not typing a mention.
            // Mention typing breaks on whitespace.
            if possiblePrefix.unicodeScalars.allSatisfy({ NSCharacterSet.whitespacesAndNewlines.contains($0) }) {
                state = .notTypingMention
                return

            // If we find the mention prefix before the selected range, we may be typing a mention.
            } else if possiblePrefix == Mention.prefix {

                // If there's more text before the mention prefix, check if it's whitespace. Mentions
                // only start at the beginning of the string OR after a whitespace character.
                if location - Mention.prefix.count > 0 {
                    let characterPrecedingPrefix = editableBody.hydratedPlaintext.substring(
                        withRange: NSRange(location: location - Mention.prefix.count - 1, length: Mention.prefix.count)
                    )

                    // If it's not whitespace, keep looking back. Mention text can contain an "@" character,
                    // for example when trying to match a profile name that contains "@"
                    if !characterPrecedingPrefix.unicodeScalars.allSatisfy({ NSCharacterSet.whitespacesAndNewlines.contains($0) }) {
                        location -= 1
                        continue
                    }
                }

                state = .typingMention(
                    range: NSRange(location: location, length: selectedRange.location - location)
                )
                return
            } else {
                location -= 1
            }
        }

        // We checked everything, so we're not typing
        state = .notTypingMention
    }

    // MARK: - Text Formatting

    // MARK: Menu items

    private let cutUIMenuAction = #selector(cut)
    private let copyUIMenuAction = #selector(UIResponderStandardEditActions.copy(_:))
    private let pasteUIMenuAction = #selector(UIResponderStandardEditActions.paste(_:))

    private let uiMenuPromptReplaceAction = Selector(("_promptForReplace:"))
    private let uiMenuReplaceAction = Selector(("replace:"))
    private let customUIMenuPromptReplaceAction = #selector(customUIMenuPromptReplace)
    @objc
    func customUIMenuPromptReplace(_ sender: Any?) { super.perform(uiMenuPromptReplaceAction, with: sender) }

    private let uiMenuTranslateAction = Selector(("_translate:"))
    private let customUIMenuTranslateAction = #selector(customUIMenuTranslate)
    @objc
    func customUIMenuTranslate(_ sender: Any?) { super.perform(uiMenuTranslateAction, with: sender) }

    private let uiMenuLookUpAction = Selector(("_define:"))
    private let customUIMenuLookUpAction = #selector(customUIMenuLookUp)
    @objc
    func customUIMenuLookUp(_ sender: Any?) { super.perform(uiMenuLookUpAction, with: sender) }

    private let uiMenuShareAction = Selector(("_share:"))
    private let customUIMenuShareAction = #selector(customUIMenuShare)
    @objc
    func customUIMenuShare(_ sender: Any?) { super.perform(uiMenuShareAction, with: sender) }

    open override func buildMenu(with builder: UIMenuBuilder) {
        if builder.menu(for: .lookup) != nil, selectedRange.length > 0 {
            // The lookup action is special; for whatever reason it doesn't go
            // through `canPerformAction` at all, so we have to disable it here
            // or it will appear before our custom format options.
            builder.remove(menu: .lookup)
        }
        super.buildMenu(with: builder)
    }

    public func disallowsAnyPasteAction() -> Bool {
        return isShowingFormatMenu
    }

    open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        // We only mess with actions when there's a selection.
        guard selectedRange.length > 0 else {
            return super.canPerformAction(action, withSender: sender)
        }
        // Let our custom style actions through.
        if action == #selector(didSelectTextFormattingSubmenu) {
            return selectedRange.length > 0
        }
        if MessageBodyRanges.SingleStyle.allCases.lazy
            .map({ (style: MessageBodyRanges.SingleStyle) -> Selector in
                return self.uiMenuItemSelector(for: style)
            })
            .contains(action) {
            return isShowingFormatMenu
        }
        if action == #selector(didSelectClearStyles) {
            guard isShowingFormatMenu, selectedRange.length > 0 else {
                return false
            }
            return editableBody.hasFormatting(in: selectedRange)
        }

        switch action {
        // Cut, copy, paste are let through as they are first in the list.
        case cutUIMenuAction, copyUIMenuAction, pasteUIMenuAction:
            guard !isShowingFormatMenu else {
                return false
            }
            return super.canPerformAction(action, withSender: sender)

        // We want these actions to appear, but _after_ format. To do that, we disable
        // the system's action and use custom ones of our own that forward to the same selector.
        case customUIMenuPromptReplaceAction:
            return super.canPerformAction(uiMenuPromptReplaceAction, withSender: sender)
        case customUIMenuLookUpAction:
            return super.canPerformAction(uiMenuLookUpAction, withSender: sender)
        case customUIMenuShareAction:
            return super.canPerformAction(uiMenuShareAction, withSender: sender)
        case customUIMenuTranslateAction:
            return true

        // The second stage of replace (picking the thing to replace with) is allowed.
        case uiMenuReplaceAction:
            return super.canPerformAction(action, withSender: sender)

        // All other actions are disallowed.
        default:
            return false
        }
    }

    // When the user selects text, we show a "format" option in the menu. Tapping
    // that sets this to true, reloading the menu with styles (bold, italic, etc) and
    // omitting all other options.
    // We have to be careful to set this to false again once the menu is dismissed by
    // any means, so that when it shows again we see cut/copy/paste and "format" again.
    // There is no one callback for this dismissal, so we have to set it to false all over:
    // resign first responder, selection changed, text changed, style option tapped, etc.
    private var isShowingFormatMenu = false {
        didSet {
            if oldValue, !isShowingFormatMenu, UIMenuController.shared.isMenuVisible {
                UIMenuController.shared.hideMenu(from: self)
            }
        }
    }

    open override func resignFirstResponder() -> Bool {
        isShowingFormatMenu = false
        return super.resignFirstResponder()
    }

    fileprivate func updateUIMenuState() {
        if selectedRange.length > 0 {
            if isShowingFormatMenu {
                let orderedStyles: [MessageBodyRanges.SingleStyle] = [
                    .bold, .italic, .monospace, .strikethrough, .spoiler
                ]
                UIMenuController.shared.menuItems = orderedStyles.map { style in
                    return UIMenuItem(title: style.displayText, action: self.uiMenuItemSelector(for: style))
                } + [UIMenuItem(
                    title: OWSLocalizedString(
                        "TEXT_MENU_CLEAR_FORMATTING",
                        comment: "Option in selected text edit menu to clear all text formatting in the selected text range"
                    ),
                    action: #selector(didSelectClearStyles)
                )]
            } else {
                UIMenuController.shared.menuItems = [
                    // to get format to show up before system menu items, put our format
                    // first and then our own replacements for the system ones after.
                    UIMenuItem(
                        title: OWSLocalizedString(
                            "TEXT_MENU_FORMAT",
                            comment: "Option in selected text edit menu to view text formatting options"
                        ),
                        action: #selector(didSelectTextFormattingSubmenu)
                    ),
                    UIMenuItem(
                        title: OWSLocalizedString(
                            "TEXT_MENU_REPLACE",
                            comment: "Option in selected text edit menu to replace text with suggestions"
                        ),
                        action: #selector(customUIMenuPromptReplace)
                    ),
                    UIMenuItem(
                        title: OWSLocalizedString(
                            "TEXT_MENU_LOOK_UP",
                            comment: "Option in selected text edit menu to look up word definitions"
                        ),
                        action: #selector(customUIMenuLookUp)
                    ),
                    UIMenuItem(
                        title: OWSLocalizedString(
                            "TEXT_MENU_TRANSLATE",
                            comment: "Option in selected text edit menu to translate the word"
                        ),
                        action: #selector(customUIMenuTranslate)
                    ),
                    UIMenuItem(
                        title: OWSLocalizedString(
                            "TEXT_MENU_SHARE",
                            comment: "Option in selected text edit menu to share selected text"
                        ),
                        action: #selector(customUIMenuShare)
                    )
                ]
            }
        } else {
            UIMenuController.shared.menuItems = nil
        }
        UIMenuController.shared.update()
    }

    @objc
    private func didSelectTextFormattingSubmenu(_ sender: UIMenu) {
        isShowingFormatMenu = true
        updateUIMenuState()
        // No way to set a sub-menu in iOS 13. Have to wait for it to dismiss
        // and then show it again in the next runloop.
        DispatchQueue.main.async { [self] in
            guard let selectedTextRange, isShowingFormatMenu else {
                return
            }
            let selectionRects = selectionRects(for: selectedTextRange)
            var completeRect = CGRect.null
            for rect in selectionRects {
                if completeRect.isNull {
                    completeRect = rect.rect
                } else {
                    completeRect = rect.rect.union(completeRect)
                }
            }
            UIMenuController.shared.showMenu(from: self, rect: completeRect)
        }
    }

    private func uiMenuItemSelector(for style: MessageBodyRanges.SingleStyle) -> Selector {
        switch style {
        case .bold: return #selector(didSelectBold)
        case .italic: return #selector(didSelectItalic)
        case .spoiler: return #selector(didSelectSpoiler)
        case .strikethrough: return #selector(didSelectStrikethrough)
        case .monospace: return #selector(didSelectMonospace)
        }
    }

    @objc
    func didSelectBold() { didSelectStyle(.bold) }
    @objc
    func didSelectItalic() { didSelectStyle(.italic) }
    @objc
    func didSelectSpoiler() { didSelectStyle(.spoiler) }
    @objc
    func didSelectStrikethrough() { didSelectStyle(.strikethrough) }
    @objc
    func didSelectMonospace() { didSelectStyle(.monospace) }

    private func didSelectStyle(_ style: MessageBodyRanges.SingleStyle) {
        Logger.info("Applying style: \(style)")
        isShowingFormatMenu = false
        guard selectedRange.length > 0 else {
            return
        }
        editableBody.beginEditing()
        editableBody.toggleStyle(style, in: selectedRange)
        editableBody.endEditing()
        textViewDidChange(self)
    }

    @objc
    private func didSelectClearStyles() {
        Logger.info("Clearing styles")
        isShowingFormatMenu = false
        guard selectedRange.length > 0 else {
            return
        }
        editableBody.beginEditing()
        editableBody.clearFormatting(in: selectedRange)
        editableBody.endEditing()
        textViewDidChange(self)
    }

    // MARK: - Text Container Insets

    open var defaultTextContainerInset: UIEdgeInsets {
        UIEdgeInsets(hMargin: 7, vMargin: 7 - .hairlineWidth)
    }

    public func updateTextContainerInset() {
        var newTextContainerInset = defaultTextContainerInset

        let currentFont = font ?? UIFont.dynamicTypeBody
        let systemDefaultFont = UIFont.preferredFont(
            forTextStyle: .body,
            compatibleWith: .init(preferredContentSizeCategory: .large)
        )
        guard systemDefaultFont.pointSize > currentFont.pointSize else {
            textContainerInset = newTextContainerInset
            return
        }

        // Increase top and bottom insets so that textView has the same one-line height
        // for any content size category smaller than the default (Large).
        // Simply fixing textView at a minimum height doesn't work well because
        // smaller text will be top-aligned (and we want center).
        let insetFontAdjustment = (systemDefaultFont.ascender - systemDefaultFont.descender) - (currentFont.ascender - currentFont.descender)
        newTextContainerInset.top += insetFontAdjustment * 0.5
        newTextContainerInset.bottom = newTextContainerInset.top - 1
        textContainerInset = newTextContainerInset
    }

    // MARK: - EditableMessageBodyDelegate

    public func editableMessageBodyDidRequestNewSelectedRange(_ newSelectedRange: NSRange) {
        self.selectedRange = newSelectedRange
    }

    public func editableMessageBodyHydrator(tx: DBReadTransaction) -> MentionHydrator {
        var possibleMentionAcis = Set<Aci>()
        mentionDelegate?.textViewMentionPickerPossibleAddresses(self, tx: tx).forEach {
            if let aci = $0.aci {
                possibleMentionAcis.insert(aci)
            }
        }
        let hydrator = ContactsMentionHydrator.mentionHydrator(transaction: tx)
        return { aci in
            guard possibleMentionAcis.contains(aci) else {
                return .preserveMention
            }
            return hydrator(aci)
        }
    }

    public func editableMessageBodyDisplayConfig() -> HydratedMessageBody.DisplayConfiguration {
        return mentionDelegate?.textViewDisplayConfiguration(self) ?? .composing(textViewColor: self.textColor)
    }

    public func isEditableMessageBodyDarkThemeEnabled() -> Bool {
        return Theme.isDarkThemeEnabled
    }

    public func editableMessageSelectedRange() -> NSRange {
        return selectedRange
    }

    public func mentionCacheInvalidationKey() -> String {
        return mentionDelegate?.textViewMentionCacheInvalidationKey(self) ?? UUID().uuidString
    }
}

// MARK: - Picker Keyboard Interaction

extension BodyRangesTextView {
    open override var keyCommands: [UIKeyCommand]? {
        guard pickerView != nil else { return nil }

        return [
            UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(upArrowPressed(_:))),
            UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(downArrowPressed(_:))),
            UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(returnPressed(_:))),
            UIKeyCommand(input: "\t", modifierFlags: [], action: #selector(tabPressed(_:)))
        ]
    }

    @objc
    func upArrowPressed(_ sender: UIKeyCommand) {
        guard let pickerView = pickerView else { return }
        pickerView.didTapUpArrow()
    }

    @objc
    func downArrowPressed(_ sender: UIKeyCommand) {
        guard let pickerView = pickerView else { return }
        pickerView.didTapDownArrow()
    }

    @objc
    func returnPressed(_ sender: UIKeyCommand) {
        guard let pickerView = pickerView else { return }
        pickerView.didTapReturn()
    }

    @objc
    func tabPressed(_ sender: UIKeyCommand) {
        guard let pickerView = pickerView else { return }
        pickerView.didTapTab()
    }
}

// MARK: - Cut/Copy/Paste

extension BodyRangesTextView {
    open override func cut(_ sender: Any?) {
        let selectedRange = self.selectedRange
        copy(sender)
        editableBody.beginEditing()
        editableBody.replaceCharacters(in: selectedRange, with: "", selectedRange: selectedRange)
        editableBody.endEditing()
        self.selectedRange = NSRange(location: selectedRange.location, length: 0)
    }

    public class func copyToPasteboard(_ text: CVTextValue) {
        let plaintext: String
        switch text {
        case .text(let text):
            plaintext = text
            UIPasteboard.general.setItems([], options: [:])
        case .attributedText(let text):
            plaintext = text.string
            UIPasteboard.general.setItems([], options: [:])
        case .messageBody(let messageBody):
            copyToPasteboard(messageBody.asMessageBodyForForwarding())
            return
        }

        guard let plaintextData = plaintext.data(using: .utf8) else {
            return owsFailDebug("Failed to calculate plaintextData on copy")
        }

        UIPasteboard.general.addItems([["public.utf8-plain-text": plaintextData]])
    }

    private class func copyToPasteboard(_ messageBody: MessageBody) {
        if messageBody.hasRanges, let encodedMessageBody = try? NSKeyedArchiver.archivedData(withRootObject: messageBody, requiringSecureCoding: true) {
            UIPasteboard.general.setItems([[Self.pasteboardType: encodedMessageBody]], options: [.localOnly: true])
        } else {
            UIPasteboard.general.setItems([], options: [:])
        }

        guard let plaintextData = messageBody.text.data(using: .utf8) else {
            return owsFailDebug("Failed to calculate plaintextData on copy")
        }

        UIPasteboard.general.addItems([["public.utf8-plain-text": plaintextData]])
    }

    public static var pasteboardType: String { SignalAttachment.bodyRangesPasteboardType }

    open override func copy(_ sender: Any?) {
        let messageBody: MessageBody
        if selectedRange.length > 0 {
            messageBody = editableBody.messageBody(forHydratedTextSubrange: selectedRange)
        } else {
            messageBody = editableBody.messageBody
        }
        Self.copyToPasteboard(messageBody)
    }

    open override func paste(_ sender: Any?) {
        if let encodedMessageBody = UIPasteboard.general.data(forPasteboardType: Self.pasteboardType),
            var messageBody = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MessageBody.self, from: encodedMessageBody) {
            editableBody.beginEditing()
            DependenciesBridge.shared.db.read { tx in
                if let possibleAddresses = mentionDelegate?.textViewMentionPickerPossibleAddresses(self, tx: tx) {
                    messageBody = messageBody.forPasting(intoContextWithPossibleAddresses: possibleAddresses, transaction: tx)
                }
                editableBody.replaceCharacters(in: selectedRange, withPastedMessageBody: messageBody, txProvider: { $0(tx) })
            }
            editableBody.endEditing()
        } else if let string = UIPasteboard.general.strings?.first {
            editableBody.beginEditing()
            editableBody.replaceCharacters(in: selectedRange, with: string, selectedRange: selectedRange)
            editableBody.endEditing()
            // Put the selection at the end of the new range.
            self.selectedRange = NSRange(location: selectedRange.location + (string as NSString).length, length: 0)
        }

        if !textStorage.isEmpty {
            // Pasting very long text generates an obscure UI error producing an UITextView where the lower
            // part contains invisible characters. The exact root of the issue is still unclear but the following
            // lines of code work as a workaround.
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { [weak self] in
                if let self = self {
                    let oldRange = self.selectedRange
                    self.selectedRange = NSRange.init(location: 0, length: 0)
                    // inserting blank text into the text storage will remove the invisible characters
                    self.textStorage.insert(NSAttributedString(string: ""), at: 0)
                    // setting the range (again) will ensure scrolling to the correct position
                    self.selectedRange = oldRange
                }
            }
        }
        self.textViewDidChange(self)
    }
}

// MARK: - UITextViewDelegate

extension BodyRangesTextView: UITextViewDelegate {
    open func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        guard shouldUpdateMentionText(in: range, changedText: text) else { return false }
        return mentionDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true
    }

    open func textViewDidChangeSelection(_ textView: UITextView) {
        mentionDelegate?.textViewDidChangeSelection?(textView)
        updateMentionState()
        isShowingFormatMenu = false
        updateUIMenuState()
    }

    open func textViewDidChange(_ textView: UITextView) {
        isShowingFormatMenu = false
        mentionDelegate?.textViewDidChange?(textView)
        if editableBody.hydratedPlaintext.isEmpty { updateMentionState() }
        self.textAlignment = editableBody.naturalTextAlignment
    }

    open func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
        return mentionDelegate?.textViewShouldBeginEditing?(textView) ?? true
    }

    open func textViewShouldEndEditing(_ textView: UITextView) -> Bool {
        isShowingFormatMenu = false
        return mentionDelegate?.textViewShouldEndEditing?(textView) ?? true
    }

    open func textViewDidBeginEditing(_ textView: UITextView) {
        mentionDelegate?.textViewDidBeginEditing?(textView)
    }

    open func textViewDidEndEditing(_ textView: UITextView) {
        mentionDelegate?.textViewDidEndEditing?(textView)
    }

    open func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        return mentionDelegate?.textView?(textView, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? true
    }

    open func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        return mentionDelegate?.textView?(textView, shouldInteractWith: textAttachment, in: characterRange, interaction: interaction) ?? true
    }
}

extension MessageBodyRanges.SingleStyle {

    var displayText: String {
        switch self {
        case .bold:
            return OWSLocalizedString(
                "TEXT_MENU_BOLD",
                comment: "Option in selected text edit menu to make text bold"
            )
        case .italic:
            return OWSLocalizedString(
                "TEXT_MENU_ITALIC",
                comment: "Option in selected text edit menu to make text italic"
            )
        case .spoiler:
            return OWSLocalizedString(
                "TEXT_MENU_SPOILER",
                comment: "Option in selected text edit menu to make text spoiler"
            )
        case .strikethrough:
            return OWSLocalizedString(
                "TEXT_MENU_STRIKETHROUGH",
                comment: "Option in selected text edit menu to make text strikethrough"
            )
        case .monospace:
            return OWSLocalizedString(
                "TEXT_MENU_MONOSPACE",
                comment: "Option in selected text edit menu to make text monospace"
            )
        }
    }
}
