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

import Foundation
import AVFoundation
import MediaPlayer
import Photos
import CoreServices
import SignalServiceKit

public protocol AttachmentApprovalViewControllerDelegate: AnyObject {

    func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController,
                            didApproveAttachments attachments: [SignalAttachment], messageBody: MessageBody?)

    func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController)

    func attachmentApproval(
        _ attachmentApproval: AttachmentApprovalViewController,
        didChangeMessageBody newMessageBody: MessageBody?
    )
    func attachmentApproval(
        _ attachmentApproval: AttachmentApprovalViewController,
        didChangeViewOnceState isViewOnce: Bool
    )

    func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment)

    func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController)
}

public protocol AttachmentApprovalViewControllerDataSource: AnyObject {

    var attachmentApprovalTextInputContextIdentifier: String? { get }

    var attachmentApprovalRecipientNames: [String] { get }

    func attachmentApprovalMentionableAddresses(tx: DBReadTransaction) -> [SignalServiceAddress]

    func attachmentApprovalMentionCacheInvalidationKey() -> String
}

// MARK: -

public struct AttachmentApprovalViewControllerOptions: OptionSet {
    public let rawValue: Int

    public init(rawValue: Int) {
        self.rawValue = rawValue
    }

    public static let canAddMore = AttachmentApprovalViewControllerOptions(rawValue: 1 << 0)
    public static let hasCancel = AttachmentApprovalViewControllerOptions(rawValue: 1 << 1)
    public static let canToggleViewOnce = AttachmentApprovalViewControllerOptions(rawValue: 1 << 2)
    /// Overrides canToggleViewOnce and ensures that option is never enabled.
    public static let disallowViewOnce = AttachmentApprovalViewControllerOptions(rawValue: 1 << 3)
    public static let canChangeQualityLevel = AttachmentApprovalViewControllerOptions(rawValue: 1 << 4)
    public static let isNotFinalScreen = AttachmentApprovalViewControllerOptions(rawValue: 1 << 5)
}

// MARK: -

public class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSNavigationChildController {

    // MARK: - Properties

    private let receivedOptions: AttachmentApprovalViewControllerOptions

    private var options: AttachmentApprovalViewControllerOptions {
        var options = receivedOptions

        if
            attachmentApprovalItemCollection.attachmentApprovalItems.count == 1,
            let firstItem = attachmentApprovalItemCollection.attachmentApprovalItems.first,
            firstItem.attachment.isValidImage || firstItem.attachment.isValidVideo,
            !receivedOptions.contains(.disallowViewOnce)
        {
            options.insert(.canToggleViewOnce)
        }

        if
            ImageQualityLevel.maximumForCurrentAppContext == .high,
            attachmentApprovalItemCollection.attachmentApprovalItems.contains(where: { $0.attachment.isValidImage }) {
            options.insert(.canChangeQualityLevel)
        }

        return options
    }

    var isAddMoreVisible: Bool {
        return options.contains(.canAddMore) && !isViewOnceEnabled
    }

    var isViewOnceEnabled = false {
        didSet {
            approvalDelegate?.attachmentApproval(self, didChangeViewOnceState: isViewOnceEnabled)
        }
    }

    lazy var outputQualityLevel: ImageQualityLevel = databaseStorage.read { .resolvedQuality(tx: $0) }

    public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
    public weak var approvalDataSource: AttachmentApprovalViewControllerDataSource?

    public weak var stickerSheetDelegate: StickerPickerSheetDelegate?

    // MARK: - Initializers

    @available(*, unavailable, message: "use attachment: constructor instead.")
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    let kSpacingBetweenItems: CGFloat = 20

    private var observerToken: NSObjectProtocol?

    private var observingKeyboardNotifications = false
    private var keyboardHeight: CGFloat = 0

    public init(options: AttachmentApprovalViewControllerOptions,
                attachmentApprovalItems: [AttachmentApprovalItem]) {
        assert(attachmentApprovalItems.count > 0)

        self.receivedOptions = options

        let pageOptions: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems]
        super.init(transitionStyle: .scroll,
                   navigationOrientation: .horizontal,
                   options: pageOptions)

        let isAddMoreVisibleBlock = { [weak self] in
            return self?.isAddMoreVisible ?? false
        }
        self.attachmentApprovalItemCollection = AttachmentApprovalItemCollection(attachmentApprovalItems: attachmentApprovalItems, isAddMoreVisible: isAddMoreVisibleBlock)
        self.dataSource = self
        self.delegate = self

        // This fixes an issue with keyboard flashing white while being dismissed.
        overrideUserInterfaceStyle = .dark

        observerToken = NotificationCenter.default.addObserver(forName: .OWSApplicationDidBecomeActive, object: nil, queue: .main) { [weak self] _ in
            guard let self = self else { return }
            self.updateContents(animated: false)
        }
    }

    deinit {
        if let observerToken = observerToken {
            NotificationCenter.default.removeObserver(observerToken)
        }
    }

    public class func wrappedInNavController(
        attachments: [SignalAttachment],
        initialMessageBody: MessageBody?,
        approvalDelegate: AttachmentApprovalViewControllerDelegate,
        approvalDataSource: AttachmentApprovalViewControllerDataSource,
        stickerSheetDelegate: StickerPickerSheetDelegate?
    ) -> OWSNavigationController {

        let attachmentApprovalItems = attachments.map { AttachmentApprovalItem(attachment: $0, canSave: false) }
        let vc = AttachmentApprovalViewController(options: [.hasCancel], attachmentApprovalItems: attachmentApprovalItems)
        vc.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)
        vc.approvalDelegate = approvalDelegate
        vc.approvalDataSource = approvalDataSource
        vc.stickerSheetDelegate = stickerSheetDelegate
        let navController = OWSNavigationController(rootViewController: vc)
        navController.setNavigationBarHidden(true, animated: false)
        return navController
    }

    // MARK: - Subviews

    var galleryRailView: GalleryRailView {
        return bottomToolView.galleryRailView
    }

    var attachmentTextToolbar: AttachmentTextToolbar {
        return bottomToolView.attachmentTextToolbar
    }

    private lazy var topBar = AttachmentApprovalTopBar(options: options)

    private let bottomToolView = AttachmentApprovalToolbar()
    private var bottomToolViewBottomConstraint: NSLayoutConstraint?

    private lazy var inputAccessoryPlaceholder: InputAccessoryViewPlaceholder = {
        let placeholder = InputAccessoryViewPlaceholder()
        placeholder.delegate = self
        placeholder.referenceView = view
        return placeholder
    }()

    lazy var contentDimmerView: UIView = {
        let dimmerView = UIView()
        dimmerView.backgroundColor = .ows_blackAlpha40
        return dimmerView
    }()

    // MARK: - View Lifecycle

    public override var prefersStatusBarHidden: Bool {
        !UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && !DependenciesBridge.shared.currentCallProvider.hasCurrentCall
    }

    public override var preferredStatusBarStyle: UIStatusBarStyle {
        .lightContent
    }

    public var prefersNavigationBarHidden: Bool {
        return true
    }

    public override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .black

        // avoid an unpleasant "bounce" which doesn't make sense in the context of a single item.
        pagerScrollView?.isScrollEnabled = attachmentApprovalItems.count > 1

        // Bottom Toolbar
        galleryRailView.delegate = self

        // Navigation
        navigationItem.title = nil

        guard let firstItem = attachmentApprovalItems.first else {
            owsFailDebug("firstItem was unexpectedly nil")
            return
        }

        setCurrentItem(firstItem, direction: .forward, animated: false)

        // Top Bar
        topBar.cancelButton.addTarget(self, action: #selector(cancelPressed), for: .touchUpInside)
        topBar.backButton.addTarget(self, action: #selector(navigateBackPressed), for: .touchUpInside)

        let topBarSize = topBar.systemLayoutSizeFitting(view.bounds.size, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
        topBar.frame = CGRect(x: 0, y: view.layoutMargins.top, width: view.width, height: topBarSize.height)
        UIView.performWithoutAnimation {
            topBar.setNeedsLayout()
            topBar.layoutIfNeeded()
        }
        topBar.install(in: view)

        // Bottom Bar
        bottomToolView.attachmentTextToolbarDelegate = self
        attachmentTextToolbar.mentionTextViewDelegate = self

        bottomToolView.buttonAddMedia.addTarget(self, action: #selector(didTapAddMedia), for: .touchUpInside)
        bottomToolView.buttonViewOnce.addTarget(self, action: #selector(didToggleViewOnce), for: .touchUpInside)
        bottomToolView.buttonSend.addTarget(self, action: #selector(didTapSend), for: .touchUpInside)
        bottomToolView.buttonMediaQuality.addTarget(self, action: #selector(didTapMediaQuality), for: .touchUpInside)
        bottomToolView.buttonSaveMedia.addTarget(self, action: #selector(didTapSave), for: .touchUpInside)
        bottomToolView.buttonPenTool.addTarget(self, action: #selector(didTapPenTool), for: .touchUpInside)
        bottomToolView.buttonCropTool.addTarget(self, action: #selector(didTapCropTool), for: .touchUpInside)

        let bottomToolViewWidth = view.bounds.width
        let bottomToolViewHeight = bottomToolView.systemLayoutSizeFitting(view.bounds.size, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel).height
        bottomToolView.frame = CGRect(x: 0, y: view.bounds.maxY - bottomToolViewHeight, width: bottomToolViewWidth, height: bottomToolViewHeight)
        UIView.performWithoutAnimation {
            bottomToolView.setNeedsLayout()
            bottomToolView.layoutIfNeeded()
        }
        view.addSubview(bottomToolView)
        bottomToolView.autoPinWidthToSuperview()
        bottomToolViewBottomConstraint = bottomToolView.autoPinEdge(toSuperviewEdge: .bottom)

        OWSTableViewController2.removeBackButtonText(viewController: self)
    }

    public override func viewWillAppear(_ animated: Bool) {
        Logger.debug("")
        super.viewWillAppear(animated)

        UIViewController.attemptRotationToDeviceOrientation()

        topBar.update(withRecipientNames: approvalDataSource?.attachmentApprovalRecipientNames ?? [])

        updateContents(animated: false)

        if let currentPageViewController {
            updateContentLayoutMargins(for: currentPageViewController)
        }
    }

    public override func viewDidAppear(_ animated: Bool) {
        Logger.debug("")
        super.viewDidAppear(animated)
    }

    public override func viewWillDisappear(_ animated: Bool) {
        Logger.debug("")
        super.viewWillDisappear(animated)

        currentPageViewController?.prepareToMoveOffscreen()
        stopObservingKeyboardNotifications()
    }

    public override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        if let currentPageViewController {
            updateContentLayoutMargins(for: currentPageViewController)
        }
    }

    private func updateContentLayoutMargins(for viewController: AttachmentPrepViewController) {
        // The goal of all this layout logic is to lay out content in Review screen
        // the same way it will be laid out in Edit mode (drawing etc) so that activating editing tools
        // does not create any changes to media's size and position.
        // However AttachmentPrepViewController's view is always full screen and is managed by UIPageViewController,
        // which makes it not possible to constrain any of its subviews to the bottom toolbar.
        // The solution is to allow to set layout margins in AttachmentPrepViewController's view externally.

        var contentLayoutMargins: UIEdgeInsets = .zero
        // On devices with a screen notch at the top content is constrained to safe area inset so that status bar is visible.
        // On older devices content is pinned to the top of the screen and status bar is hidden to allow for more screen room.
        if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
            contentLayoutMargins.top = view.safeAreaInsets.top
        }

        if let mediaEditingToolbarHeight = viewController.mediaEditingToolbarHeight {
            // For images there is an "edit" mode and it is necessary to keep image center the same
            // when switching to/from "edit" mode. Therefore image is laid out usign bottom inset from "edit" mode screen.
            contentLayoutMargins.bottom = mediaEditingToolbarHeight
        } else {
            // bottomToolView contains UIStackView that doesn't always have a final frame at this point.
            bottomToolView.layoutIfNeeded()
            contentLayoutMargins.bottom = bottomToolView.opaqueAreaHeight

            // For videos there's thumbnail timelinebar embedded into the `bottomToolView`
            if let supplementaryView = viewController.toolbarSupplementaryView {
                contentLayoutMargins.bottom += supplementaryView.height
            }
        }
        contentLayoutMargins.bottom += view.safeAreaInsets.bottom

        viewController.contentLayoutMargins = contentLayoutMargins
    }

    private func updateContents(animated: Bool) {
        updateBottomToolView(animated: animated)
        updateMediaRail(animated: animated)
    }

    // MARK: - Input Accessory

    public override var canBecomeFirstResponder: Bool {
        return true
    }

    public override var inputAccessoryView: UIView? {
        return inputAccessoryPlaceholder
    }

    public override var textInputContextIdentifier: String? {
        return approvalDataSource?.attachmentApprovalTextInputContextIdentifier
    }

    private func updateControlsVisibility(animated: Bool, completion: ((Bool) -> Void)? = nil) {
        let alpha: CGFloat = shouldHideControls ? 0 : 1
        if animated {
            UIView.animate(withDuration: 0.15,
                           animations: {
                self.topBar.alpha = alpha
                self.bottomToolView.alpha = alpha
            }, completion: completion)
        } else {
            topBar.alpha = alpha
            bottomToolView.alpha = alpha
            if let completion = completion {
                completion(true)
            }
        }
    }

    private func updateBottomToolView(animated: Bool) {
        guard let currentPageViewController = currentPageViewController else { return }

        let isScreenNotFinal = options.contains(.isNotFinalScreen)
        let configuration = AttachmentApprovalToolbar.Configuration(
            isAddMoreVisible: isAddMoreVisible,
            isMediaStripVisible: attachmentApprovalItems.count > 1,
            isMediaHighQualityEnabled: outputQualityLevel == .high,
            isViewOnceOn: isViewOnceEnabled,
            canToggleViewOnce: options.contains(.canToggleViewOnce),
            canChangeMediaQuality: options.contains(.canChangeQualityLevel),
            canSaveMedia: currentPageViewController.canSaveMedia,
            doneButtonIcon: isScreenNotFinal ? .next : .send
        )
        bottomToolView.update(
            currentAttachmentItem: currentPageViewController.attachmentApprovalItem,
            configuration: configuration,
            animated: animated
        )
    }

    public var messageBodyForSending: MessageBody? {
        return attachmentTextToolbar.messageBodyForSending
    }

    public func setMessageBody(_ messageBody: MessageBody?, txProvider: EditableMessageBodyTextStorage.ReadTxProvider) {
        attachmentTextToolbar.setMessageBody(messageBody, txProvider: txProvider)
    }

    // MARK: - Control Visibility

    public var shouldHideControls: Bool {
        currentPageViewController?.shouldHideControls ?? false
    }

    // MARK: - View Helpers

    func remove(attachmentApprovalItem: AttachmentApprovalItem) {
        if attachmentApprovalItem == currentItem {
            if let nextItem = attachmentApprovalItemCollection.itemAfter(item: attachmentApprovalItem) {
                setCurrentItem(nextItem, direction: .forward, animated: true)
            } else if let prevItem = attachmentApprovalItemCollection.itemBefore(item: attachmentApprovalItem) {
                setCurrentItem(prevItem, direction: .reverse, animated: true)
            } else {
                owsFailBeta("removing last item shouldn't be possible because rail should not be visible")
                return
            }
        } else {
            owsFailBeta("Deleting item that is not current")
        }

        attachmentApprovalItemCollection.remove(item: attachmentApprovalItem)
        approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentApprovalItem.attachment)

        // If media rail needs to be hidden, do it immediately.
        if attachmentApprovalItems.count < 2 {
            updateMediaRail(animated: true)
        }
    }

    lazy var pagerScrollView: UIScrollView? = {
        // This is kind of a hack. Since we don't have first class access to the superview's `scrollView`
        // we traverse the view hierarchy until we find it.
        let pagerScrollView = view.subviews.first { $0 is UIScrollView } as? UIScrollView
        assert(pagerScrollView != nil)

        return pagerScrollView
    }()

    // MARK: - UIPageViewControllerDelegate

    public func pageViewController(_ pageViewController: UIPageViewController,
                                   willTransitionTo pendingViewControllers: [UIViewController]) {
        Logger.debug("")

        owsAssertDebug(pendingViewControllers.count == 1)

        // Pause video playback for current page
        currentPageViewController?.prepareToMoveOffscreen()

        // Update layout margins for view controllers to become visible.
        pendingViewControllers.forEach { viewController in
            guard let pendingPage = viewController as? AttachmentPrepViewController else {
                owsFailDebug("unexpected viewController: \(viewController)")
                return
            }
            updateContentLayoutMargins(for: pendingPage)
        }
    }

    public func pageViewController(_ pageViewController: UIPageViewController,
                                   didFinishAnimating finished: Bool,
                                   previousViewControllers: [UIViewController],
                                   transitionCompleted: Bool) {
        Logger.debug("")

        assert(previousViewControllers.count == 1)
        previousViewControllers.forEach { viewController in
            guard let previousPage = viewController as? AttachmentPrepViewController else {
                owsFailDebug("unexpected viewController: \(viewController)")
                return
            }

            if transitionCompleted {
                previousPage.zoomOut(animated: false)
            }
        }

        updateContents(animated: true)
        if let currentPageViewController = currentPageViewController {
            updateSupplementaryToolbarView(using: currentPageViewController, animated: true)
        }
    }

    // MARK: - UIPageViewControllerDataSource

    public func pageViewController(_ pageViewController: UIPageViewController,
                                   viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentViewController = viewController as? AttachmentPrepViewController else {
            owsFailDebug("unexpected viewController: \(viewController)")
            return nil
        }

        let currentItem = currentViewController.attachmentApprovalItem
        guard let previousItem = attachmentApprovalItem(before: currentItem) else {
            return nil
        }

        return buildPage(item: previousItem)
    }

    public func pageViewController(_ pageViewController: UIPageViewController,
                                   viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentViewController = viewController as? AttachmentPrepViewController else {
            owsFailDebug("unexpected viewController: \(viewController)")
            return nil
        }

        let currentItem = currentViewController.attachmentApprovalItem
        guard let nextItem = attachmentApprovalItem(after: currentItem) else {
            return nil
        }

        return buildPage(item: nextItem)
    }

    public var currentPageViewController: AttachmentPrepViewController? {
        return pageViewControllers.first
    }

    public var pageViewControllers: [AttachmentPrepViewController] {
        guard let viewControllers = super.viewControllers else {
            return []
        }
        return viewControllers.compactMap { $0 as? AttachmentPrepViewController }
    }

    var currentItem: AttachmentApprovalItem? {
        return currentPageViewController?.attachmentApprovalItem
    }

    private var cachedPages: [AttachmentApprovalItem: AttachmentPrepViewController] = [:]
    private func buildPage(item: AttachmentApprovalItem) -> AttachmentPrepViewController? {

        if let cachedPage = cachedPages[item] {
            Logger.debug("cache hit.")
            return cachedPage
        }

        Logger.debug("cache miss.")
        guard let viewController = AttachmentPrepViewController.viewController(
            for: item,
            stickerSheetDelegate: stickerSheetDelegate
        ) else {
            owsFailDebug("Failed to create AttachmentPrepViewController.")
            return nil
        }

        viewController.prepDelegate = self
        cachedPages[item] = viewController

        return viewController
    }

    private func setCurrentItem(_ item: AttachmentApprovalItem,
                                direction: UIPageViewController.NavigationDirection,
                                animated: Bool) {

        guard let page = buildPage(item: item) else {
            owsFailDebug("unexpectedly unable to build new page")
            return
        }

        let previousPage = currentPageViewController

        // Pause video playback for current page
        currentPageViewController?.prepareToMoveOffscreen()

        page.loadViewIfNeeded()
        updateContentLayoutMargins(for: page)

        Logger.debug("currentItem for attachment: \(item.attachment.debugDescription)")
        setViewControllers([page], direction: direction, animated: animated) { _ in
            previousPage?.zoomOut(animated: false)
        }

        // This does make animations smoother.
        DispatchQueue.main.async {
            self.updateContents(animated: animated)
            self.updateSupplementaryToolbarView(using: page, animated: animated)
        }
    }

    private func updateSupplementaryToolbarView(using viewController: AttachmentPrepViewController, animated: Bool) {
        if animated {
            UIView.animate(withDuration: 0.25) {
                self.bottomToolView.set(supplementaryView: viewController.toolbarSupplementaryView)
                self.bottomToolView.setNeedsLayout()
                self.bottomToolView.layoutIfNeeded()
            }
        } else {
            bottomToolView.set(supplementaryView: viewController.toolbarSupplementaryView)
        }
    }

    func updateMediaRail(animated: Bool = false) {
        guard isViewLoaded else { return }

        guard let currentItem else {
            owsFailDebug("currentItem was unexpectedly nil")
            return
        }

        let cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView = { [weak self] railItem in
            switch railItem {
            case is AddMoreRailItem:
                return AddMediaRailCellView()
            case is AttachmentApprovalItem:
                let cell = ApprovalRailCellView()
                cell.approvalRailCellDelegate = self
                return cell
            default:
                owsFailDebug("unexpected rail item type: \(railItem)")
                return GalleryRailCellView()
            }
        }

        galleryRailView.configureCellViews(itemProvider: attachmentApprovalItemCollection,
                                           focusedItem: currentItem,
                                           cellViewBuilder: cellViewBuilder,
                                           animated: animated)
    }

    var attachmentApprovalItemCollection: AttachmentApprovalItemCollection!

    var attachmentApprovalItems: [AttachmentApprovalItem] {
        return attachmentApprovalItemCollection.attachmentApprovalItems
    }

    func outputAttachmentsPromise() -> Promise<[SignalAttachment]> {
        var promises = [Promise<SignalAttachment>]()
        for attachmentApprovalItem in attachmentApprovalItems {
            let outputQualityLevel = self.outputQualityLevel
            promises.append(outputAttachmentPromise(for: attachmentApprovalItem).map(on: DispatchQueue.global()) { attachment in
                attachment.preparedForOutput(qualityLevel: outputQualityLevel)
            })
        }
        return Promise.when(fulfilled: promises)
    }

    // For any attachments edited with an editor, returns a
    // new SignalAttachment that reflects those changes.  Otherwise,
    // returns the original attachment.
    //
    // If any errors occurs in the export process, we fail over to
    // sending the original attachment.  This seems better than trying
    // to involve the user in resolving the issue.
    func outputAttachmentPromise(for attachmentApprovalItem: AttachmentApprovalItem) -> Promise<SignalAttachment> {
        if let imageEditorModel = attachmentApprovalItem.imageEditorModel, imageEditorModel.isDirty() {
            return editedAttachmentPromise(imageEditorModel: imageEditorModel,
                                           attachmentApprovalItem: attachmentApprovalItem)
        }
        if let videoEditorModel = attachmentApprovalItem.videoEditorModel, videoEditorModel.needsRender {
            return renderedAttachmentPromise(videoEditorModel: videoEditorModel,
                                             attachmentApprovalItem: attachmentApprovalItem)
        }
        // No editor applies. Use original, un-edited attachment.
        return Promise.value(attachmentApprovalItem.attachment)
    }

    // For any attachments edited with the image editor, returns a
    // new SignalAttachment that reflects those changes.
    //
    // If any errors occurs in the export process, we fail over to
    // sending the original attachment.  This seems better than trying
    // to involve the user in resolving the issue.
    func editedAttachmentPromise(imageEditorModel: ImageEditorModel,
                                 attachmentApprovalItem: AttachmentApprovalItem) -> Promise<SignalAttachment> {
        assert(imageEditorModel.isDirty())
        return DispatchQueue.main.async(.promise) { () -> UIImage in
            guard let dstImage = imageEditorModel.renderOutput() else {
                throw OWSAssertionError("Could not render for output.")
            }
            return dstImage
        }.map(on: DispatchQueue.global()) { (dstImage: UIImage) -> SignalAttachment in
            var dataUTI = kUTTypeImage as String
            guard let dstData: Data = {
                let isLossy: Bool = attachmentApprovalItem.attachment.mimeType.caseInsensitiveCompare(MimeType.imageJpeg.rawValue) == .orderedSame
                if isLossy {
                    dataUTI = kUTTypeJPEG as String
                    return dstImage.jpegData(compressionQuality: 0.9)
                } else {
                    dataUTI = kUTTypePNG as String
                    return dstImage.pngData()
                }
                }() else {
                    owsFailDebug("Could not export for output.")
                    return attachmentApprovalItem.attachment
            }
            guard let dataSource = DataSourceValue.dataSource(with: dstData, utiType: dataUTI) else {
                owsFailDebug("Could not prepare data source for output.")
                return attachmentApprovalItem.attachment
            }

            // Rewrite the filename's extension to reflect the output file format.
            var filename: String? = attachmentApprovalItem.attachment.sourceFilename
            if let sourceFilename = attachmentApprovalItem.attachment.sourceFilename {
                if let fileExtension: String = MimeTypeUtil.fileExtensionForUtiType(dataUTI) {
                    filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension)
                }
            }
            dataSource.sourceFilename = filename

            let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
            if let attachmentError = dstAttachment.error {
                owsFailDebug("Could not prepare attachment for output: \(attachmentError).")
                return attachmentApprovalItem.attachment
            }
            return dstAttachment
        }
    }

    // For any attachments edited with the video editor, returns a
    // new SignalAttachment that reflects those changes.
    //
    // If any errors occurs in the export process, we fail over to
    // sending the original attachment.  This seems better than trying
    // to involve the user in resolving the issue.
    func renderedAttachmentPromise(videoEditorModel: VideoEditorModel,
                                   attachmentApprovalItem: AttachmentApprovalItem) -> Promise<SignalAttachment> {
        assert(videoEditorModel.needsRender)
        return videoEditorModel.ensureCurrentRender().result.map(on: DispatchQueue.sharedUserInitiated) { result in
            let filePath = try result.consumeResultPath()
            guard let fileExtension = filePath.fileExtension else {
                throw OWSAssertionError("Missing fileExtension.")
            }
            guard let dataUTI = MimeTypeUtil.utiTypeForFileExtension(fileExtension) else {
                throw OWSAssertionError("Missing dataUTI.")
            }
            let dataSource = try DataSourcePath.dataSource(withFilePath: filePath, shouldDeleteOnDeallocation: true)
            // Rewrite the filename's extension to reflect the output file format.
            var filename: String? = attachmentApprovalItem.attachment.sourceFilename
            if let sourceFilename = attachmentApprovalItem.attachment.sourceFilename {
                filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension)
            }
            dataSource.sourceFilename = filename

            let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
            if let attachmentError = dstAttachment.error {
                throw OWSAssertionError("Could not prepare attachment for output: \(attachmentError).")
            }
            dstAttachment.isViewOnceAttachment = attachmentApprovalItem.attachment.isViewOnceAttachment
            return dstAttachment
        }
    }

    func attachmentApprovalItem(before currentItem: AttachmentApprovalItem) -> AttachmentApprovalItem? {
        guard let currentIndex = attachmentApprovalItems.firstIndex(of: currentItem) else {
            owsFailDebug("currentIndex was unexpectedly nil")
            return nil
        }

        let index: Int = attachmentApprovalItems.index(before: currentIndex)
        guard let previousItem = attachmentApprovalItems[safe: index] else {
            // already at first item
            return nil
        }

        return previousItem
    }

    func attachmentApprovalItem(after currentItem: AttachmentApprovalItem) -> AttachmentApprovalItem? {
        guard let currentIndex = attachmentApprovalItems.firstIndex(of: currentItem) else {
            owsFailDebug("currentIndex was unexpectedly nil")
            return nil
        }

        let index: Int = attachmentApprovalItems.index(after: currentIndex)
        guard let nextItem = attachmentApprovalItems[safe: index] else {
            // already at last item
            return nil
        }

        return nextItem
    }
}

// MARK: - Event Handlers

extension AttachmentApprovalViewController {

    @objc
    private func cancelPressed() {
        self.approvalDelegate?.attachmentApprovalDidCancel(self)
    }

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

    @objc
    private func didTapSave() {
        guard let currentItem = currentItem else { return }

        let errorText = OWSLocalizedString("ATTACHMENT_APPROVAL_FAILED_TO_SAVE",
                                           comment: "alert text when Signal was unable to save a copy of the attachment to the system photo library")
        do {
            let saveableAsset: SaveableAsset = try SaveableAsset(attachmentApprovalItem: currentItem)

            self.ows_askForMediaLibraryPermissions { isGranted in
                guard isGranted else {
                    return
                }

                PHPhotoLibrary.shared().performChanges({
                    switch saveableAsset {
                    case .image(let image):
                        PHAssetCreationRequest.creationRequestForAsset(from: image)
                    case .imageUrl(let imageUrl):
                        PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: imageUrl)
                    case .videoUrl(let videoUrl):
                        PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: videoUrl)
                    }
                }, completionHandler: { didSucceed, error in
                    DispatchQueue.main.async {
                        if didSucceed {
                            let toastController = ToastController(text: OWSLocalizedString("ATTACHMENT_APPROVAL_MEDIA_DID_SAVE",
                                                                                           comment: "toast alert shown after user taps the 'save' button"))
                            let inset = self.bottomToolView.height + 16
                            toastController.presentToastView(from: .bottom, of: self.view, inset: inset)
                        } else {
                            owsFailDebug("error: \(String(describing: error))")
                            OWSActionSheets.showErrorAlert(message: errorText)
                        }
                    }
                })
            }
        } catch {
            owsFailDebug("error: \(error)")
            OWSActionSheets.showErrorAlert(message: errorText)
        }
    }

    @objc
    private func didTapAddMedia() {
        approvalDelegate?.attachmentApprovalDidTapAddMore(self)
    }

    @objc
    private func didToggleViewOnce() {
        owsAssertDebug(options.contains(.canToggleViewOnce), "Cannot toggle `View Once`")

        isViewOnceEnabled = !isViewOnceEnabled
        preferences.setWasViewOnceTooltipShown()

        updateBottomToolView(animated: true)
    }

    @objc
    private func didTapSend() {
        // Generate the attachments once, so that any changes we
        // make below are reflected afterwards.
        ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modalVC in
            self.outputAttachmentsPromise()
                .done(on: DispatchQueue.main) { attachments in
                    AssertIsOnMainThread()
                    modalVC.dismiss {
                        AssertIsOnMainThread()

                        if self.options.contains(.canToggleViewOnce), self.isViewOnceEnabled {
                            for attachment in attachments {
                                attachment.isViewOnceAttachment = true
                            }
                            assert(attachments.count <= 1)
                        }

                        self.approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageBody: self.attachmentTextToolbar.messageBodyForSending)
                    }
                }.catch { error in
                    AssertIsOnMainThread()
                    owsFailDebug("Error: \(error)")

                    modalVC.dismiss {
                        let actionSheet = ActionSheetController(
                            title: CommonStrings.errorAlertTitle,
                            message: OWSLocalizedString(
                                "ATTACHMENT_APPROVAL_FAILED_TO_EXPORT",
                                comment: "Error that outgoing attachments could not be exported."),
                            theme: .translucentDark)
                        actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton, style: .default))

                        self.present(actionSheet, animated: true)
                    }
                }
        }
    }

    @objc
    private func didTapPenTool() {
        currentPageViewController?.activatePenTool()
    }

    @objc
    private func didTapCropTool() {
        currentPageViewController?.activateCropTool()
    }
}

// MARK: -

extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate {

    private func showContentDimmerView() {
        contentDimmerView.alpha = 0
        view.insertSubview(contentDimmerView, belowSubview: bottomToolView)
        contentDimmerView.autoPinEdgesToSuperviewEdges()
        UIView.animate(withDuration: 0.2) {
            self.contentDimmerView.alpha = 1
        }
        if contentDimmerView.gestureRecognizers?.isEmpty ?? true {
            contentDimmerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapContentDimmerView(gesture:))))
        }
    }

    private func hideContentDimmerView() {
        UIView.animate(withDuration: 0.2,
                       animations: {
            self.contentDimmerView.alpha = 0
        },
                       completion: { _ in
            self.contentDimmerView.removeFromSuperview()
        })
    }

    @objc
    func didTapContentDimmerView(gesture: UITapGestureRecognizer) {
        _ = bottomToolView.resignFirstResponder()
    }

    func attachmentTextToolbarWillBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
        startObservingKeyboardNotifications()
    }

    func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
        showContentDimmerView()
    }

    func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
        hideContentDimmerView()
    }

    func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) {
        approvalDelegate?.attachmentApproval(self, didChangeMessageBody: attachmentTextToolbar.messageBodyForSending)
    }

    func attachmentTextToolBarDidChangeHeight(_ attachmentTextToolbar: AttachmentTextToolbar) { }

    private func startObservingKeyboardNotifications() {
        guard !observingKeyboardNotifications else { return }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillChangeFrameNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillHideNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardDidHideNotification,
            object: nil
        )
        observingKeyboardNotifications = true
    }

    private func stopObservingKeyboardNotifications() {
        guard observingKeyboardNotifications else { return }

        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
        observingKeyboardNotifications = false
    }

    @objc
    private func handleKeyboardNotification(_ notification: Notification) {
        guard
            let currentPageViewController = currentPageViewController,
            let userInfo = notification.userInfo,
            let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }

        var keyboardHeight = endFrame.height

        switch notification.name {
        case UIResponder.keyboardDidHideNotification, UIResponder.keyboardWillHideNotification:
            keyboardHeight = 0

        default: break
        }

        guard self.keyboardHeight != keyboardHeight else { return }
        self.keyboardHeight = keyboardHeight

        if
            let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
            let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
            let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve)
        {
            UIView.animate(
                withDuration: animationDuration,
                delay: 0,
                options: animationCurve.asAnimationOptions,
                animations: {
                    currentPageViewController.keyboardHeight = keyboardHeight
                }
            )
        } else {
            currentPageViewController.keyboardHeight = keyboardHeight
        }
    }
}

// MARK: - Media Quality Selection Sheet

extension AttachmentApprovalViewController {

    private static let mediaQualityLocalizedString = OWSLocalizedString(
        "ATTACHMENT_APPROVAL_MEDIA_QUALITY_TITLE",
        comment: "Title for the attachment approval media quality sheet"
    )

    @objc
    private func didTapMediaQuality() {
        AssertIsOnMainThread()

        let actionSheet = ActionSheetController(theme: .translucentDark)
        actionSheet.isCancelable = true

        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        let localPhoneNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber
        let standardQualityLevel = ImageQualityLevel.remoteDefault(localPhoneNumber: localPhoneNumber)

        let selectionControl = MediaQualitySelectionControl(
            standardQualityLevel: standardQualityLevel,
            currentQualityLevel: outputQualityLevel
        )
        selectionControl.callback = { [weak self, weak actionSheet] qualityLevel in
            self?.outputQualityLevel = qualityLevel
            self?.updateBottomToolView(animated: false)

            if UIAccessibility.isVoiceOverRunning {
                // Dismissing immediately and without animation prevents VoiceOver engine from reading accessibilityLabel again.
                actionSheet?.dismiss(animated: false)
            } else {
                // Dismiss the action sheet with a slight delay so that user has a chance to see the change they made.
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    actionSheet?.dismiss(animated: true)
                }
            }
        }

        let titleLabel = UILabel()
        titleLabel.font = .dynamicTypeSubheadlineClamped
        titleLabel.textColor = Theme.darkThemePrimaryColor
        titleLabel.textAlignment = .center
        titleLabel.text = AttachmentApprovalViewController.mediaQualityLocalizedString
        titleLabel.isAccessibilityElement = false
        let titleLabelContainer = UIView()
        titleLabelContainer.addSubview(titleLabel)
        titleLabel.autoPinEdgesToSuperviewMargins()

        let margin = OWSTableViewController2.defaultHOuterMargin
        let bottomMargin = view.safeAreaInsets.bottom > 0 ? 0 : margin
        let headerStack = UIStackView(arrangedSubviews: [ selectionControl, titleLabelContainer ])
        headerStack.layoutMargins = UIEdgeInsets(top: margin, leading: margin, bottom: bottomMargin, trailing: margin)
        headerStack.isLayoutMarginsRelativeArrangement = true
        headerStack.spacing = 16
        headerStack.axis = .vertical

        actionSheet.customHeader = headerStack

        presentActionSheet(actionSheet)
    }

    private class MediaQualitySelectionControl: UIView {

        private let buttonQualityStandard: MediaQualityButton

        private let buttonQualityHigh = MediaQualityButton(
            title: ImageQualityLevel.high.localizedString,
            subtitle: OWSLocalizedString(
                "ATTACHMENT_APPROVAL_MEDIA_QUALITY_HIGH_OPTION_SUBTITLE",
                comment: "Subtitle for the 'high' option for media quality."
            )
        )

        private let standardQualityLevel: ImageQualityLevel
        private(set) var qualityLevel: ImageQualityLevel

        var callback: ((ImageQualityLevel) -> Void)?

        init(standardQualityLevel: ImageQualityLevel, currentQualityLevel: ImageQualityLevel) {
            self.standardQualityLevel = standardQualityLevel
            self.qualityLevel = currentQualityLevel

            self.buttonQualityStandard = MediaQualityButton(
                title: standardQualityLevel.localizedString,
                subtitle: OWSLocalizedString(
                    "ATTACHMENT_APPROVAL_MEDIA_QUALITY_STANDARD_OPTION_SUBTITLE",
                    comment: "Subtitle for the 'standard' option for media quality."
                )
            )

            super.init(frame: .zero)

            buttonQualityStandard.block = { [weak self] in
                self?.didSelectQualityLevel(standardQualityLevel)
            }
            addSubview(buttonQualityStandard)
            buttonQualityStandard.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .trailing)

            buttonQualityHigh.block = { [weak self] in
                self?.didSelectQualityLevel(.high)
            }
            addSubview(buttonQualityHigh)
            buttonQualityHigh.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .leading)

            buttonQualityHigh.autoPinWidth(toWidthOf: buttonQualityStandard)
            buttonQualityHigh.autoPinEdge(.leading, to: .trailing, of: buttonQualityStandard, withOffset: 20)

            updateButtonAppearance()
        }

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

        private func didSelectQualityLevel(_ qualityLevel: ImageQualityLevel) {
            self.qualityLevel = qualityLevel
            updateButtonAppearance()
            callback?(qualityLevel)
        }

        private func updateButtonAppearance() {
            buttonQualityStandard.isSelected = qualityLevel == standardQualityLevel
            buttonQualityHigh.isSelected = qualityLevel == .high
        }

        private class MediaQualityButton: OWSButton {

            let topLabel: UILabel = {
                let label = UILabel()
                label.textColor = Theme.darkThemePrimaryColor
                label.font = .dynamicTypeFootnoteClamped.medium()
                return label
            }()

            let bottomLabel: UILabel = {
                let label = UILabel()
                label.textColor = Theme.darkThemePrimaryColor
                label.font = .dynamicTypeCaption1Clamped
                label.lineBreakMode = .byWordWrapping
                label.numberOfLines = 0
                return label
            }()

            init(title: String, subtitle: String) {
                super.init(block: { })

                layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 8)

                layer.cornerRadius = 18
                layer.borderWidth = 1
                layer.borderColor = UIColor.clear.cgColor

                topLabel.text = title
                bottomLabel.text = subtitle

                let stackView = UIStackView(arrangedSubviews: [ topLabel, bottomLabel ])
                stackView.alignment = .center
                stackView.axis = .vertical
                stackView.spacing = 2
                stackView.isUserInteractionEnabled = false
                addSubview(stackView)
                stackView.autoPinEdgesToSuperviewMargins()
            }

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

            override var isSelected: Bool {
                didSet { updateAppearance() }
            }

            override var isHighlighted: Bool {
                didSet { updateAppearance() }
            }

            private func updateAppearance() {
                let textColor = isSelected ? UIColor.white : (isHighlighted ? UIColor.ows_whiteAlpha40 : UIColor.ows_whiteAlpha70)
                topLabel.textColor = textColor
                bottomLabel.textColor = textColor
                layer.borderColor = isSelected ? UIColor.white.cgColor : UIColor.clear.cgColor
            }
        }

        // MARK: - VoiceOver

        override var isAccessibilityElement: Bool {
            get { true }
            set { super.isAccessibilityElement = newValue }
        }

        override var accessibilityTraits: UIAccessibilityTraits {
            get { .adjustable }
            set { super.accessibilityTraits = newValue }
        }

        override var accessibilityLabel: String? {
            get { AttachmentApprovalViewController.mediaQualityLocalizedString }
            set { super.accessibilityLabel = newValue }
        }

        override var accessibilityValue: String? {
            get {
                let selectedButton = qualityLevel == .high ? buttonQualityHigh : buttonQualityStandard
                return [ selectedButton.topLabel, selectedButton.bottomLabel ].compactMap { $0.text }.joined(separator: ",")
            }
            set { super.accessibilityValue = newValue }
        }

        override var accessibilityFrame: CGRect {
            get { UIAccessibility.convertToScreenCoordinates(bounds.inset(by: UIEdgeInsets(margin: -4)), in: self) }
            set { super.accessibilityFrame = newValue }
        }

        override func accessibilityActivate() -> Bool {
            callback?(qualityLevel)
            return true
        }

        override func accessibilityIncrement() {
            if qualityLevel == standardQualityLevel {
                qualityLevel = .high
                updateButtonAppearance()
            }
        }

        override func accessibilityDecrement() {
            if qualityLevel == .high {
                qualityLevel = standardQualityLevel
                updateButtonAppearance()
            }
        }
    }
}

extension AttachmentApprovalViewController: BodyRangesTextViewDelegate {

    public func textViewDidBeginTypingMention(_ textView: BodyRangesTextView) { }

    public func textViewDidEndTypingMention(_ textView: BodyRangesTextView) { }

    public func textViewMentionPickerParentView(_ textView: BodyRangesTextView) -> UIView? {
        return view
    }

    public func textViewMentionPickerReferenceView(_ textView: BodyRangesTextView) -> UIView? {
        return bottomToolView.attachmentTextToolbar
    }

    public func textViewMentionPickerPossibleAddresses(_ textView: BodyRangesTextView, tx: DBReadTransaction) -> [SignalServiceAddress] {
        return approvalDataSource?.attachmentApprovalMentionableAddresses(tx: tx) ?? []
    }

    public func textViewDisplayConfiguration(_ textView: BodyRangesTextView) -> HydratedMessageBody.DisplayConfiguration {
        return .composingAttachment()
    }

    public func mentionPickerStyle(_ textView: BodyRangesTextView) -> MentionPickerStyle {
        return .composingAttachment
    }

    public func textViewMentionCacheInvalidationKey(_ textView: BodyRangesTextView) -> String {
        return approvalDataSource?.attachmentApprovalMentionCacheInvalidationKey() ?? UUID().uuidString
    }
}

// MARK: -

extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate {

    func attachmentPrepViewControllerDidRequestUpdateControlsVisibility(_ viewController: AttachmentPrepViewController,
                                                                        completion: ((Bool) -> Void)? = nil) {
        updateControlsVisibility(animated: true, completion: completion)
    }
}

// MARK: GalleryRail

extension AttachmentApprovalItem: GalleryRailItem {

    public func buildRailItemView() -> UIView {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = getThumbnailImage()
        return imageView
    }
}

extension AddMoreRailItem: GalleryRailItem {

    func buildRailItemView() -> UIView {
        let button = RoundMediaButton(
            image: UIImage(imageLiteralResourceName: "plus-square-28"),
            backgroundStyle: .blur
        )
        button.isUserInteractionEnabled = false
        button.layoutMargins = .zero
        button.contentEdgeInsets = .zero
        return button
    }
}

// MARK: -

extension AttachmentApprovalItemCollection: GalleryRailItemProvider {

    var railItems: [GalleryRailItem] {
        if isAddMoreVisible() {
            return self.attachmentApprovalItems + [AddMoreRailItem()]
        } else {
            return self.attachmentApprovalItems
        }
    }
}

// MARK: -

extension AttachmentApprovalViewController: GalleryRailViewDelegate {

    public func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
        if imageRailItem is AddMoreRailItem {
            didTapAddMedia()
            return
        }

        guard let targetItem = imageRailItem as? AttachmentApprovalItem else {
            owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
            return
        }

        guard let currentItem = currentItem,
              let currentIndex = attachmentApprovalItems.firstIndex(of: currentItem) else {
            owsFailDebug("currentIndex was unexpectedly nil")
            return
        }

        guard let targetIndex = attachmentApprovalItems.firstIndex(of: targetItem) else {
            owsFailDebug("targetIndex was unexpectedly nil")
            return
        }

        let direction: UIPageViewController.NavigationDirection = currentIndex < targetIndex ? .forward : .reverse
        setCurrentItem(targetItem, direction: direction, animated: true)
    }
}

// MARK: -

extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate {

    func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentApprovalItem: AttachmentApprovalItem) {
        remove(attachmentApprovalItem: attachmentApprovalItem)
    }

    func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool {
        return self.attachmentApprovalItems.count > 1
    }
}

extension AttachmentApprovalViewController: InputAccessoryViewPlaceholderDelegate {

    public func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
        handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
    }

    public func inputAccessoryPlaceholderKeyboardDidPresent() {
        updateBottomToolViewPosition()
    }

    public func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
        handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
    }

    public func inputAccessoryPlaceholderKeyboardDidDismiss() {
        updateBottomToolViewPosition()
    }

    public func inputAccessoryPlaceholderKeyboardIsDismissingInteractively() {
        updateBottomToolViewPosition()
    }

    func handleKeyboardStateChange(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
        guard animationDuration > 0 else { return updateBottomToolViewPosition() }

        UIView.animate(
            withDuration: animationDuration,
            delay: 0,
            options: animationCurve.asAnimationOptions,
            animations: { [self] in
                self.updateBottomToolViewPosition()
            }
        )
    }

    func updateBottomToolViewPosition() {
        bottomToolViewBottomConstraint?.constant = -inputAccessoryPlaceholder.keyboardOverlap

        // We always want to apply the new bottom bar position immediately,
        // as this only happens during animations (interactive or otherwise)
        bottomToolView.superview?.layoutIfNeeded()
    }
}

// MARK: -

private enum SaveableAsset {
    case image(_ image: UIImage)
    case imageUrl(_ url: URL)
    case videoUrl(_ url: URL)
}

private extension SaveableAsset {
    init(attachmentApprovalItem: AttachmentApprovalItem) throws {
        if let imageEditorModel = attachmentApprovalItem.imageEditorModel {
            try self.init(imageEditorModel: imageEditorModel)
        } else {
            try self.init(attachment: attachmentApprovalItem.attachment)
        }
    }

    init(imageEditorModel: ImageEditorModel) throws {
        guard let image = imageEditorModel.renderOutput() else {
            throw OWSAssertionError("failed to render image")
        }

        self = .image(image)
    }

    init(attachment: SignalAttachment) throws {
        if attachment.isValidImage {
            guard let imageUrl = attachment.dataUrl else {
                throw OWSAssertionError("imageUrl was unexpectedly nil")
            }

            self = .imageUrl(imageUrl)
        } else if attachment.isValidVideo {
            guard let videoUrl = attachment.dataUrl else {
                throw OWSAssertionError("videoUrl was unexpectedly nil")
            }

            self = .videoUrl(videoUrl)
        } else {
            throw OWSAssertionError("unsaveable media")
        }
    }
}
