// 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 Capacitor
import OloPaySDK
import PassKit
import os

/**
 * Please read the Capacitor iOS Plugin Development Guide
 * here: https://capacitorjs.com/docs/plugins/ios
 */
@objc(OloPaySDKPlugin)
public class OloPaySDKPlugin: CAPPlugin, OPApplePayLauncherDelegate {
    private let _logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "@olo/pay-capacitor")
    
    private var _applePayLauncher = OPApplePayLauncher()
    private var _applePayPluginCall: CAPPluginCall? = nil
    private var _applePayPaymentMethod: OloPaySDK.OPPaymentMethodProtocol? = nil
    private var _applePayConfig: OPApplePayConfiguration? = nil

    private var _initializeInternalCalled = 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 THIS VARIABLE DIRECTLY. USE THREAD-SAFE GETTERS AND SETTERS
    private var _sdkInitialized = 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 func initializeInternal(_ call: CAPPluginCall) {
        if (!_initializeInternalCalled) {
            _initializeInternalCalled = true
            let hybridVersion = call.getString(DataKeys.HybridSdkVersionKey, defaultVersionString)
            let hybridBuildType = call.getString(DataKeys.HybridBuildTypeKey, DataKeys.HybridBuildTypeInternalValue)
            setSdkWrapperInfo(version: hybridVersion, buildType: hybridBuildType)
        }
        
        call.resolve()
    }
    
    @objc func initialize(_ call: CAPPluginCall) {
        dispatchToBackgroundThread(with: _sdkInitializingSemaphore, autoRelease: false) {
            self.sdkInitialized = false
            do {
                let productionEnvironment = try call.getOrReject(
                    for: DataKeys.ProductionEnvironmentKey,
                    baseError: "Unable to initialize Olo Pay SDK",
                    withDefault: self.defaultProductionEnvironment
                )
                
                OloPayApiInitializer().setup(for: productionEnvironment ? .production : .test) {
                    self.sdkInitialized = true
                    self._sdkInitializingSemaphore.signal()
                }
            } catch {
                return
            }
            
            if let options = call.getOptions(), call.keyExists(DataKeys.DigitalWalletConfig, in: options) {
                self.updateDigitalWalletConfiguration(call)
            } else {
                call.resolve()
            }
        }
    }
    
    @objc func updateDigitalWalletConfiguration(_ call: CAPPluginCall) {
        let baseError = applePayConfig == nil
            ? "Unable to initialize Apple Pay" : "Unable to update Apple Pay configuration"

        _applePayLauncher.delegate = self

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

            self.applePayConfig = nil

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

            self.applePayConfig = newConfiguration
            call.resolve()
        }
    }
    
    private func getApplePayConfig(_ call: CAPPluginCall, baseError: String) -> OPApplePayConfiguration? {
        let baseError = "Unable to initialize Apple Pay"
        do {
            let digitalWalletConfig: JSObject = try call.getOrReject(
                for: DataKeys.DigitalWalletConfigParameterKey,
                baseError: baseError
            )
            
            let shouldInitializeApplePay: Bool = try call.getOrReject(
                for: DataKeys.InitializeApplePayKey,
                baseError: "Unable to initialize Apple Pay",
                withDefault: false,
                in: digitalWalletConfig
            )
            
            if !shouldInitializeApplePay {
                call.resolve()
                return nil
            }
            
            let applePayConfig: JSObject = try call.getOrReject(
                for: DataKeys.ApplePayConfigParameterKey,
                baseError: baseError,
                in: digitalWalletConfig
            )
            
            let merchantId = try call.getStringOrReject(
                for: DataKeys.ApplePayMerchantIdParameterKey,
                baseError: baseError,
                allowEmptyValue: false,
                in: applePayConfig
            )
            
            let companyLabel = try call.getStringOrReject(
                for: DataKeys.ApplePayCompanyLabelParameterKey,
                baseError: baseError,
                allowEmptyValue: false,
                in: digitalWalletConfig
            )
            
            let currencyCodeString = try call.getStringOrReject(
                for: DataKeys.ApplePayCurrencyCodeKey,
                baseError: baseError,
                allowEmptyValue: false,
                withDefault: defaultCurrencyCode.description,
                in: digitalWalletConfig
            ).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 {
                call.reject(
                    "\(baseError): \(currencyCodeString.uppercased()) is not supported",
                    ErrorCodes.InvalidParameter
                )
                return nil
            }
            
            var currencyCode = defaultCurrencyCode
            if currencyCodeString == "cad" {
                currencyCode = .cad
            }
            
            let countryCode = try call.getStringOrReject(
                for: DataKeys.ApplePayCountryCodeKey,
                baseError: baseError,
                allowEmptyValue: false,
                withDefault: defaultCountryCode,
                in: digitalWalletConfig
            )
            
            let fullBillingAddressRequired: Bool = try call.getOrReject(
                for: DataKeys.DigitalWalletFullBillingAddressRequiredParameterKey,
                baseError: baseError,
                withDefault: defaultFullBillingAddressRequired,
                in: digitalWalletConfig
            )
            
            let phoneNumberRequired: Bool = try call.getOrReject(
                for: DataKeys.DigitalWalletPhoneNumberRequiredParameterKey,
                baseError: baseError,
                withDefault: defaultPhoneNumberRequired,
                in: digitalWalletConfig
            )
            
            let fullNameRequired: Bool = try call.getOrReject(
                for: DataKeys.DigitalWalletFullNameRequiredParameterKey,
                baseError: baseError,
                withDefault: defaultFullNameRequired,
                in: digitalWalletConfig
            )
            
            let emailRequired: Bool = try call.getOrReject(
                for: DataKeys.DigitalWalletEmailRequiredParameterKey,
                baseError: baseError,
                withDefault: defaultEmailRequired,
                in: digitalWalletConfig
            )
            
            let fullPhoneticNameRequired: Bool = try call.getOrReject(
                for: DataKeys.DigitalWalletFullPhoneticNameRequiredParameterKey,
                baseError: baseError,
                withDefault: defaultFullPhoneticNameRequired,
                in: applePayConfig
            )
            
            return OPApplePayConfiguration(
                merchantId: merchantId,
                companyLabel: companyLabel,
                currencyCode: currencyCode,
                countryCode: countryCode,
                emailRequired: emailRequired,
                phoneNumberRequired: phoneNumberRequired,
                fullNameRequired: fullNameRequired,
                fullBillingAddressRequired: fullBillingAddressRequired,
                fullPhoneticNameRequired: fullPhoneticNameRequired
            )
        } catch {
            return nil
        }
    }
    
    @objc func isInitialized(_ call: CAPPluginCall) {
        call.resolve([DataKeys.IsInitializedKey: sdkInitialized])
    }

    @objc func isDigitalWalletInitialized(_ call: CAPPluginCall) {
        call.resolve([DataKeys.IsInitializedKey: applePayInitialized])
    }

    
    @objc func isDigitalWalletReady(_ call: CAPPluginCall) {
        call.resolve([DataKeys.DigitalWalletIsReadyKey: applePayReady])
    }
    
    @objc func createDigitalWalletPaymentMethod(_ call: CAPPluginCall) {
        let baseError = "Unable to create payment method"
        guard sdkInitialized else {
            call.reject(
                "\(baseError): Olo Pay SDK has not been initialized",
                ErrorCodes.UninitializedSdk
            )
            return
        }
        
        guard OPApplePayLauncher.canMakePayments() else {
            call.reject(
                "\(baseError): Apple Pay is not supported on this device",
                ErrorCodes.ApplePayUnsupported
            )
            return
        }
        
        guard applePayInitialized else {
            call.reject(
                "\(baseError): Apple Pay has not been initialized",
                ErrorCodes.DigitalWalletUninitialized
            )
            return
        }
        
        dispatchToBackgroundThread(with: _applePaySemaphore, autoRelease: false) {
            var amountDouble: Double = 0.0
            var validateLineItems: Bool
            var lineItems: [PKPaymentSummaryItem]? = nil
            guard let dict = call.getOptions() else {
                call.reject(
                    "\(baseError): An unexpected error occurred",
                    ErrorCodes.UnexpectedError
                )
                self.applePayCleanup()
                return
            }
            
            do {
                amountDouble = try call.getOrReject(
                    for: DataKeys.ApplePayAmountKey,
                    baseError: baseError
                )
                
                validateLineItems = try call.getOrReject(
                    for: DataKeys.ValidateLineItemsKey,
                    baseError: baseError,
                    withDefault: true
                )
                
                lineItems = try self.getLineItemsFromArgs(
                    args: dict,
                    baseError: baseError,
                    call: call
                )
            } catch {
                self.applePayCleanup()
                return
            }
            
            guard amountDouble >= 0 else {
                call.reject(
                    "\(baseError): \(DataKeys.ApplePayAmountKey) cannot be negative",
                    ErrorCodes.InvalidParameter
                )
                self.applePayCleanup()
                return
            }
        
            let amount = NSDecimalNumber(decimal: Decimal(amountDouble))
            var errorCode: String
            var errorMessage: String
            
            do {
                try self._applePayLauncher.present(
                    for: amount,
                    with: lineItems,
                    validateLineItems: validateLineItems
                )
                self._applePayPluginCall = call
                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.EmptyMerchantId
                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"
            }
            
            let errorData: PluginCallResultData = [
                DataKeys.ErrorMessageKey: errorMessage,
                DataKeys.ErrorCodeKey: errorCode
            ]
            
            call.reject("\(baseError): \(errorMessage)", errorCode)
            self.applePayCleanup()
        }
    }
    
    public func paymentMethodCreated(from launcher: OloPaySDK.OPApplePayLauncherProtocol, with paymentMethod: OloPaySDK.OPPaymentMethodProtocol) -> NSError? {
        if _applePayPluginCall == nil {
            let message = "Unexpected error: Saved plugin call 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?) {
        guard let applePayPluginCall = _applePayPluginCall else {
            // If there is no saved plugin call then there is nothing to be done except log the problem and return
            _logger.error("Unexpected error: Saved plugin call is nil")
            applePayCleanup()
            return
        }
        
        let baseError = "Unable to create payment method"
        
        var applePayData: PluginCallResultData? = nil
        
        if status == .error {
            return rejectAndCleanup(
                "\(baseError): \(error!.localizedDescription)",
                ErrorCodes.ApplePayError
            )
        } else if status == .timeout {
            return rejectAndCleanup(
                "\(baseError): Payment was not processed in time",
                ErrorCodes.ApplePayTimeout
            )
        } else if status == .success && _applePayPaymentMethod == nil {
            return rejectAndCleanup(
                "\(baseError): Unexpected error - Payment method is nil",
                ErrorCodes.UnexpectedError
            )
        } else if status == .success && _applePayPaymentMethod != nil {
            let paymentMethodData = _applePayPaymentMethod!.toDictionary()
            
            applePayData = [DataKeys.PaymentMethodKey: paymentMethodData]
        } else if status == .userCancellation {
            applePayData = [DataKeys.PaymentMethodKey: NSNull()]
        }
        
        applePayCleanup()
        
        if let applePayDataUnwrapped = applePayData {
            applePayPluginCall.resolve(applePayDataUnwrapped)
        }
    }
    
    private func rejectAndCleanup(_ errorMessage: String, _ errorCode: String) {
        guard let applePayPluginCall = _applePayPluginCall else {
            // If there is no saved plugin call then there is nothing to be done except log the problem and return
            _logger.error("Unexpected error: Saved plugin call is nil")
            applePayCleanup()
            return
        }

        applePayPluginCall.reject(errorMessage, errorCode)
        applePayCleanup()
    }
    
    private func applePayCleanup() {
        _applePayPaymentMethod = nil
        _applePayPluginCall = nil
        _applePaySemaphore.signal()
    }
    
    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
        self.emitDigitalWalletReadyEvent(isReady: currentState)
    }
    
    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: .capacitor
        )
        
        OloPayAPI.sdkWrapperInfo = sdkWrapperInfo
    }
    
    private func getLineItemsFromArgs(
        args: [String: Any],
        baseError: String,
        call: CAPPluginCall
    ) throws -> [PKPaymentSummaryItem]? {
        do {
            let lineItemError = "\(baseError) - Unable to parse LineItems"
             var lineItems: [PKPaymentSummaryItem] = []

            if let lineItemsArray: [[String: Any]] = try call.getOrReject(
                for: DataKeys.LineItemsKey,
                baseError: baseError,
                withDefault: [],
                in: args
            ), !lineItemsArray.isEmpty {
                for item in lineItemsArray {
                    let label = try call.getStringOrReject(
                        for: DataKeys.LineItemLabelKey,
                        baseError: lineItemError,
                        allowEmptyValue: false,
                        in: item
                    )

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

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

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

                    let typeString = try call.getStringOrReject(
                        for: DataKeys.LineItemTypeKey,
                        baseError: lineItemError,
                        allowEmptyValue: false,
                        in: item
                    )

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

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

             if lineItems.isEmpty {
                 return nil
             }

            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"
}
