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

import Lottie
import SignalServiceKit
import SignalUI

@MainActor
protocol SendPaymentViewDelegate: AnyObject {
    func didSendPayment(success: Bool)
}

// MARK: -

enum SendPaymentMode: UInt {
    case fromConversationView
    case fromPaymentSettings
    case fromTransferOutFlow

    var isModalRootView: Bool {
        switch self {
        case .fromConversationView:
            return true
        case .fromPaymentSettings,
             .fromTransferOutFlow:
            return false
        }
    }
}

// MARK: -

class SendPaymentViewController: OWSViewController, SendPaymentMemoViewDelegate, SendPaymentHelperDelegate,
    SendPaymentCompletionDelegate, AmountsDelegate
{
    private let mode: SendPaymentMode

    fileprivate typealias PaymentInfo = SendPaymentInfo

    weak var delegate: SendPaymentViewDelegate?

    private let recipient: SendPaymentRecipient
    private let isOutgoingTransfer: Bool

    private let bigAmountLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 54)
        label.textAlignment = .center
        label.adjustsFontSizeToFitWidth = true
        label.minimumScaleFactor = 0.25
        label.setContentHuggingVerticalHigh()
        label.setCompressionResistanceVerticalHigh()
        return label
    }()

    private let smallAmountLabel: UILabel = {
        let label = UILabel()
        label.font = .dynamicTypeSubheadlineClamped
        label.adjustsFontSizeToFitWidth = true
        label.adjustsFontForContentSizeCategory = true
        label.textColor = .Signal.secondaryLabel
        label.textAlignment = .center
        label.setContentHuggingVerticalHigh()
        label.setCompressionResistanceVerticalHigh()
        return label
    }()

    private lazy var currencyConversionInfoButton: UIButton = {
        let button = UIButton(
            configuration: .plain(),
            primaryAction: UIAction { [weak self] _ in
                self?.didTapCurrencyConversionInfo()
            },
        )
        button.configuration?.image = UIImage(named: "info-20")
        button.configuration?.contentInsets = .init(hMargin: 12, vMargin: 4)
        button.tintColor = .Signal.secondaryLabel
        button.setCompressionResistanceHorizontalHigh()
        return button
    }()

    private var memoView = UIView()

    private let balanceLabel = SendPaymentHelper.buildBottomLabel()

    // MARK: - Amount

    private let amounts = Amounts()
    private var amount: Amount { amounts.currentAmount }
    private var otherCurrencyAmount: Amount? { amounts.otherCurrencyAmount }

    // MARK: -

    private var memoMessage: String? {
        didSet {
            updateMemoView()
        }
    }

    private var hasMemoMessage: Bool {
        memoMessage?.strippedOrNil != nil
    }

    private var helper: SendPaymentHelper?

    private var currentCurrencyConversion: CurrencyConversionInfo? { helper?.currentCurrencyConversion }

    private var isIdentifiedPayment: Bool {
        recipient.isIdentifiedPayment
    }

    private var isUsingPresentedStyle: Bool {
        return presentingViewController != nil
    }

    init(
        recipient: SendPaymentRecipient,
        initialPaymentAmount: TSPaymentAmount?,
        isOutgoingTransfer: Bool,
        mode: SendPaymentMode,
    ) {
        self.recipient = recipient
        self.mode = mode
        self.isOutgoingTransfer = isOutgoingTransfer

        if
            Self.wasLastPaymentInFiat,
            let defaultFiatAmount = Amounts.defaultFiatAmount
        {
            amounts.set(currentAmount: defaultFiatAmount, otherCurrencyAmount: nil)
        } else {
            amounts.set(currentAmount: Amounts.defaultMCAmount, otherCurrencyAmount: nil)
        }

        if let initialPaymentAmount {
            owsAssertDebug(initialPaymentAmount.currency == .mobileCoin)

            if let amountString = PaymentsFormat.formatAsDoubleString(picoMob: initialPaymentAmount.picoMob) {
                let inputString = InputString.parseString(amountString, isFiat: false)
                amounts.set(
                    currentAmount: .mobileCoin(
                        inputString: inputString,
                        exactAmount: initialPaymentAmount,
                    ),
                    otherCurrencyAmount: nil,
                )
            } else {
                owsFailDebug("Could not apply initial amount.")
            }
        }

        super.init()

        helper = SendPaymentHelper(delegate: self)
        amounts.delegate = self
    }

    deinit {
        for observation in observations {
            NotificationCenter.default.removeObserver(observation)
        }
    }

    // MARK: - Presenting

    private enum PresentationMode {
        case fromConversationView(fromViewController: UIViewController)
        case inNavigationController(navigationController: UINavigationController)
    }

    private static func present(
        fromViewController: UIViewController,
        presentationMode: PresentationMode,
        delegate: SendPaymentViewDelegate,
        recipientAddress: SignalServiceAddress,
        initialPaymentAmount: TSPaymentAmount? = nil,
        isOutgoingTransfer: Bool,
        mode: SendPaymentMode,
    ) {
        guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled else {
            Logger.info("Payments not enabled.")
            showEnablePaymentsActionSheet()
            return
        }
        guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
            Logger.info("Local user is not registered and ready.")
            showNotRegisteredActionSheet()
            return
        }

        var hasProfileKeyForRecipient = false
        var hasSentMessagesToRecipient = false
        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            if SSKEnvironment.shared.profileManagerRef.userProfile(for: recipientAddress, tx: transaction)?.profileKey != nil {
                hasProfileKeyForRecipient = true
                return
            }
            guard let thread = TSContactThread.getWithContactAddress(recipientAddress, transaction: transaction) else {
                return
            }
            let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
            if interactionFinder.outgoingMessageCount(transaction: transaction) > 0 {
                hasSentMessagesToRecipient = true
            }
        }
        guard hasProfileKeyForRecipient else {
            let title = OWSLocalizedString(
                "PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_TITLE",
                comment: "Title for error alert indicating that a given user cannot receive payments because of a pending message request.",
            )
            let message = (
                hasSentMessagesToRecipient
                    ? OWSLocalizedString(
                        "PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_MESSAGE_W_MESSAGES",
                        comment: "Message for error alert indicating that a given user cannot receive payments because of a pending message request for a recipient that they have sent messages to.",
                    )
                    : OWSLocalizedString(
                        "PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_MESSAGE_WO_MESSAGES",
                        comment: "Message for error alert indicating that a given user cannot receive payments because of a pending message request for a recipient that they have not sent message to.",
                    ),
            )

            let actionSheet = ActionSheetController(title: title, message: message)

            if !hasSentMessagesToRecipient {
                switch mode {
                case .fromConversationView:
                    break
                case .fromTransferOutFlow:
                    owsFailDebug("not a valid mode for this method")
                case .fromPaymentSettings:
                    actionSheet.addAction(ActionSheetAction(
                        title: CommonStrings.sendMessage,
                        style: .default,
                        handler: { [weak fromViewController] _ in
                            guard let fromViewController else { return }
                            // We want to get back to the app's main interface. This is shown inside
                            // Payment Settings, which is presented, and is part of the Send Payment
                            // flow, which is *also* presented.
                            let rootViewController = fromViewController.presentingViewController?.presentingViewController
                            owsAssertDebug(rootViewController != nil)
                            rootViewController?.dismiss(animated: true) {
                                SignalApp.shared.presentConversationForAddress(recipientAddress, action: .compose, animated: true)
                            }
                        },
                    ))
                }
            }

            actionSheet.addAction(OWSActionSheets.okayAction)

            fromViewController.presentActionSheet(actionSheet)

            return
        }

        let recipientHasPaymentsEnabled = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
        }
        if recipientHasPaymentsEnabled {
            presentAfterRecipientCheck(
                presentationMode: presentationMode,
                delegate: delegate,
                recipientAddress: recipientAddress,
                initialPaymentAmount: initialPaymentAmount,
                isOutgoingTransfer: isOutgoingTransfer,
                mode: mode,
            )
        } else {
            // Check whether recipient can receive payments.
            ModalActivityIndicatorViewController.presentAsInvisible(fromViewController: fromViewController) { modalActivityIndicator in
                Task { @MainActor in
                    do {
                        guard let serviceId = recipientAddress.serviceId else {
                            throw ProfileRequestError.notFound
                        }
                        let profileFetcher = SSKEnvironment.shared.profileFetcherRef
                        _ = try await profileFetcher.fetchProfile(for: serviceId)

                        modalActivityIndicator.dismiss {
                            Self.presentAfterRecipientCheck(
                                presentationMode: presentationMode,
                                delegate: delegate,
                                recipientAddress: recipientAddress,
                                initialPaymentAmount: initialPaymentAmount,
                                isOutgoingTransfer: isOutgoingTransfer,
                                mode: mode,
                            )
                        }
                    } catch {
                        owsFailDebug("Error: \(error)")
                        modalActivityIndicator.dismiss {
                            Self.showRecipientNotEnabledAlert(recipientAddress: recipientAddress)
                        }
                    }
                }
            }
        }
    }

    private static func presentAfterRecipientCheck(
        presentationMode: PresentationMode,
        delegate: SendPaymentViewDelegate,
        recipientAddress: SignalServiceAddress,
        initialPaymentAmount: TSPaymentAmount? = nil,
        isOutgoingTransfer: Bool,
        mode: SendPaymentMode,
    ) {
        let recipientHasPaymentsEnabled = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
        }
        guard recipientHasPaymentsEnabled else {
            showRecipientNotEnabledAlert(recipientAddress: recipientAddress)
            return
        }

        let recipient: SendPaymentRecipientImpl = .address(address: recipientAddress)
        let view = SendPaymentViewController(
            recipient: recipient,
            initialPaymentAmount: initialPaymentAmount,
            isOutgoingTransfer: isOutgoingTransfer,
            mode: mode,
        )
        view.delegate = delegate
        switch presentationMode {
        case .fromConversationView(let fromViewController):
            let navigationController = OWSNavigationController(rootViewController: view)
            fromViewController.presentFormSheet(navigationController, animated: true)
        case .inNavigationController(let navigationController):
            navigationController.pushViewController(view, animated: true)
        }
    }

    private static func showRecipientNotEnabledAlert(recipientAddress: SignalServiceAddress) {
        let titleFormat = OWSLocalizedString(
            "PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_TITLE",
            comment: "Title for error alert indicating that a given user cannot receive payments because they have not enabled payments. Embeds {{ the contact's name }}",
        )
        let recipientName: String = SSKEnvironment.shared.databaseStorageRef.read { tx in
            SSKEnvironment.shared.contactManagerRef.displayName(for: recipientAddress, tx: tx).resolvedValue()
        }
        let title = String.nonPluralLocalizedStringWithFormat(titleFormat, recipientName)
        let actionSheet = ActionSheetController(
            title: title,
            message: OWSLocalizedString(
                "PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_MESSAGE",
                comment: "Message for error alert indicating that a given user cannot receive payments because they have not enabled payments.",
            ),
        )

        let sendAction = ActionSheetAction(
            title: OWSLocalizedString(
                "PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_BUTTON",
                comment: "The label for the 'send request' button in alerts and action sheets.",
            ),
            style: .default,
        ) { _ in
            sendActivationRequest(recipientAddress: recipientAddress)
        }
        actionSheet.addAction(sendAction)
        actionSheet.addAction(OWSActionSheets.cancelAction)

        OWSActionSheets.showActionSheet(actionSheet)
    }

    private static func sendActivationRequest(recipientAddress: SignalServiceAddress) {
        SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
            guard
                let thread = TSContactThread.getWithContactAddress(
                    recipientAddress,
                    transaction: transaction,
                )
            else {
                return
            }
            let message = OutgoingPaymentActivationRequestMessage(thread: thread, tx: transaction)
            // The request message isn't inserted or rendered in chat; thats done with an info message.
            let preparedMessage = PreparedOutgoingMessage.preprepared(
                transientMessageWithoutAttachments: message,
            )
            SSKEnvironment.shared.messageSenderJobQueueRef.add(
                message: preparedMessage,
                transaction: transaction,
            )
            if let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci {
                let infoMessage = TSInfoMessage(
                    thread: thread,
                    messageType: .paymentsActivationRequest,
                    infoMessageUserInfo: [
                        .paymentActivationRequestSenderAci: localAci.serviceIdString,
                    ],
                )
                infoMessage.anyInsert(transaction: transaction)
            }
        }
    }

    static func presentFromConversationView(
        _ fromViewController: UIViewController,
        delegate: SendPaymentViewDelegate,
        recipientAddress: SignalServiceAddress,
        initialPaymentAmount: TSPaymentAmount? = nil,
        isOutgoingTransfer: Bool,
    ) {
        present(
            fromViewController: fromViewController,
            presentationMode: .fromConversationView(fromViewController: fromViewController),
            delegate: delegate,
            recipientAddress: recipientAddress,
            initialPaymentAmount: initialPaymentAmount,
            isOutgoingTransfer: isOutgoingTransfer,
            mode: .fromConversationView,
        )
    }

    static func present(
        inNavigationController navigationController: UINavigationController,
        delegate: SendPaymentViewDelegate,
        recipientAddress: SignalServiceAddress,
        initialPaymentAmount: TSPaymentAmount? = nil,
        isOutgoingTransfer: Bool,
        mode: SendPaymentMode,
    ) {
        present(
            fromViewController: navigationController,
            presentationMode: .inNavigationController(navigationController: navigationController),
            delegate: delegate,
            recipientAddress: recipientAddress,
            initialPaymentAmount: initialPaymentAmount,
            isOutgoingTransfer: isOutgoingTransfer,
            mode: mode,
        )
    }

    // MARK: - UIViewController

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

        if mode.isModalRootView {
            navigationItem.rightBarButtonItem = .doneButton(dismissingFrom: self)
        }

        view.backgroundColor = .Signal.groupedBackground

        //
        // 1. Amounts
        //
        let amountsView = UIView()
        // Big label.
        amountsView.addSubview(bigAmountLabel)
        // Small label with (i) info button next to it.
        amountsView.addSubview(smallAmountLabel)
        amountsView.addSubview(currencyConversionInfoButton)

        bigAmountLabel.translatesAutoresizingMaskIntoConstraints = false
        smallAmountLabel.translatesAutoresizingMaskIntoConstraints = false
        currencyConversionInfoButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            bigAmountLabel.topAnchor.constraint(equalTo: amountsView.topAnchor),
            bigAmountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: amountsView.leadingAnchor),
            bigAmountLabel.centerXAnchor.constraint(equalTo: amountsView.centerXAnchor),

            smallAmountLabel.topAnchor.constraint(equalTo: bigAmountLabel.bottomAnchor, constant: 8),
            smallAmountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: amountsView.leadingAnchor),
            smallAmountLabel.centerXAnchor.constraint(equalTo: amountsView.centerXAnchor),
            smallAmountLabel.bottomAnchor.constraint(equalTo: amountsView.bottomAnchor),

            currencyConversionInfoButton.centerYAnchor.constraint(equalTo: smallAmountLabel.centerYAnchor),
            currencyConversionInfoButton.leadingAnchor.constraint(equalTo: smallAmountLabel.trailingAnchor),
            currencyConversionInfoButton.trailingAnchor.constraint(lessThanOrEqualTo: amountsView.trailingAnchor),
        ])
        // Transfer button next to big label.
        if currentCurrencyConversion != nil {
            let transferButton = UIButton(
                configuration: .plain(),
                primaryAction: UIAction { [weak self] _ in
                    self?.didTapSwapCurrency()
                },
            )
            transferButton.tintColor = .secondaryLabel
            transferButton.configuration?.image = UIImage(named: "transfer")
            transferButton.configuration?.contentInsets = .init(margin: 4)
            transferButton.setCompressionResistanceHorizontalHigh()
            amountsView.addSubview(transferButton)
            transferButton.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                transferButton.centerYAnchor.constraint(equalTo: bigAmountLabel.centerYAnchor),
                transferButton.trailingAnchor.constraint(equalTo: amountsView.trailingAnchor),
                transferButton.leadingAnchor.constraint(greaterThanOrEqualTo: bigAmountLabel.trailingAnchor),
            ])
        }

        let stackView = addStaticContentStackView(
            arrangedSubviews: [
                amountsView,
            ],
            isScrollable: true,
        )

        //
        // 2. Memo (optional)
        //
        if isIdentifiedPayment {
            updateMemoView()
            stackView.addArrangedSubview(memoView)
        }

        //
        //
        // 3. Stretching space.
        stackView.addArrangedSubview(.vStretchingSpacer())

        //
        // 4. Keyboard
        //
        let keyboardView = buildKeyboardView()
        stackView.addArrangedSubview(keyboardView)
        stackView.setCustomSpacing(24, after: keyboardView)

        //
        // 5. Big blue Pay button.
        //
        let payButton = UIButton(
            configuration: .largePrimary(title: OWSLocalizedString(
                "PAYMENTS_NEW_PAYMENT_PAY_BUTTON",
                comment: "Label for the 'new payment' button.",
            )),
            primaryAction: UIAction { [weak self] _ in
                self?.didTapPayButton()
            },
        )
        // This sets required side margins and is necessary even though there's just one button.
        let payButtonContainer = [payButton].enclosedInVerticalStackView(isFullWidthButtons: true)
        stackView.addArrangedSubview(payButtonContainer)
        stackView.setCustomSpacing(0, after: payButtonContainer)

        //
        // 6. Balance Label.
        //
        stackView.addArrangedSubview(balanceLabel)
        stackView.addArrangedSubview(.spacer(withHeight: 16))

        updateAmountLabels()
        updateBalanceLabel()

        addObservations()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        helper?.refreshObservedValues()
    }

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

        // For now, the design only allows for portrait layout on non-iPads
        if !UIDevice.current.isIPad, view.window?.windowScene?.interfaceOrientation != .portrait {
            UIDevice.current.ows_setOrientation(.portrait)
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return UIDevice.current.isIPad ? .all : .portrait
    }

    // MARK: - Observations

    private var observations = [NotificationCenter.Observer]()

    private func addObservations() {
        observations.append(NotificationCenter.default.addObserver(
            name: PaymentsConstants.isPaymentsVersionOutdatedDidChange,
        ) { [weak self] _ in
            self?.isPaymentsVersionOutdatedDidChange()
        })
    }

    private func isPaymentsVersionOutdatedDidChange() {
        guard UIApplication.shared.frontmostViewController == self else { return }
        if SSKEnvironment.shared.paymentsHelperRef.isPaymentsVersionOutdated {
            OWSActionSheets.showPaymentsOutdatedClientSheet(title: .updateRequired)
        }
    }

    // MARK: - Updating UI

    private func updateAmountLabels() {
        let isZero = amount.inputString.isZero

        func hideConversionLabelOrShowWarning() {
            let shouldHaveValidValue = isZero == false && currentCurrencyConversion != nil
            smallAmountLabel.text = (
                shouldHaveValidValue
                    ? OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_INVALID_AMOUNT",
                        comment: "Label for the 'invalid amount' button.",
                    )
                    : " ",
            )
            smallAmountLabel.textColor = .Signal.warningLabel
            currencyConversionInfoButton.alpha = 0
        }

        func enableSmallLabel(_ text: String) {
            smallAmountLabel.text = text
            smallAmountLabel.textColor = .Signal.secondaryLabel
            currencyConversionInfoButton.alpha = 1
        }

        bigAmountLabel.attributedText = amount.formatAsKeyboardInputAttributed(withSpace: false)

        switch amount {
        case .mobileCoin:
            if
                let otherCurrencyAmount,
                let currencyConversion = otherCurrencyAmount.currencyConversion
            {
                let formattedAmount = otherCurrencyAmount.formatForDisplay(withSpace: true).string
                enableSmallLabel(Self.formatWithConversionFreshness(
                    formattedAmount: formattedAmount,
                    currencyConversion: currencyConversion,
                    isZero: isZero,
                ))
            } else if
                let currencyConversion = currentCurrencyConversion,
                let fiatCurrencyAmount = currencyConversion.convertToFiatCurrency(paymentAmount: parsedPaymentAmount),
                let fiatString = PaymentsFormat.attributedFormat(
                    fiatCurrencyAmount: fiatCurrencyAmount,
                    currencyCode: currencyConversion.currencyCode,
                    withSpace: true,
                )
            {
                enableSmallLabel(Self.formatWithConversionFreshness(
                    formattedAmount: fiatString.string,
                    currencyConversion: currencyConversion,
                    isZero: isZero,
                ))
            } else {
                hideConversionLabelOrShowWarning()
            }
        case .fiatCurrency(_, let currencyConversion):
            if let otherCurrencyAmount = self.otherCurrencyAmount {
                let formattedAmount = otherCurrencyAmount.formatForDisplay(withSpace: true).string
                enableSmallLabel(Self.formatWithConversionFreshness(
                    formattedAmount: formattedAmount,
                    currencyConversion: currencyConversion,
                    isZero: isZero,
                ))
            } else {
                let paymentAmount = currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
                let formattedAmount = PaymentsFormat.attributedFormat(
                    paymentAmount: paymentAmount,
                    isShortForm: false,
                    withSpace: true,
                ).string
                enableSmallLabel(Self.formatWithConversionFreshness(
                    formattedAmount: formattedAmount,
                    currencyConversion: currencyConversion,
                    isZero: isZero,
                ))
            }
        }
    }

    private func updateMemoView() {
        memoView.removeAllSubviews()

        var buttonConfiguration: UIButton.Configuration
        if let memoString = memoMessage?.stripped.nilIfEmpty {
            buttonConfiguration = .bordered()
            buttonConfiguration.baseBackgroundColor = .Signal.secondaryGroupedBackground
            buttonConfiguration.title = memoString
            buttonConfiguration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadlineClamped)
            buttonConfiguration.baseForegroundColor = .Signal.label
            if #available(iOS 26, *) {
                buttonConfiguration.cornerStyle = .capsule
            } else {
                buttonConfiguration.cornerStyle = .fixed
                buttonConfiguration.background.cornerRadius = 10
            }
        } else {
            buttonConfiguration = .plain()
            buttonConfiguration.title = OWSLocalizedString(
                "PAYMENTS_NEW_PAYMENT_ADD_MEMO",
                comment: "Label for the 'add memo' ui in the 'send payment' UI.",
            )
            buttonConfiguration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeBodyClamped)
            buttonConfiguration.baseForegroundColor = .Signal.accent
        }
        buttonConfiguration.contentInsets = .init(
            hMargin: OWSTableViewController2.cellHInnerMargin,
            vMargin: OWSTableViewController2.cellVInnerMargin,
        )

        let button = UIButton(
            configuration: buttonConfiguration,
            primaryAction: UIAction { [weak self] _ in
                self?.didTapAddMemo()
            },
        )
        memoView.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.topAnchor.constraint(equalTo: memoView.topAnchor),
            button.leadingAnchor.constraint(greaterThanOrEqualTo: memoView.leadingAnchor),
            button.centerXAnchor.constraint(equalTo: memoView.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: memoView.bottomAnchor),
        ])
    }

    private func updateBalanceLabel() {
        helper?.updateBalanceLabel(balanceLabel)
    }

    // MARK: - Formatting helpers

    static func formatWithConversionFreshness(
        formattedAmount: String,
        currencyConversion: CurrencyConversionInfo,
        isZero: Bool,
    ) -> String {
        guard !isZero else {
            return formattedAmount
        }
        let formattedFreshness = DateUtil.formatDateAsTime(currencyConversion.conversionDate)
        let conversionFormat = OWSLocalizedString(
            "PAYMENTS_CURRENCY_CONVERSION_FRESHNESS_FORMAT",
            comment: "Format for indicator of a payment amount converted to fiat currency with the freshness of the conversion rate. Embeds: {{ %1$@ the payment amount, %2$@ the freshness of the currency conversion rate }}.",
        )
        return String.nonPluralLocalizedStringWithFormat(conversionFormat, formattedAmount, formattedFreshness)
    }

    private func updateAmount(_ amount: Amount) -> Amount {
        guard let currentCurrencyConversion else {
            return amount
        }
        switch amount {
        case .mobileCoin:
            return amount
        case .fiatCurrency(let inputString, _):
            return .fiatCurrency(inputString: inputString, currencyConversion: currentCurrencyConversion)
        }
    }

    private func resetContents() {
        amounts.reset()

        memoMessage = nil
    }

    // MARK: - Button action handlers

    private func didTapAddMemo() {
        let view = SendPaymentMemoViewController(memoMessage: self.memoMessage)
        view.delegate = self
        navigationController?.pushViewController(view, animated: true)
    }

    private func didTapSwapCurrency() {
        // If users repeatedly swap input currency, we don't want the
        // values to drift due to rounding errors.  So we keep around
        // the "other" currency amount and use it to swap if no changes
        // have been made since the last switch.
        if let otherCurrencyAmount {
            amounts.set(
                currentAmount: updateAmount(otherCurrencyAmount),
                otherCurrencyAmount: updateAmount(self.amount),
            )
            return
        }

        switch amount {
        case .mobileCoin:
            if
                let currencyConversion = currentCurrencyConversion,
                let fiatCurrencyAmount = currencyConversion.convertToFiatCurrency(paymentAmount: parsedPaymentAmount),
                let fiatString = PaymentsFormat.formatAsDoubleString(fiatCurrencyAmount)
            {
                // Store the otherCurrencyAmount.
                amounts.set(
                    currentAmount: .fiatCurrency(
                        inputString: InputString.parseString(fiatString, isFiat: true),
                        currencyConversion: currencyConversion,
                    ),
                    otherCurrencyAmount: self.amount,
                )
            } else {
                owsFailDebug("Could not switch to fiat currency.")
                resetContents()
            }
        case .fiatCurrency(_, let currencyConversion):
            let paymentAmount = currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
            if let mobString = PaymentsFormat.formatAsDoubleString(picoMob: paymentAmount.picoMob) {
                // Store the otherCurrencyAmount.
                amounts.set(
                    currentAmount: .mobileCoin(
                        inputString: InputString.parseString(
                            mobString,
                            isFiat: false,
                        ),
                        exactAmount: nil,
                    ),
                    otherCurrencyAmount: self.amount,
                )
            } else {
                owsFailDebug("Could not switch from fiat currency.")
                resetContents()
            }
        }
    }

    private func didTapCurrencyConversionInfo() {
        PaymentsSettingsViewController.showCurrencyConversionInfoAlert(fromViewController: self)
    }

    // MARK: -

    private static let keyValueStore = KeyValueStore(collection: "SendPaymentView")
    private static let wasLastPaymentInFiatKey = "wasLastPaymentInFiat"

    private static var wasLastPaymentInFiat: Bool {
        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            Self.keyValueStore.getBool(
                Self.wasLastPaymentInFiatKey,
                defaultValue: false,
                transaction: transaction,
            )
        }
    }

    private func setWasLastPaymentInFiat(_ value: Bool) {
        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            Self.keyValueStore.setBool(
                value,
                key: Self.wasLastPaymentInFiatKey,
                transaction: transaction,
            )
        }
    }

    // MARK: - Payment handling

    private var actionSheet: SendPaymentCompletionActionSheet?

    private func showInvalidAmountAlert() {
        let errorMessage = OWSLocalizedString(
            "PAYMENTS_NEW_PAYMENT_INVALID_AMOUNT",
            comment: "Label for the 'invalid amount' button.",
        )
        OWSActionSheets.showErrorAlert(message: errorMessage)
    }

    private func didTapPayButton() {
        let paymentAmount = parsedPaymentAmount
        guard paymentAmount.picoMob > 0 else {
            showInvalidAmountAlert()
            return
        }

        setWasLastPaymentInFiat(amounts.currentAmount.isFiat)
        getEstimatedFeeAndSubmit(paymentAmount: paymentAmount)
    }

    private func getEstimatedFeeAndSubmit(paymentAmount: TSPaymentAmount) {
        ModalActivityIndicatorViewController.presentAsInvisible(fromViewController: self) { modalActivityIndicator in
            Promise.wrapAsync {
                try await SUIEnvironment.shared.paymentsSwiftRef.getEstimatedFee(forPaymentAmount: paymentAmount)
            }.done { (estimatedFeeAmount: TSPaymentAmount) in
                AssertIsOnMainThread()

                modalActivityIndicator.dismiss {
                    self.tryToShowPaymentCompletionUI(
                        paymentAmount: paymentAmount,
                        estimatedFeeAmount: estimatedFeeAmount,
                    )
                }
            }.catch { error in
                AssertIsOnMainThread()
                if case PaymentsError.insufficientFunds = error {
                    Logger.warn("Error: \(error)")
                } else {
                    owsFailDebugUnlessMCNetworkFailure(error)
                }

                modalActivityIndicator.dismiss {
                    AssertIsOnMainThread()

                    OWSActionSheets.showErrorAlert(
                        message: SendPaymentCompletionActionSheet.formatPaymentFailure(
                            error,
                            withErrorPrefix: false,
                        ),
                    )
                }
            }
        }
    }

    private func tryToShowPaymentCompletionUI(
        paymentAmount: TSPaymentAmount,
        estimatedFeeAmount: TSPaymentAmount,
    ) {
        guard
            paymentAmount.isValidAmount(canBeEmpty: false),
            estimatedFeeAmount.isValidAmount(canBeEmpty: false)
        else {
            showInvalidAmountAlert()
            return
        }
        let totalAmount = paymentAmount.plus(estimatedFeeAmount)
        guard let paymentBalance = SUIEnvironment.shared.paymentsSwiftRef.currentPaymentBalance else {
            OWSActionSheets.showErrorAlert(message: OWSLocalizedString(
                "SETTINGS_PAYMENTS_CANNOT_SEND_PAYMENT_NO_BALANCE",
                comment: "Error message indicating that a payment could not be sent because the current balance is unavailable.",
            ))
            return
        }
        guard paymentBalance.amount.picoMob >= totalAmount.picoMob else {
            showInsufficientBalanceUI(paymentBalance: paymentBalance)
            return
        }

        showPaymentCompletionUI(
            paymentAmount: paymentAmount,
            estimatedFeeAmount: estimatedFeeAmount,
        )
    }

    private func showInsufficientBalanceUI(paymentBalance: PaymentBalance) {
        let messageFormat = OWSLocalizedString(
            "SETTINGS_PAYMENTS_PAYMENT_INSUFFICIENT_BALANCE_ALERT_MESSAGE_FORMAT",
            comment: "Message for the 'insufficient balance for payment' alert. Embeds: {{ The current payments balance }}.",
        )
        let message = String.nonPluralLocalizedStringWithFormat(messageFormat, PaymentsFormat.format(
            paymentAmount: paymentBalance.amount,
            isShortForm: false,
            withCurrencyCode: true,
            withSpace: true,
        ))

        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "SETTINGS_PAYMENTS_PAYMENT_INSUFFICIENT_BALANCE_ALERT_TITLE",
                comment: "Title for the 'insufficient balance for payment' alert.",
            ),
            message: message,
        )

        // There's no point doing a "transfer in" transaction in order to
        // enable a "transfer out".
        if mode != .fromTransferOutFlow {
            actionSheet.addAction(ActionSheetAction(
                title: OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_ADD_MONEY",
                    comment: "Label for the 'add money' button in the 'send payment' UI.",
                ),
                style: .default,
            ) { [weak self] _ in
                self?.didTapAddMoneyButton()
            })
        }

        actionSheet.addAction(OWSActionSheets.cancelAction)

        presentActionSheet(actionSheet)
    }

    private func didTapAddMoneyButton() {
        switch mode {
        case .fromConversationView:
            dismiss(animated: true) {
                guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
                    owsFailDebug("could not identify frontmostViewController")
                    return
                }
                frontmostViewController.navigationController?.popToRootViewController(animated: true)
                SignalApp.shared.showAppSettings(mode: .paymentsTransferIn)
            }
        case .fromPaymentSettings:
            let paymentsTransferIn = PaymentsTransferInViewController()
            navigationController?.pushViewController(paymentsTransferIn, animated: true)
        case .fromTransferOutFlow:
            owsFailDebug("Unexpected interaction.")

            dismiss(animated: true) {
                guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
                    owsFailDebug("could not identify frontmostViewController")
                    return
                }
                guard let navigationController = frontmostViewController.navigationController else {
                    owsFailDebug("Missing navigationController.")
                    return
                }
                let paymentsTransferIn = PaymentsTransferInViewController()
                navigationController.pushViewController(paymentsTransferIn, animated: true)
            }
        }
    }

    private func showPaymentCompletionUI(
        paymentAmount: TSPaymentAmount,
        estimatedFeeAmount: TSPaymentAmount,
    ) {
        // Snapshot the conversion rate.
        let currencyConversion = self.currentCurrencyConversion

        let paymentInfo = PaymentInfo(
            recipient: recipient,
            paymentAmount: paymentAmount,
            estimatedFeeAmount: estimatedFeeAmount,
            currencyConversion: currencyConversion,
            memoMessage: memoMessage,
            isOutgoingTransfer: isOutgoingTransfer,
        )
        let actionSheet = SendPaymentCompletionActionSheet(
            mode: .payment(paymentInfo: paymentInfo),
            delegate: self,
        )
        self.actionSheet = actionSheet
        actionSheet.present(fromViewController: self)
    }

    private static func showEnablePaymentsActionSheet() {
        guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
            owsFailDebug("could not identify frontmostViewController")
            return
        }
        let title = OWSLocalizedString(
            "SETTINGS_PAYMENTS_NOT_ENABLED_ALERT_TITLE",
            comment: "Title for the 'payments not enabled' alert.",
        )
        let message = OWSLocalizedString(
            "SETTINGS_PAYMENTS_NOT_ENABLED_ALERT_MESSAGE",
            comment: "Message for the 'payments not enabled' alert.",
        )
        let actionSheet = ActionSheetController(
            title: title,
            message: message,
        )

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "SETTINGS_PAYMENTS_ENABLE_ACTION",
                comment: "Label for the 'enable payments' button in the 'payments not enabled' alert.",
            ),
            style: .default,
        ) { _ in
            Self.didTapEnablePaymentsButton()
        })

        actionSheet.addAction(OWSActionSheets.cancelAction)

        frontmostViewController.presentActionSheet(actionSheet)
    }

    private static func showNotRegisteredActionSheet() {
        guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
            owsFailDebug("could not identify frontmostViewController")
            return
        }
        let title = OWSLocalizedString(
            "SETTINGS_PAYMENTS_NOT_REGISTERED_ALERT_TITLE",
            comment: "Title for the 'payments not registered' alert.",
        )
        let message = OWSLocalizedString(
            "SETTINGS_PAYMENTS_NOT_REGISTERED_ALERT_MESSAGE",
            comment: "Message for the 'payments not registered' alert.",
        )
        let actionSheet = ActionSheetController(title: title, message: message)

        actionSheet.addAction(OWSActionSheets.okayAction)

        frontmostViewController.presentActionSheet(actionSheet)
    }

    private static func didTapEnablePaymentsButton() {
        guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
            owsFailDebug("could not identify frontmostViewController")
            return
        }
        frontmostViewController.navigationController?.popToRootViewController(animated: true)
        SignalApp.shared.showAppSettings(mode: .payments)
    }

    // MARK: - SendPaymentMemoViewDelegate

    func didChangeMemo(memoMessage: String?) {
        // Will update UI in setter.
        self.memoMessage = memoMessage?.nilIfEmpty
    }

    // MARK: - Payment Keyboard

    private func buildKeyboardView() -> UIView {
        let keyboardButtonSpacing: CGFloat = 24
        let buttonFont = UIFont.dynamicTypeTitle1Clamped
        let buttonSize = buttonFont.lineHeight * 2.2

        var buttonConfiguration: UIButton.Configuration
        if #available(iOS 26, *) {
            buttonConfiguration = .prominentGlass()
        } else {
            buttonConfiguration = .borderedProminent()
        }
        buttonConfiguration.cornerStyle = .capsule
        buttonConfiguration.titleTextAttributesTransformer = .defaultFont(buttonFont)
        buttonConfiguration.baseForegroundColor = .Signal.label
        buttonConfiguration.baseBackgroundColor = .Signal.secondaryGroupedBackground

        func buildAmountKeyboardButton(title: String, action: UIAction) -> UIButton {
            let button = UIButton(configuration: buttonConfiguration, primaryAction: action)
            button.configuration?.title = title
            return button
        }
        func buildAmountKeyboardButton(imageName: String, action: UIAction) -> UIButton {
            let button = UIButton(configuration: buttonConfiguration, primaryAction: action)
            button.configuration?.image = UIImage(named: imageName)
            return button
        }

        var keyboardRows = [UIView]()
        let buildKeyboardRow = { (buttons: [UIButton]) -> UIView in
            buttons.forEach { button in
                button.translatesAutoresizingMaskIntoConstraints = false
                NSLayoutConstraint.activate([
                    button.widthAnchor.constraint(equalToConstant: buttonSize),
                    button.widthAnchor.constraint(equalTo: button.heightAnchor),
                ])
            }

            let rowStack = UIStackView(arrangedSubviews: buttons)
            rowStack.axis = .horizontal
            rowStack.spacing = keyboardButtonSpacing
            rowStack.distribution = .fillEqually
            rowStack.alignment = .fill
            rowStack.translatesAutoresizingMaskIntoConstraints = false

            let buttonWidth: CGFloat = buttonSize * CGFloat(buttons.count)
            let spacingWidth: CGFloat = keyboardButtonSpacing * CGFloat(buttons.count - 1)
            rowStack.widthAnchor.constraint(equalToConstant: buttonWidth + spacingWidth).isActive = true

            keyboardRows.append(rowStack)

            return rowStack
        }

        func buildDecimalButton() -> UIButton {
            if let decimalSeparator = PaymentsConstants.decimalSeparator.nilIfEmpty {
                return buildAmountKeyboardButton(
                    title: decimalSeparator,
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedDecimal()
                    },
                )
            }
            return buildAmountKeyboardButton(
                imageName: "decimal-32",
                action: UIAction { [weak self] _ in
                    self?.keyboardPressedDecimal()
                },
            )
        }

        // Don't localize; use Arabic numeral literals.
        //
        // TODO: Localize or remove custom keyboard to support payments
        //       in locales that don't use arabic numerals.
        let keyboardStackView = UIStackView(arrangedSubviews: [
            buildKeyboardRow([
                buildAmountKeyboardButton(
                    title: "1",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("1")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "2",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("2")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "3",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("3")
                    },
                ),
            ]),
            buildKeyboardRow([
                buildAmountKeyboardButton(
                    title: "4",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("4")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "5",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("5")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "6",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("6")
                    },
                ),
            ]),
            buildKeyboardRow([
                buildAmountKeyboardButton(
                    title: "7",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("7")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "8",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("8")
                    },
                ),
                buildAmountKeyboardButton(
                    title: "9",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("9")
                    },
                ),
            ]),
            buildKeyboardRow([
                buildDecimalButton(),
                buildAmountKeyboardButton(
                    title: "0",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedNumeral("0")
                    },
                ),
                buildAmountKeyboardButton(
                    imageName: "backspace-32",
                    action: UIAction { [weak self] _ in
                        self?.keyboardPressedBackspace()
                    },
                ),
            ]),
        ])
        keyboardStackView.axis = .vertical
        keyboardStackView.spacing = keyboardButtonSpacing
        keyboardStackView.alignment = .center

        return keyboardStackView
    }

    private func keyboardPressedNumeral(_ numeralString: String) {
        let inputString = amount.inputString.append(.digit(digit: numeralString))
        updateAmountString(inputString)
    }

    private func keyboardPressedDecimal() {
        let inputString = amount.inputString.append(.decimal)
        updateAmountString(inputString)
    }

    private func keyboardPressedBackspace() {
        let inputString = amount.inputString.removeLastChar()
        updateAmountString(inputString)
    }

    private func updateAmountString(_ inputString: InputString) {
        switch amount {
        case .mobileCoin:
            amounts.set(
                currentAmount: .mobileCoin(
                    inputString: inputString,
                    exactAmount: nil,
                ),
                otherCurrencyAmount: nil,
            )
        case .fiatCurrency(_, let oldCurrencyConversion):
            let newCurrencyConversion = self.currentCurrencyConversion ?? oldCurrencyConversion
            amounts.set(
                currentAmount: .fiatCurrency(
                    inputString: inputString,
                    currencyConversion: newCurrencyConversion,
                ),
                otherCurrencyAmount: nil,
            )
        }
    }

    private var parsedPaymentAmount: TSPaymentAmount {
        switch amount {
        case .mobileCoin(_, let exactAmount):
            if let exactAmount {
                return exactAmount
            }
            let picoMob = PaymentsConstants.convertMobToPicoMob(amount.asDouble)
            return TSPaymentAmount(currency: .mobileCoin, picoMob: picoMob)
        case .fiatCurrency(_, let currencyConversion):
            return currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
        }
    }

    // MARK: - SendPaymentHelperDelegate

    func balanceDidChange() {
        updateBalanceLabel()
    }

    func currencyConversionDidChange() {
        guard isViewLoaded else {
            return
        }
        guard nil != currentCurrencyConversion else {
            Logger.warn("Currency conversion unavailable.")
            resetContents()
            return
        }

        if let otherCurrencyAmount {
            amounts.set(
                currentAmount: updateAmount(self.amount),
                otherCurrencyAmount: updateAmount(otherCurrencyAmount),
            )
        } else {
            amounts.set(
                currentAmount: updateAmount(self.amount),
                otherCurrencyAmount: nil,
            )
        }

        updateAmountLabels()
        updateBalanceLabel()
    }

    // MARK: - SendPaymentCompletionDelegate

    func didSendPayment(success: Bool) {
        let delegate = self.delegate
        dismiss(animated: true) {
            delegate?.didSendPayment(success: success)
        }
    }

    // MARK: - AmountsDelegate

    fileprivate func amountDidChange(oldValue: Amount, newValue: Amount) {
        guard isViewLoaded else { return }
        updateAmountLabels()
    }
}

// MARK: - Amount

private enum Amount {
    // inputString should be a raw double strings: e.g. 123456.789.
    // It should not be formatted: e.g. 123,456.789
    case mobileCoin(inputString: InputString, exactAmount: TSPaymentAmount?)
    case fiatCurrency(inputString: InputString, currencyConversion: CurrencyConversionInfo)

    var isFiat: Bool {
        switch self {
        case .mobileCoin:
            return false
        case .fiatCurrency:
            return true
        }
    }

    var isZero: Bool {
        switch self {
        case .mobileCoin(let inputString, _):
            return inputString.isZero
        case .fiatCurrency(let inputString, _):
            return inputString.isZero
        }
    }

    var inputString: InputString {
        switch self {
        case .mobileCoin(let inputString, _):
            return inputString
        case .fiatCurrency(let inputString, _):
            return inputString
        }
    }

    var currencyConversion: CurrencyConversionInfo? {
        switch self {
        case .mobileCoin:
            return nil
        case .fiatCurrency(_, let currencyConversion):
            return currencyConversion
        }
    }

    var asDouble: Double {
        inputString.asDouble
    }

    var formatForDisplay: String {
        switch self {
        case .mobileCoin:
            guard
                let mobString = PaymentsFormat.format(
                    mob: asDouble,
                    isShortForm: false,
                )
            else {
                owsFailDebug("Couldn't format MOB string: \(inputString.asString(formatMode: .parsing))")
                return inputString.asString(formatMode: .display)
            }
            return mobString
        case .fiatCurrency:
            guard
                let fiatString = PaymentsFormat.format(
                    fiatCurrencyAmount: asDouble,
                    minimumFractionDigits: 0,
                )
            else {
                owsFailDebug("Couldn't format fiat string: \(inputString.asString(formatMode: .parsing))")
                return inputString.asString(formatMode: .display)
            }
            return fiatString
        }
    }

    var formatAsKeyboardInput: String {
        inputString.formatAsKeyboardInput
    }

    func formatForDisplay(withSpace: Bool) -> NSAttributedString {
        switch self {
        case .mobileCoin:
            return PaymentsFormat.attributedFormat(
                mobileCoinString: formatForDisplay,
                withSpace: withSpace,
            )
        case .fiatCurrency(_, let currencyConversion):
            return PaymentsFormat.attributedFormat(
                currencyString: formatForDisplay,
                currencyCode: currencyConversion.currencyCode,
                withSpace: withSpace,
            )
        }
    }

    func formatAsKeyboardInputAttributed(withSpace: Bool) -> NSAttributedString {
        switch self {
        case .mobileCoin:
            return PaymentsFormat.attributedFormat(
                mobileCoinString: formatAsKeyboardInput,
                withSpace: withSpace,
            )
        case .fiatCurrency(_, let currencyConversion):
            return PaymentsFormat.attributedFormat(
                currencyString: formatAsKeyboardInput,
                currencyCode: currencyConversion.currencyCode,
                withSpace: withSpace,
            )
        }
    }
}

// MARK: -

@MainActor
private protocol AmountsDelegate: AnyObject {
    func amountDidChange(oldValue: Amount, newValue: Amount)
}

// MARK: -

@MainActor
private class Amounts {

    weak var delegate: AmountsDelegate?

    static var defaultMCAmount: Amount {
        .mobileCoin(
            inputString: InputString.defaultString(isFiat: false),
            exactAmount: nil,
        )
    }

    static var defaultFiatAmount: Amount? {
        let currentCurrencyCode = SSKEnvironment.shared.paymentsCurrenciesRef.currentCurrencyCode
        guard let currencyConversion = SSKEnvironment.shared.paymentsCurrenciesRef.conversionInfo(forCurrencyCode: currentCurrencyCode) else {
            return nil
        }
        return .fiatCurrency(
            inputString: InputString.defaultString(isFiat: true),
            currencyConversion: currencyConversion,
        )
    }

    fileprivate private(set) var currentAmount: Amount = Amounts.defaultMCAmount
    fileprivate private(set) var otherCurrencyAmount: Amount?

    func set(currentAmount: Amount, otherCurrencyAmount: Amount?) {
        let oldValue = self.currentAmount

        self.currentAmount = currentAmount
        self.otherCurrencyAmount = otherCurrencyAmount

        delegate?.amountDidChange(oldValue: oldValue, newValue: currentAmount)
    }

    func reset() {
        set(currentAmount: Self.defaultMCAmount, otherCurrencyAmount: nil)
    }
}

// MARK: -

private enum FormatMode {
    case display
    case parsing
}

// MARK: -

private enum InputChar: Equatable {
    case digit(digit: String)
    case decimal

    func asString(formatMode: FormatMode) -> String {
        switch self {
        case .digit(let digit):
            return digit
        case .decimal:
            switch formatMode {
            case .display:
                return PaymentsConstants.decimalSeparator
            case .parsing:
                return "."
            }
        }
    }

    static func isDigit(_ value: String) -> Bool {
        "0123456789".contains(value)
    }
}

// MARK: -

private struct InputString: Equatable {
    let chars: [InputChar]
    let isFiat: Bool

    init(_ chars: [InputChar], isFiat: Bool) {
        self.chars = chars
        self.isFiat = isFiat
    }

    static func forDouble(_ value: Double, isFiat: Bool) -> InputString {
        guard let stringValue = PaymentsFormat.formatAsDoubleString(value) else {
            owsFailDebug("Couldn't format double: \(value)")
            return Self.defaultString(isFiat: isFiat)
        }
        return parseString(stringValue, isFiat: isFiat)
    }

    static func parseString(_ stringValue: String, isFiat: Bool) -> InputString {
        var result = InputString.defaultString(isFiat: isFiat)
        for char in stringValue {
            let charString = String(char)
            if charString == InputChar.decimal.asString(formatMode: .parsing) {
                result = result.append(InputChar.decimal)
            } else if InputChar.isDigit(charString) {
                result = result.append(InputChar.digit(digit: charString))
            } else {
                owsFailDebug("Ignoring invalid character: \(charString)")
            }
        }
        return result
    }

    static var defaultChar: InputChar { .digit(digit: "0") }
    static func defaultString(isFiat: Bool) -> InputString {
        InputString([defaultChar], isFiat: isFiat)
    }

    func append(_ char: InputChar) -> InputString {
        let result: InputString = {
            switch char {
            case .digit:
                // Avoid leading zeroes.
                //
                // "00" should be "0"
                // "01" should be "1"
                if self == Self.defaultString(isFiat: isFiat) {
                    return InputString([char], isFiat: isFiat)
                } else {
                    return InputString(chars + [char], isFiat: isFiat)
                }
            case .decimal:
                if hasDecimal {
                    // Don't allow two decimals.
                    return self
                } else {
                    return InputString(chars + [char], isFiat: isFiat)
                }
            }
        }()
        guard result.isValid else {
            Logger.warn("Invalid result: \(self.asString(formatMode: .parsing)) -> \(result.asString(formatMode: .parsing))")
            return self
        }
        return result
    }

    func removeLastChar() -> InputString {
        if chars.count > 1 {
            return InputString(
                Array(chars.prefix(chars.count - 1)),
                isFiat: isFiat,
            )
        } else {
            return Self.defaultString(isFiat: isFiat)
        }
    }

    var isValid: Bool {
        digitCountBeforeDecimal <= maxDigitsBeforeDecimal &&
            digitCountAfterDecimal <= maxDigitsAfterDecimal
    }

    var isZero: Bool {
        !isNonZero
    }

    var isNonZero: Bool {
        for char in chars {
            switch char {
            case .digit(let digit):
                if digit != "0" {
                    return true
                }
            case .decimal:
                continue
            }
        }
        return false
    }

    static func maxDigitsBeforeDecimal(isFiat: Bool) -> UInt {
        // Max transaction size: 1 billion MOB.
        return isFiat ? 9 : PaymentsConstants.maxMobNonDecimalDigits
    }

    static func maxDigitsAfterDecimal(isFiat: Bool) -> UInt {
        // picoMob
        isFiat ? 2 : 12
    }

    var maxDigitsBeforeDecimal: UInt {
        Self.maxDigitsBeforeDecimal(isFiat: isFiat)
    }

    var maxDigitsAfterDecimal: UInt {
        Self.maxDigitsAfterDecimal(isFiat: isFiat)
    }

    var digitsBeforeDecimal: [String] {
        var result = [String]()
        for char in chars {
            switch char {
            case .digit(let digit):
                result.append(digit)
            case .decimal:
                return result
            }
        }
        return result
    }

    var digitCountBeforeDecimal: Int {
        digitsBeforeDecimal.count
    }

    var digitsAfterDecimal: [String] {
        var result = [String]()
        var hasPassedDecimal = false
        for char in chars {
            switch char {
            case .digit(let digit):
                if hasPassedDecimal {
                    result.append(digit)
                }
            case .decimal:
                hasPassedDecimal = true
            }
        }
        return result
    }

    var digitCountAfterDecimal: Int {
        digitsAfterDecimal.count
    }

    var hasDecimal: Bool {
        !Array(chars.filter { $0 == .decimal }).isEmpty
    }

    func asString(formatMode: FormatMode) -> String {
        chars.map { $0.asString(formatMode: formatMode) }.joined()
    }

    var asCharString: String {
        "[" + chars.map { $0.asString(formatMode: .parsing) }.joined(separator: ", ") + "]"
    }

    var asDouble: Double {
        Self.parseAsDouble(asString(formatMode: .parsing))
    }

    private static func parseAsDouble(_ stringValue: String) -> Double {
        guard let value = Double(stringValue.ows_stripped()) else {
            // inputString should be parseable at all times.
            owsFailDebug("Invalid inputString.")
            return 0
        }
        return value
    }

    // We need to manually format (more or less) the exact input string
    // when redering the "keyboard input" so that every keystroke of
    // the custom keyboard updates the "keyboard input" in a WYSIWYG
    // fashion, e.g. if the user enters "0.0000000", we need to render
    // the exact number of zeros the user has entered.
    var formatAsKeyboardInput: String {
        let groupingSeparator = PaymentsConstants.groupingSeparator
        let decimalSeparator = PaymentsConstants.decimalSeparator
        let groupingSize = PaymentsConstants.groupingSize
        let shouldUseGroupingSeparatorsAfterDecimal = PaymentsConstants.shouldUseGroupingSeparatorsAfterDecimal

        func addGroupingSeparators(digits: [String], afterGroupsOfSize groupSize: Int) -> [String] {
            var result = [String]()
            for (index, digit) in digits.enumerated() {
                if
                    index != 0,
                    index % groupSize == 0
                {
                    result.append(groupingSeparator)
                }
                result.append(digit)
            }
            return result
        }

        var formattedChars = [String]()

        // e.g.:
        //
        // 1,234,567.890,123,456.
        // 0.000,000,001
        formattedChars.append(contentsOf: addGroupingSeparators(
            digits: digitsBeforeDecimal.reversed(),
            afterGroupsOfSize: groupingSize,
        ).reversed())
        if hasDecimal {
            formattedChars.append(decimalSeparator)
        }

        if shouldUseGroupingSeparatorsAfterDecimal {
            formattedChars.append(contentsOf: addGroupingSeparators(
                digits: digitsAfterDecimal,
                afterGroupsOfSize: groupingSize,
            ))
        } else {
            formattedChars.append(contentsOf: digitsAfterDecimal)
        }

        let formatted = formattedChars.joined()
        return formatted
    }
}
