// Copyright © 2022 Olo Inc. All rights reserved.
// This software is made available under the Olo Pay SDK License (See LICENSE.md file)
import Foundation
import OloPaySDK
import PassKit
import os

@objc(OlopaysdkReactNative)
class OlopaysdkReactNative: NSObject, OPApplePayLauncherDelegate {
    private let _logger = Logger(
        subsystem: Bundle.main.bundleIdentifier!, category: "@olo/pay-react-native")

    private var _applePayLauncher: OPApplePayLauncher = OPApplePayLauncher()
    private var _applePayResolver: RCTPromiseResolveBlock? = nil
    private var _applePayRejector: RCTPromiseRejectBlock? = nil
    private var _applePayPaymentMethod: OloPaySDK.OPPaymentMethodProtocol? = nil
    private var _applePayConfig: OPApplePayConfiguration? = nil
    private var _initializeMetadataCalled = false
    private var _hasEmittedDigitalWalletReadyEvent = false

    private let _applePaySemaphore = DispatchSemaphore(value: 1)
    private let _applePayInitializedSemaphore = DispatchSemaphore(value: 1)
    private let _sdkInitializingSemaphore = DispatchSemaphore(value: 1)
    private let _sdkInitializedSemaphore = DispatchSemaphore(value: 1)

    // WARNING: NEVER ACCESS/MODIFY THESE VARIABLES DIRECTLY. USE THREAD-SAFE GETTERS AND SETTERS
    private var _sdkInitialized = false
    private var _applePayInitialized = false

    private var sdkInitialized: Bool {
        get {
            return controlledReturn(with: _sdkInitializedSemaphore) {
                return self._sdkInitialized
            }
        }

        set(newValue) {
            let previousReadyState = applePayReady

            controlledExecute(with: _sdkInitializedSemaphore) {
                self._sdkInitialized = newValue
            }

            emitDigitalWalletReadyEvent(previousState: previousReadyState)
        }
    }

    var applePayConfig: OPApplePayConfiguration? {
        get {
            return controlledReturn(with: _applePayInitializedSemaphore) {
                return self._applePayConfig
            }
        }

        set(newValue) {
            let previousReadyState = applePayReady

            controlledExecute(with: _applePayInitializedSemaphore) {
                self._applePayConfig = newValue
                self._applePayLauncher.configuration = newValue
            }

            emitDigitalWalletReadyEvent(previousState: previousReadyState)
        }
    }

    var applePayInitialized: Bool {
        applePayConfig != nil
    }

    var applePayReady: Bool {
        return sdkInitialized && applePayInitialized && OPApplePayLauncher.canMakePayments()
    }

    @objc(initializeOloPay:withResolver:withRejecter:)
    func initializeOloPay(
        args: NSDictionary,
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        dispatchToBackgroundThread(with: _sdkInitializingSemaphore, autoRelease: false) {
            self.sdkInitialized = false

            let productionEnvironment =
                args.getBool(DataKeys.ProductionEnvironmentKey) ?? self.defaultProductionEnvironment

            OloPayApiInitializer().setup(for: productionEnvironment ? .production : .test) {
                self.sdkInitialized = true

                // Check if digital wallet config was passed during initialization
                if args.getDictionary(DataKeys.DigitalWalletConfigParameterKey) != nil {
                    // Configure digital wallet inside completion handler to avoid race condition
                    self.performDigitalWalletConfiguration(
                        args: args,
                        resolve: resolve,
                        reject: reject
                    )
                } else {
                    // No digital wallet config - resolve immediately
                    resolve(nil)
                }

                self._sdkInitializingSemaphore.signal()
            }
        }
    }

    @objc(initializeMetadata:withResolver:withRejecter:)
    func initializeMetadata(
        _ args: NSDictionary,
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        if !_initializeMetadataCalled {
            _initializeMetadataCalled = true

            let hybridVersion = args.getString(
                DataKeys.HybridSdkVersionKey,
                defaultValue: defaultVersionString
            )

            let hybridBuildType = args.getString(
                DataKeys.HybridBuildTypeKey,
                defaultValue: DataKeys.HybridBuildTypeInternalValue
            )

            setSdkWrapperInfo(version: hybridVersion, buildType: hybridBuildType)
        }

        resolve(nil)
    }

    @objc(isOloPayInitialized:withRejecter:)
    func isOloPayInitialized(
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        resolve([DataKeys.IsInitializedKey: sdkInitialized])
    }

    @objc(isDigitalWalletInitialized:withRejecter:)
    func isDigitalWalletInitialized(
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        resolve([DataKeys.IsInitializedKey: applePayInitialized])
    }

    @objc(isDigitalWalletReady:withRejecter:)
    func isDigitalWalletReady(
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        resolve([DataKeys.DigitalWalletIsReadyKey: applePayReady])
    }

    @objc(getDigitalWalletPaymentMethod:withResolver:withRejecter:)
    func getDigitalWalletPaymentMethod(
        args: NSDictionary,
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        let baseError = "Unable to create payment method"

        guard sdkInitialized else {
            reject(
                ErrorCodes.SdkUninitialized,
                "\(baseError): Olo Pay SDK has not been initialized",
                nil
            )
            return
        }

        guard OPApplePayLauncher.canMakePayments() else {
            reject(
                ErrorCodes.ApplePayUnsupported,
                "\(baseError): Apple Pay is not supported on this device",
                nil
            )
            return
        }

        guard applePayInitialized else {
            reject(
                ErrorCodes.DigitalWalletUninitialized,
                "\(baseError): Apple Pay has not been initialized",
                nil
            )
            return
        }

        DispatchQueue.global(qos: .userInteractive).async {
            self._applePaySemaphore.wait()

            var amountDouble: Double = 0.0
            var validateLineItems: Bool
            var lineItems: [PKPaymentSummaryItem]? = nil
            let dictArgs = args.toSwiftDictionary()

            do {
                amountDouble = try dictArgs.getOrReject(
                    for: DataKeys.ApplePayAmountKey,
                    baseError: baseError,
                    reject: reject
                )
                
                validateLineItems = try dictArgs.getOrReject(
                    for: DataKeys.ValidateLineItemsKey,
                    withDefault: true,
                    baseError: baseError,
                    reject: reject
                )
                
                lineItems = try self.getLineItemsFromArgs(
                    args: dictArgs,
                    baseError: baseError,
                    reject: reject
                )
            } catch {
                self.applePayCleanup()
                return
            }

            guard amountDouble >= 0 else {
                reject(
                    ErrorCodes.InvalidParameter,
                    "\(baseError): \(DataKeys.ApplePayAmountKey) cannot be negative",
                    nil
                )
                self.applePayCleanup()
                return
            }

            let amount = NSDecimalNumber(decimal: Decimal(amountDouble))

            var errorMessage: String
            var errorCode: String = ""
            do {
                try self._applePayLauncher.present(
                    for: amount,
                    with: lineItems,
                    validateLineItems: validateLineItems
                )

                self._applePayResolver = resolve
                self._applePayRejector = reject
                return
            } catch OPApplePayLauncherError.applePayNotSupported {
                errorCode = ErrorCodes.ApplePayUnsupported
                errorMessage = "Apple Pay is not supported on this device"
            } catch OPApplePayLauncherError.configurationNotSet {
                errorCode = ErrorCodes.DigitalWalletUninitialized
                errorMessage = "Apple Pay has not been initialized"
            } catch OPApplePayLauncherError.delegateNotSet {
                errorCode = ErrorCodes.DigitalWalletUninitialized
                errorMessage = "Apple Pay has not been initialized"
            } catch OPApplePayLauncherError.emptyMerchantId {
                errorCode = ErrorCodes.ApplePayEmptyMerchantId
                errorMessage = "Merchant ID cannot be empty"
            } catch OPApplePayLauncherError.emptyCompanyLabel {
                errorCode = ErrorCodes.EmptyCompanyLabel
                errorMessage = "Company label cannot be empty"
            } catch OPApplePayLauncherError.invalidCountryCode {
                errorCode = ErrorCodes.InvalidCountryCode
                errorMessage = "Country code cannot be empty"
            } catch OPApplePayLauncherError.lineItemTotalMismatchError {
                errorCode = ErrorCodes.LineItemsTotalMismatch
                errorMessage = "The total of the line items does not match the total amount"
            } catch {
                errorCode = ErrorCodes.GeneralError
                errorMessage = "Unexpected error occurred"
            }

            reject(errorCode, "\(baseError): \(errorMessage)", nil)
            self.applePayCleanup()
        }
    }

    private func performDigitalWalletConfiguration(
        args: NSDictionary,
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        let baseError =
            applePayConfig == nil
            ? "Unable to initialize Apple Pay" : "Unable to update Apple Pay configuration"

        _applePayLauncher.delegate = self

        self.applePayConfig = nil

        guard
            let newConfiguration = self.getApplePayConfig(
                args: args, reject: reject, baseError: baseError)
        else {
            return
        }

        self.applePayConfig = newConfiguration
        resolve(nil)
    }

    @objc(updateDigitalWalletConfig:withResolver:withRejecter:)
    private func updateDigitalWalletConfig(
        args: NSDictionary,
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        let baseError =
            applePayConfig == nil
            ? "Unable to initialize Apple Pay" : "Unable to update Apple Pay configuration"

        dispatchToBackgroundThread(with: _applePaySemaphore) {
            guard self.sdkInitialized else {
                reject(
                    ErrorCodes.SdkUninitialized,
                    "\(baseError): Olo Pay SDK has not been initialized",
                    nil
                )
                return
            }

            self.performDigitalWalletConfiguration(
                args: args,
                resolve: resolve,
                reject: reject
            )
        }
    }

    private func getApplePayConfig(
        args: NSDictionary?,
        reject: @escaping RCTPromiseRejectBlock,
        baseError: String
    ) -> OPApplePayConfiguration? {
        guard let digitalWalletConfig = args?.getDictionary(DataKeys.DigitalWalletConfigParameterKey) else {
            reject(
                ErrorCodes.MissingParameter,
                "\(baseError): Missing parameter '\(DataKeys.ApplePayMerchantIdParameterKey)'",
                nil
            )
            return nil
        }

        guard let applePayConfig = digitalWalletConfig.getDictionary(DataKeys.ApplePayConfigParameterKey) else {
            reject(
                ErrorCodes.MissingParameter,
                "\(baseError): Missing parameter '\(DataKeys.ApplePayConfigParameterKey)'",
                nil
            )
            return nil
        }

        guard let merchantId = applePayConfig.getString(DataKeys.ApplePayMerchantIdParameterKey)
        else {
            reject(
                ErrorCodes.MissingParameter,
                "\(baseError): Missing parameter \(DataKeys.ApplePayMerchantIdParameterKey)",
                nil
            )
            return nil
        }

        if merchantId.isEmpty {
            reject(
                ErrorCodes.InvalidParameter,
                "\(baseError): Value for \(DataKeys.ApplePayMerchantIdParameterKey) cannot be empty",
                nil
            )
            return nil
        }

        guard let companyLabel = digitalWalletConfig.getString(
                DataKeys.ApplePayCompanyLabelParameterKey)
        else {
            reject(
                ErrorCodes.MissingParameter,
                "\(baseError): Missing parameter \(DataKeys.ApplePayCompanyLabelParameterKey)",
                nil
            )
            return nil
        }

        if companyLabel.isEmpty {
            reject(
                ErrorCodes.InvalidParameter,
                "\(baseError): Value for \(DataKeys.ApplePayCompanyLabelParameterKey) cannot be empty",
                nil
            )
            return nil
        }

        let currencyCodeString =
            digitalWalletConfig
            .getString(
                DataKeys.ApplePayCurrencyCodeKey, defaultValue: defaultCurrencyCode.description
            )
            .lowercased()

        // These are the only two currencies we support currently
        // NOTE: We should add a "from()" method on OPCurrencyCode in the iOS SDK to avoid having to do this
        guard currencyCodeString == "usd" || currencyCodeString == "cad" else {
            reject(
                ErrorCodes.InvalidParameter,
                "\(baseError): \(currencyCodeString.uppercased()) is not supported",
                nil
            )
            return nil
        }

        var currencyCode = defaultCurrencyCode
        if currencyCodeString == "cad" {
            currencyCode = .cad
        }

        let countryCode = digitalWalletConfig.getString(
            DataKeys.ApplePayCountryCodeKey,
            defaultValue: defaultCountryCode
        )

        let fullBillingAddressRequired = digitalWalletConfig.getBool(
            DataKeys.DigitalWalletFullBillingAddressRequiredParameterKey,
            defaultValue: defaultFullBillingAddressRequired
        )

        let phoneNumberRequired = digitalWalletConfig.getBool(
            DataKeys.DigitalWalletPhoneNumberRequiredParameterKey,
            defaultValue: defaultPhoneNumberRequired
        )

        let fullNameRequired = digitalWalletConfig.getBool(
            DataKeys.DigitalWalletFullNameRequiredParameterKey,
            defaultValue: defaultFullNameRequired
        )

        let emailRequired = digitalWalletConfig.getBool(
            DataKeys.DigitalWalletEmailRequiredParameterKey,
            defaultValue: defaultEmailRequired
        )

        let fullPhoneticNameRequired = applePayConfig.getBool(
            DataKeys.DigitalWalletFullPhoneticNameRequiredParameterKey,
            defaultValue: defaultFullPhoneticNameRequired
        )

        return OPApplePayConfiguration(
            merchantId: merchantId,
            companyLabel: companyLabel,
            currencyCode: currencyCode,
            countryCode: countryCode,
            emailRequired: emailRequired,
            phoneNumberRequired: phoneNumberRequired,
            fullNameRequired: fullNameRequired,
            fullBillingAddressRequired: fullBillingAddressRequired,
            fullPhoneticNameRequired: fullPhoneticNameRequired
        )
    }

    public func paymentMethodCreated(
        from launcher: OloPaySDK.OPApplePayLauncherProtocol,
        with paymentMethod: OloPaySDK.OPPaymentMethodProtocol
    ) -> NSError? {
        guard _applePayResolver != nil else {
            let message = "Unexpected error: Saved method call resolver is nil"
            _logger.error("\(message)")
            return OPError(errorType: .generalError, description: message)
        }

        _applePayPaymentMethod = paymentMethod

        // NOTE: This will trigger a success flow in the Apple Pay Sheet... If the actual order placement fails with the Olo Odering API it
        // will be up to the app developer to display a different error in their app to let the end user know that the Apple Pay flow did not
        // succeed. The only way around this limitation would be to make the call to the basket submit endpoint to the olo ordering api for
        // them. We would have to take as method parameters everything needed to make the api call on their behalf, and return the result of
        // the API call to them. This would be a rather large undertaking to get it right
        return nil
    }

    public func applePayDismissed(
        from launcher: OPApplePayLauncherProtocol,
        with status: OPApplePayStatus,
        error: Error?
    ) {
        let baseError = "Unable to create payment method"
        guard let applePayResolver = _applePayResolver else {
            // If there is no saved resolver there is nothing to be done except log the problem and return
            _logger.error("Unexpected error: Saved method call resolver is nil")
            applePayCleanup()
            return
        }

        var applePayData: NSDictionary? = nil

        if status == .error {
            return rejectAndCleanup(
                ErrorCodes.ApplePayError,
                "\(baseError): \(error!.localizedDescription)"
            )
        } else if status == .timeout {
            return rejectAndCleanup(
                ErrorCodes.ApplePayTimeout,
                "\(baseError): Payment was not processed in time"
            )
        } else if status == .success && _applePayPaymentMethod == nil {
            return rejectAndCleanup(
                ErrorCodes.UnexpectedError,
                "Unexpected error: Payment method is nil"
            )
        } else if status == .success && _applePayPaymentMethod != nil {
            applePayData = [DataKeys.PaymentMethodKey: _applePayPaymentMethod!.toDictionary()]
        } else if status == .userCancellation {
            applePayData = [DataKeys.PaymentMethodKey: NSNull()]
        }

        applePayCleanup()
        applePayResolver(applePayData)
    }

    private func rejectAndCleanup(
        _ errorCode: String,
        _ errorMessage: String
    ) {
        guard let applePayRejector = _applePayRejector else {
            _logger.error("Unexpected error: Saved method call rejector is nil")
            applePayCleanup()
            return
        }

        applePayRejector(errorCode, errorMessage, nil)
        applePayCleanup()
    }

    private func emitDigitalWalletReadyEvent(previousState: Bool) {
        let currentState = applePayReady

        // Filter out redundant emitting of this event if the actual state value hasn't changed, but only if it is not
        // the first time the event has been emitted
        if _hasEmittedDigitalWalletReadyEvent && currentState == previousState {
            return
        }

        _hasEmittedDigitalWalletReadyEvent = true
        OloPayEventEmitter.emitDigitalWalletReadyEvent(isReady: currentState)
    }

    private func applePayCleanup() {
        _applePayPaymentMethod = nil
        _applePayResolver = nil
        _applePayRejector = nil
        _applePaySemaphore.signal()
    }

    private func setSdkWrapperInfo(
        version: String,
        buildType: String
    ) {
        let versionStrings = version.components(separatedBy: ".")
        let majorVersionString =
            versionStrings.count > MajorVersionIndex ? versionStrings[MajorVersionIndex] : "0"
        let minorVersionString =
            versionStrings.count > MinorVersionIndex ? versionStrings[MinorVersionIndex] : "0"
        let buildVersionString =
            versionStrings.count > BuildVersionIndex ? versionStrings[BuildVersionIndex] : "0"

        let sdkWrapperInfo = OPSdkWrapperInfo(
            withMajorVersion: Int(majorVersionString) ?? 0,
            withMinorVersion: Int(minorVersionString) ?? 0,
            withBuildVersion: Int(buildVersionString) ?? 0,
            withSdkBuildType: buildType == DataKeys.HybridBuildTypePublicValue
                ? .publicBuild : .internalBuild,
            withSdkPlatform: .reactNative
        )

        OloPayAPI.sdkWrapperInfo = sdkWrapperInfo
    }

    private func getLineItemsFromArgs(
        args: [String: Any],
        baseError: String,
        reject: @escaping RCTPromiseRejectBlock
    ) throws -> [PKPaymentSummaryItem]? {
        do {
            let lineItemError = "\(baseError) - Unable to parse LineItems"
            var lineItems: [PKPaymentSummaryItem]? = nil

            if let lineItemsArray: [[String: Any]] = try args.getOrReject(
                for: DataKeys.LineItemsKey,
                withDefault: nil,
                baseError: baseError,
                reject: reject
            ), !lineItemsArray.isEmpty {
                lineItems = []

                for item in lineItemsArray {
                    let label = try item.getStringOrReject(
                        for: DataKeys.LineItemLabelKey,
                        baseError: lineItemError,
                        acceptEmptyValue: false,
                        reject: reject
                    )

                    let amount: Double = try item.getOrReject(
                        for: DataKeys.LineItemAmountKey,
                        baseError: lineItemError,
                        reject: reject
                    )

                    let type: PKPaymentSummaryItemType
                    let statusString = try item.getStringOrReject(
                        for: DataKeys.LineItemStatusKey,
                        withDefault: DataKeys.LineItemFinalStatusKey,
                        baseError: lineItemError,
                        acceptEmptyValue: false,
                        reject: reject
                    )

                    switch statusString {
                    case DataKeys.LineItemFinalStatusKey:
                        type = .final
                    case DataKeys.LineItemPendingStatusKey:
                        type = .pending
                    default:
                        reject(
                            ErrorCodes.InvalidParameter,
                            "\(lineItemError): '\(statusString)' is not a LineItemStatus enum value",
                            nil
                        )
                        throw OloError.InvalidKeyError
                    }

                    let typeString = try item.getStringOrReject(
                        for: DataKeys.LineItemTypeKey,
                        baseError: lineItemError,
                        acceptEmptyValue: false,
                        reject: reject
                    )

                    guard DataKeys.LineItemTypeKeys.contains(typeString) else {
                        reject(
                            ErrorCodes.InvalidParameter,
                            "\(lineItemError): '\(typeString)' is not a LineItemType enum value",
                            nil
                        )
                        throw OloError.InvalidKeyError
                    }

                    lineItems!.append(
                        PKPaymentSummaryItem(
                            label: label,
                            amount: NSDecimalNumber(value: amount),
                            type: type
                        ))
                }
            }

            return lineItems
        } catch let error {
            throw error
        }
    }

    // Default values
    private let defaultProductionEnvironment = true
    private let defaultCountryCode = "US"
    private let defaultCurrencyCode = OPCurrencyCode.usd
    private let defaultFullBillingAddressRequired = false
    private let defaultPhoneNumberRequired = false
    private let defaultFullNameRequired = false
    private let defaultFullPhoneticNameRequired = false
    private let defaultEmailRequired = false
    private let MajorVersionIndex = 0
    private let MinorVersionIndex = 1
    private let BuildVersionIndex = 2
    private let defaultVersionString = "0.0.0"
}
