/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

final class AEMAdvertiserMultiEntryRule: NSObject, AEMAdvertiserRuleMatching, NSSecureCoding {

  enum CodingKeys: String, CodingKey {
    case `operator`
    case rules
  }

  let `operator`: AEMAdvertiserRuleOperator
  let rules: [AEMAdvertiserRuleMatching]

  init(with operator: AEMAdvertiserRuleOperator, rules: [AEMAdvertiserRuleMatching]) {
    self.operator = `operator`
    self.rules = rules
  }

  // MARK: - AEMAdvertiserRuleMatching

  func isMatchedEventParameters(_ eventParams: [String: Any]?) -> Bool {
    var isMatched = `operator` != .or
    for rule in rules {
      let doesSubruleMatch = rule.isMatchedEventParameters(eventParams)
      if `operator` == .and {
        isMatched = isMatched && doesSubruleMatch
      }
      if `operator` == .or {
        isMatched = isMatched || doesSubruleMatch
      }
      if `operator` == .not {
        isMatched = isMatched && !doesSubruleMatch
      }
    }
    return isMatched
  }

  // MARK: - NSCoding

  static var supportsSecureCoding: Bool { true }

  convenience init?(coder: NSCoder) {
    let `operator` = AEMAdvertiserRuleOperator(rawValue: coder.decodeInteger(forKey: CodingKeys.operator.rawValue))
    let classes = [
      NSArray.self,
      AEMAdvertiserMultiEntryRule.self,
      AEMAdvertiserSingleEntryRule.self,
    ]
    let rules = coder.decodeObject(of: classes, forKey: CodingKeys.rules.rawValue) as? [AEMAdvertiserRuleMatching]
    guard let `operator` = `operator`,
          let rules = rules else {
      return nil
    }
    self.init(with: `operator`, rules: rules)
  }

  func encode(with coder: NSCoder) {
    coder.encode(`operator`.rawValue, forKey: CodingKeys.operator.rawValue)
    coder.encode(rules, forKey: CodingKeys.rules.rawValue)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMAdvertiserRuleFactory: AEMAdvertiserRuleProviding {

  // MARK: - AEMAdvertiserRuleProviding

  func createRule(json: String?) -> AEMAdvertiserRuleMatching? {
    guard let json = json,
          let data = json.data(using: .utf8)
    else {
      return nil
    }

    do {
      let rule = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] ?? [:]
      return createRule(dictionary: rule)
    } catch {
      print("Fail to parse Advertiser Rules with JSON")
      return nil
    }
  }

  func createRule(dictionary: [String: Any]) -> AEMAdvertiserRuleMatching? {
    let `operator` = getOperator(from: dictionary)

    if isOperatorForMultiEntryRule(`operator`) {
      return createMultiEntryRule(from: dictionary)
    } else {
      return createSingleEntryRule(from: dictionary)
    }
  }

  // MARK: - Internal

  func createMultiEntryRule(from dictionary: [String: Any]) -> AEMAdvertiserMultiEntryRule? {
    guard !dictionary.isEmpty,
          let opString = primaryKey(for: dictionary)
    else {
      return nil
    }

    let `operator` = getOperator(from: dictionary)

    if !isOperatorForMultiEntryRule(`operator`) {
      return nil
    }

    let subrules: [[String: Any]] = dictionary[opString] as? [[String: Any]] ?? []
    var rules: [AEMAdvertiserRuleMatching] = []

    for subrule in subrules {
      guard let entryRule = createRule(dictionary: subrule) else {
        return nil
      }
      rules.append(entryRule)
    }

    guard !rules.isEmpty else {
      return nil
    }

    return AEMAdvertiserMultiEntryRule(with: `operator`, rules: rules)
  }

  func createSingleEntryRule(from dictionary: [String: Any]) -> AEMAdvertiserSingleEntryRule? {
    guard let paramKey = primaryKey(for: dictionary) else {
      return nil
    }

    let rawRule: [String: Any] = dictionary[paramKey] as? [String: Any] ?? [:]

    guard let encodedOperator = primaryKey(for: rawRule) else {
      return nil
    }

    let `operator`: AEMAdvertiserRuleOperator = getOperator(from: rawRule)

    var linguisticCondition: String?
    var numericalCondition: NSNumber?
    var arrayCondition: [String]?

    switch `operator` {
    case .contains,
         .notContains,
         .startsWith,
         .caseInsensitiveContains,
         .caseInsensitiveNotContains,
         .caseInsensitiveStartsWith,
         .regexMatch,
         .equal,
         .notEqual:
      linguisticCondition = rawRule[encodedOperator] as? String

    case .lessThan,
         .lessThanOrEqual,
         .greaterThan,
         .greaterThanOrEqual:
      numericalCondition = rawRule[encodedOperator] as? NSNumber

    case .caseInsensitiveIsAny, .caseInsensitiveIsNotAny, .isAny, .isNotAny:
      arrayCondition = rawRule[encodedOperator] as? [String]

    case .unknown:
      return nil
    default:
      return nil
    }

    if linguisticCondition != nil || numericalCondition != nil || arrayCondition?.isEmpty == false {
      return AEMAdvertiserSingleEntryRule(
        with: `operator`,
        paramKey: paramKey,
        linguisticCondition: linguisticCondition,
        numericalCondition: numericalCondition,
        arrayCondition: arrayCondition
      )
    } else {
      return nil
    }
  }

  func primaryKey(for rule: [String: Any]) -> String? {
    rule.keys.first
  }

  func getOperator(from rule: [String: Any]) -> AEMAdvertiserRuleOperator {
    guard let key = primaryKey(for: rule) else {
      return .unknown
    }

    let operatorKeys: [String] = [
      // UNCRUSTIFY_FORMAT_OFF
      "unknown", // FBAEMAdvertiserRuleOperatorUnknown
      "and", // FBAEMAdvertiserRuleOperatorAnd
      "or", // FBAEMAdvertiserRuleOperatorOr
      "not", // FBAEMAdvertiserRuleOperatorNot
      "contains", // FBAEMAdvertiserRuleOperatorContains
      "not_contains", // FBAEMAdvertiserRuleOperatorNotContains
      "starts_with", // FBAEMAdvertiserRuleOperatorStartsWith
      "i_contains", // FBAEMAdvertiserRuleOperatorCaseInsensitiveContains
      "i_not_contains", // FBAEMAdvertiserRuleOperatorCaseInsensitiveNotContains
      "i_starts_with", // FBAEMAdvertiserRuleOperatorCaseInsensitiveStartsWith
      "regex_match", // FBAEMAdvertiserRuleOperatorRegexMatch
      "eq", // FBAEMAdvertiserRuleOperatorEqual
      "neq", // FBAEMAdvertiserRuleOperatorNotEqual
      "lt", // FBAEMAdvertiserRuleOperatorLessThan
      "lte", // FBAEMAdvertiserRuleOperatorLessThanOrEqual
      "gt", // FBAEMAdvertiserRuleOperatorGreaterThan
      "gte", // FBAEMAdvertiserRuleOperatorGreaterThanOrEqual
      "i_is_any", // FBAEMAdvertiserRuleOperatorCaseInsensitiveIsAny
      "i_is_not_any", // FBAEMAdvertiserRuleOperatorCaseInsensitiveIsNotAny
      "is_any", // FBAEMAdvertiserRuleOperatorIsAny
      "is_not_any", // FBAEMAdvertiserRuleOperatorIsNotAny
      // UNCRUSTIFY_FORMAT_ON
    ]

    guard let index = operatorKeys.firstIndex(of: key.lowercased()) else {
      return .unknown
    }

    return AEMAdvertiserRuleOperator(rawValue: index) ?? .unknown
  }

  func isOperatorForMultiEntryRule(_ operator: AEMAdvertiserRuleOperator) -> Bool {
    let operators: [AEMAdvertiserRuleOperator] = [.and, .or, .not]
    return operators.contains(`operator`)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

protocol AEMAdvertiserRuleMatching {
  func isMatchedEventParameters(_ eventParams: [String: Any]?) -> Bool
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

enum AEMAdvertiserRuleOperator: Int {
  case unknown = 0
  // Multi Entry Rule Operator
  case and
  // swiftlint:disable:next identifier_name
  case or
  case not
  // Single Entry Rule Operator
  case contains
  case notContains
  case startsWith
  case caseInsensitiveContains
  case caseInsensitiveNotContains
  case caseInsensitiveStartsWith
  case regexMatch
  case equal
  case notEqual
  case lessThan
  case lessThanOrEqual
  case greaterThan
  case greaterThanOrEqual
  case caseInsensitiveIsAny
  case caseInsensitiveIsNotAny
  case isAny
  case isNotAny
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

/**
 Describes anything that can provide instances of `AEMAdvertiserRuleMatching`
 */
protocol AEMAdvertiserRuleProviding {

  func createRule(json: String?) -> AEMAdvertiserRuleMatching?

  func createRule(dictionary: [String: Any]) -> AEMAdvertiserRuleMatching?
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMAdvertiserSingleEntryRule: NSObject, NSSecureCoding, AEMAdvertiserRuleMatching {

  var `operator`: AEMAdvertiserRuleOperator
  let paramKey: String
  let linguisticCondition: String?
  let numericalCondition: Double?
  let arrayCondition: [String]?

  private enum Keys {
    static let `operator` = "operator"
    static let param = "param_key"
    static let stringValue = "string_value"
    static let numberValue = "number_value"
    static let arrayValue = "array_value"
  }

  private enum Delimeter {
    static let param = "."
    static let asterisk = "[*]"
  }

  // MRAK: - Init

  init(
    operator: AEMAdvertiserRuleOperator,
    paramKey: String,
    linguisticCondition: String?,
    numericalCondition: Double?,
    arrayCondition: [String]?
  ) {
    self.operator = `operator`
    self.paramKey = paramKey
    self.linguisticCondition = linguisticCondition
    self.numericalCondition = numericalCondition
    self.arrayCondition = arrayCondition
    super.init()
  }

  convenience init(
    with operator: AEMAdvertiserRuleOperator,
    paramKey: String,
    linguisticCondition: String?,
    numericalCondition: NSNumber?,
    arrayCondition: [String]?
  ) {
    self.init(
      operator: `operator`,
      paramKey: paramKey,
      linguisticCondition: linguisticCondition,
      numericalCondition: numericalCondition?.doubleValue,
      arrayCondition: arrayCondition
    )
  }

  // MARK: - AEMAdvertiserRuleMatching

  func isMatchedEventParameters(_ eventParams: [String: Any]?) -> Bool {
    let paramPath = paramKey.components(separatedBy: Delimeter.param)
    return isMatchedEventParameters(eventParams: eventParams, paramPath: paramPath)
  }

  func isMatchedEventParameters(eventParams: [String: Any]?, paramPath: [String]) -> Bool {
    guard let eventParams = eventParams, !eventParams.isEmpty else {
      return false
    }
    let param = paramPath.first
    if let param = param,
       param.hasSuffix(Delimeter.asterisk) == true {
      return isMatched(withAsteriskParam: param, eventParameters: eventParams, paramPath: paramPath)
    }

    // if data does not contain the key, we should return false directly.
    guard let param = param,
          eventParams.keys.contains(param) else {
      return false
    }

    // Apply operator rule if the last param is reached
    if paramPath.count == 1 {
      var stringValue: String?
      var numericalValue: Double?
      switch `operator` {
      case .contains,
           .notContains,
           .startsWith,
           .caseInsensitiveContains,
           .caseInsensitiveNotContains,
           .caseInsensitiveStartsWith,
           .regexMatch,
           .equal,
           .notEqual,
           .caseInsensitiveIsAny,
           .caseInsensitiveIsNotAny,
           .isAny,
           .isNotAny:
        stringValue = eventParams[param] as? String

      case .lessThan,
           .lessThanOrEqual,
           .greaterThan,
           .greaterThanOrEqual:
        numericalValue = eventParams[param] as? Double

      default:
        break
      }

      return isMatched(withStringValue: stringValue, numericalValue: numericalValue)
    }

    let subParams = eventParams[param] as? [String: Any]
    let subParamPath = Array(paramPath.dropFirst())
    return isMatchedEventParameters(eventParams: subParams, paramPath: subParamPath)
  }

  func isMatched(withAsteriskParam param: String, eventParameters: [String: Any], paramPath: [String]) -> Bool {
    let length = param.count - Delimeter.asterisk.count
    let paramSubstring = String(param[param.startIndex ..< param.index(param.startIndex, offsetBy: length)])
    let items = eventParameters[paramSubstring] as? [Any] ?? []
    if items.isEmpty || paramPath.count < 2 {
      return false
    }

    var isMatched = false
    let subParamPath = Array(paramPath.dropFirst())
    for item in items {
      isMatched = isMatchedEventParameters(eventParams: item as? [String: Any], paramPath: subParamPath)
      if isMatched {
        break
      }
    }
    return isMatched
  }

  // swiftlint:disable:next cyclomatic_complexity
  func isMatched(withStringValue stringValue: String?, numericalValue: Double?) -> Bool {
    var isMatched = false
    switch `operator` {
    case .contains:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition,
         stringValue.lowercased().contains(linguisticCondition.lowercased()) {
        isMatched = true
      }

    case .notContains:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition {
        isMatched = !stringValue.lowercased().contains(linguisticCondition.lowercased())
      }

    case .startsWith:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition {
        isMatched = stringValue.lowercased().hasPrefix(linguisticCondition.lowercased())
      }

    case .caseInsensitiveContains:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition,
         stringValue.lowercased().contains(linguisticCondition.lowercased()) {
        isMatched = true
      }

    case .caseInsensitiveNotContains:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition {
        isMatched = !stringValue.lowercased().contains(linguisticCondition.lowercased())
      }

    case .caseInsensitiveStartsWith:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition,
         stringValue.lowercased().hasPrefix(linguisticCondition.lowercased()) {
        isMatched = true
      }

    case .regexMatch:
      if let stringValue = stringValue {
        isMatched = isRegexMatch(stringValue)
      }

    case .equal:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition,
         stringValue.lowercased() == linguisticCondition.lowercased() {
        isMatched = true
      }

    case .notEqual:
      if let stringValue = stringValue,
         let linguisticCondition = linguisticCondition {
        isMatched = stringValue.lowercased() != linguisticCondition.lowercased()
      }

    case .caseInsensitiveIsAny:
      if let stringValue = stringValue {
        isMatched = isAny(of: arrayCondition ?? [], stringValue: stringValue, ignoreCase: true)
      }

    case .caseInsensitiveIsNotAny:
      if let stringValue = stringValue {
        return !isAny(of: arrayCondition ?? [], stringValue: stringValue, ignoreCase: true)
      }

    case .isAny:
      if let stringValue = stringValue {
        return isAny(of: arrayCondition ?? [], stringValue: stringValue, ignoreCase: false)
      }

    case .isNotAny:
      if let stringValue = stringValue,
         !isAny(of: arrayCondition ?? [], stringValue: stringValue, ignoreCase: false) {
        isMatched = true
      }

    case .lessThan:
      if let numericalValue = numericalValue,
         let numericalCondition = numericalCondition {
        isMatched = numericalValue < numericalCondition
      }

    case .lessThanOrEqual:
      if let numericalValue = numericalValue,
         let numericalCondition = numericalCondition,
         numericalValue <= numericalCondition {
        isMatched = true
      }

    case .greaterThan:
      if let numericalValue = numericalValue,
         let numericalCondition = numericalCondition,
         numericalValue > numericalCondition {
        isMatched = true
      }

    case .greaterThanOrEqual:
      if let numericalValue = numericalValue,
         let condition = numericalCondition,
         numericalValue >= condition {
        isMatched = true
      }
    default:
      break
    }
    return isMatched
  }

  func isRegexMatch(_ stringValue: String) -> Bool {
    guard let linguisticCondition = linguisticCondition, !linguisticCondition.isEmpty else {
      return false
    }
    do {
      let regex = try NSRegularExpression(pattern: linguisticCondition, options: .allowCommentsAndWhitespace)
      let range = NSRange(location: 0, length: stringValue.count)
      let matches = regex.matches(in: stringValue, options: .anchored, range: range)
      return !matches.isEmpty
    } catch {
      return false
    }
  }

  func isAny(of arrayCondition: [String], stringValue: String, ignoreCase: Bool) -> Bool {
    var set = Set<String>()
    for item in arrayCondition {
      if ignoreCase {
        set.insert(item.lowercased())
      } else {
        set.insert(item)
      }
    }
    return set.contains(ignoreCase ? stringValue.lowercased() : stringValue)
  }

  // MARK: - NSCoding

  static var supportsSecureCoding = true

  init?(coder: NSCoder) {
    let operatorValue = coder.decodeInteger(forKey: Keys.operator)
    guard let `operator` = AEMAdvertiserRuleOperator(rawValue: operatorValue),
          let paramKey = coder.decodeObject(of: NSString.self, forKey: Keys.param),
          let linguisticCondition = coder.decodeObject(of: NSString.self, forKey: Keys.stringValue),
          let numericalCondition = coder.decodeObject(of: NSNumber.self, forKey: Keys.numberValue) else {
      return nil
    }
    let arrayCondition = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: Keys.arrayValue) as? [String]

    self.operator = `operator`
    self.paramKey = paramKey as String
    self.linguisticCondition = linguisticCondition as String
    self.numericalCondition = numericalCondition.doubleValue
    self.arrayCondition = arrayCondition
    super.init()
  }

  func encode(with coder: NSCoder) {
    coder.encode(`operator`.rawValue, forKey: Keys.operator)
    coder.encode(paramKey, forKey: Keys.param)
    coder.encode(linguisticCondition, forKey: Keys.stringValue)
    coder.encode(numericalCondition, forKey: Keys.numberValue)
    coder.encode(arrayCondition, forKey: Keys.arrayValue)
  }

  override func isEqual(_ object: Any?) -> Bool {
    if let rule = object as? AEMAdvertiserSingleEntryRule {
      let isOpEqual = self.operator == rule.operator
      let isParamKeyEqual = paramKey == rule.paramKey
      let isLinguisticConditionEqual = linguisticCondition == rule.linguisticCondition
      var isArrayConditionEqual = false
      if let array1 = arrayCondition {
        let array2 = rule.arrayCondition
        isArrayConditionEqual = array1 == array2
      } else {
        isArrayConditionEqual = rule.arrayCondition == nil
      }
      let isNumericConditionEqual = ((numericalCondition == nil && rule.numericalCondition == nil)
        || (numericalCondition == rule.numericalCondition) == true)
      return isOpEqual && isParamKeyEqual && isLinguisticConditionEqual
        && isArrayConditionEqual && isNumericConditionEqual
    }
    return false
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

final class AEMConfiguration: NSObject, NSSecureCoding {

  enum CodingKeys: String, CodingKey {
    case defaultCurrency = "default_currency"
    case cutoffTime = "cutoff_time"
    case conversionValueRules = "conversion_value_rules"
    case validFrom = "valid_from"
    case mode = "config_mode"
    case advertiserID = "advertiser_id"
    case businessID = "business_id"
    case paramRule = "param_rule"
  }

  private(set) var cutoffTime: Int

  /// The UNIX timestamp of configuration's valid date and works as a unqiue identifier of the configuration
  private(set) var validFrom: Int
  private(set) var defaultCurrency: String
  private(set) var mode: String
  private(set) var businessID: String?
  private(set) var matchingRule: AEMAdvertiserRuleMatching?
  private(set) var conversionValueRules: [AEMRule]
  private(set) var eventSet: Set<String>
  private(set) var currencySet: Set<String>

  private(set) static var ruleProvider: AEMAdvertiserRuleProviding?

  static func configure(withRuleProvider ruleProvider: AEMAdvertiserRuleProviding) {
    self.ruleProvider = ruleProvider
  }

  init?(json dict: [String: Any]?) {
    guard let dict = dict else { return nil }

    guard let defaultCurrency = dict[CodingKeys.defaultCurrency.rawValue] as? String,
          let cutoffTime = dict[CodingKeys.cutoffTime.rawValue] as? Int,
          let validFrom = dict[CodingKeys.validFrom.rawValue] as? Int,
          let mode = dict[CodingKeys.mode.rawValue] as? String
    else { return nil }

    let businessID = dict[CodingKeys.advertiserID.rawValue] as? String
    let paramRuleJson = dict[CodingKeys.paramRule.rawValue] as? String
    let matchingRule = AEMConfiguration.ruleProvider?.createRule(json: paramRuleJson)
    guard let rules = AEMConfiguration.parseRules(dict[CodingKeys.conversionValueRules.rawValue] as? [[String: Any]]),
          !rules.isEmpty,
          businessID == nil || matchingRule != nil else { return nil }

    self.validFrom = validFrom
    self.cutoffTime = cutoffTime
    self.validFrom = validFrom
    self.businessID = businessID
    self.matchingRule = matchingRule
    self.defaultCurrency = defaultCurrency
    self.mode = mode
    conversionValueRules = rules
    eventSet = AEMConfiguration.getEventSet(from: conversionValueRules)
    currencySet = AEMConfiguration.getCurrencySet(from: conversionValueRules)
  }

  private init(
    defaultCurrency: String,
    cutoffTime: Int,
    validFrom: Int,
    mode: String,
    businessID: String?,
    matchingRule: AEMAdvertiserRuleMatching?,
    conversionValueRules: [AEMRule]
  ) {
    self.defaultCurrency = defaultCurrency
    self.cutoffTime = cutoffTime
    self.validFrom = validFrom
    self.mode = mode
    self.businessID = businessID
    self.matchingRule = matchingRule
    self.conversionValueRules = conversionValueRules
    eventSet = AEMConfiguration.getEventSet(from: self.conversionValueRules)
    currencySet = AEMConfiguration.getCurrencySet(from: self.conversionValueRules)
  }

  static func parseRules(_ rules: [[String: Any]]?) -> [AEMRule]? {
    guard let rules = rules,
          !rules.isEmpty else { return nil }

    var parsedRules: [AEMRule] = []
    for ruleEntry in rules {
      guard let rule = AEMRule(json: ruleEntry) else { return nil }

      parsedRules.append(rule)
    }
    // Sort the rules in descending priority order
    parsedRules.sort { obj1, obj2 in
      obj1.priority > obj2.priority
    }
    return parsedRules
  }

  static func getEventSet(from rules: [AEMRule]) -> Set<String> {
    var eventSet: Set<String> = []
    for rule in rules {
      for event in rule.events {
        eventSet.insert(event.eventName)
      }
    }
    return eventSet
  }

  static func getCurrencySet(from rules: [AEMRule]) -> Set<String> {
    var currencySet: Set<String> = []
    for rule in rules {
      for event in rule.events {
        if let currencyValueDict = event.values {
          for currency in currencyValueDict.keys {
            currencySet.insert(currency.uppercased())
          }
        }
      }
    }
    return currencySet
  }

  func isSame(validFrom: Int, businessID: String?) -> Bool {
    (validFrom == self.validFrom) && isSameBusinessID(businessID)
  }

  func isSameBusinessID(_ businessID: String?) -> Bool {
    businessID == self.businessID
  }

  // MARK: NSSecureCoding

  func encode(with coder: NSCoder) {
    coder.encode(defaultCurrency, forKey: CodingKeys.defaultCurrency.rawValue)
    coder.encode(cutoffTime, forKey: CodingKeys.cutoffTime.rawValue)
    coder.encode(validFrom, forKey: CodingKeys.validFrom.rawValue)
    coder.encode(mode, forKey: CodingKeys.mode.rawValue)
    coder.encode(businessID, forKey: CodingKeys.businessID.rawValue)
    coder.encode(matchingRule, forKey: CodingKeys.paramRule.rawValue)
    coder.encode(conversionValueRules, forKey: CodingKeys.conversionValueRules.rawValue)
  }

  convenience init?(coder: NSCoder) {
    let defaultCurrency = coder.decodeObject(
      of: NSString.self, forKey: CodingKeys.defaultCurrency.rawValue
    ) as String? ?? ""
    let cutoffTime = coder.decodeInteger(forKey: CodingKeys.cutoffTime.rawValue)
    let validFrom = coder.decodeInteger(forKey: CodingKeys.validFrom.rawValue)
    let mode = coder.decodeObject(
      of: NSString.self,
      forKey: CodingKeys.mode.rawValue
    ) as String? ?? ""
    let businessID = coder.decodeObject(of: NSString.self, forKey: CodingKeys.businessID.rawValue) as String?
    let matchingRule = coder.decodeObject(
      of: [NSArray.self, AEMAdvertiserMultiEntryRule.self, AEMAdvertiserSingleEntryRule.self],
      forKey: CodingKeys.paramRule.rawValue
    ) as? AEMAdvertiserRuleMatching
    guard let rules = coder.decodeObject(
      of: [NSArray.self, AEMRule.self, AEMEvent.self],
      forKey: CodingKeys.conversionValueRules.rawValue
    ) as? [AEMRule] else { return nil }

    self.init(
      defaultCurrency: defaultCurrency,
      cutoffTime: cutoffTime,
      validFrom: validFrom,
      mode: mode,
      businessID: businessID,
      matchingRule: matchingRule,
      conversionValueRules: rules
    )
  }

  static var supportsSecureCoding: Bool { true }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

final class AEMEvent: NSObject, NSSecureCoding {
  enum CodingKeys: String, CodingKey {
    case eventName = "event_name"
    case values
    case currency
    case amount
  }

  private(set) var eventName: String
  private(set) var values: [String: Double]?

  init?(dict: [String: Any]?) {
    guard let dict = dict else { return nil }

    // Event name is a required field
    guard let eventName = dict[CodingKeys.eventName.rawValue] as? String else {
      return nil
    }
    self.eventName = eventName

    // Values is an optional field, so don't return nil
    guard let valueEntries = dict[CodingKeys.values.rawValue] as? [[String: Any]] else { return }

    if !valueEntries.isEmpty {
      var valuesDict: [String: Double] = [:]

      for valueEntry in valueEntries {
        guard let currency = valueEntry[CodingKeys.currency.rawValue] as? String,
              let amount = valueEntry[CodingKeys.amount.rawValue] as? Double,
              !currency.isEmpty else {
          return nil
        }
        valuesDict[currency.uppercased()] = amount
      }

      values = valuesDict
    }
  }

  private init(eventName: String, values: [String: Double]?) {
    self.eventName = eventName
    self.values = values
  }

  // MARK: NSSecureCoding

  static var supportsSecureCoding: Bool {
    true
  }

  convenience init?(coder: NSCoder) {
    let decodedEventName = coder.decodeObject(of: NSString.self, forKey: CodingKeys.eventName.rawValue) as String? ?? ""
    let decodedValues = coder.decodeObject(
      of: [NSDictionary.self, NSNumber.self, NSString.self],
      forKey: CodingKeys.values.rawValue
    ) as? [String: Double]
    self.init(eventName: decodedEventName, values: decodedValues)
  }

  func encode(with coder: NSCoder) {
    coder.encode(eventName, forKey: CodingKeys.eventName.rawValue)
    if values != nil {
      coder.encode(values, forKey: CodingKeys.values.rawValue)
    }
  }

  // MARK: - NSObject

  override func isEqual(_ object: Any?) -> Bool {
    guard let other = object as? AEMEvent else { return false }

    return eventName == other.eventName
      && values == other.values
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import CommonCrypto.CommonHMAC
import FBSDKCoreKit_Basics
import Foundation

class AEMInvocation: NSObject, NSSecureCoding { // swiftlint:disable:this prefer_final_classes

  var campaignID: String
  let acsToken: String
  var acsSharedSecret: String?
  var acsConfigurationID: String?
  var businessID: String?
  var catalogID: String?
  let isTestMode: Bool
  var hasStoreKitAdNetwork: Bool
  var isConversionFilteringEligible: Bool
  private(set) var timestamp: Date
  private(set) var configurationMode: String
  /// The unique identifier of the configuration, it's the same as configuration's validFrom
  var configurationID: Int
  var recordedEvents: Set<String>
  var recordedValues: [String: [String: Any]]
  var conversionValue: Int
  var priority: Int
  var conversionTimestamp: Date?
  var isAggregated: Bool

  private static let secondsInDay = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */
  private static let catalogOptimizationModulus = 8
  private static let topOutPriority = 32

  private enum Key: String {
    case campaignIdentifier = "campaign_ids"
    case acsToken = "acs_token"
    case acsSharedSecret = "shared_secret"
    case acsConfigurationIdentifier = "acs_config_id"
    case businessIdentifier = "advertiser_id"
    case catalogIdentifier = "catalog_id"
    case testDeepLink = "test_deeplink"
    case timestamp
    case configurationMode = "config_mode"
    case configurationIdentifier = "config_id"
    case recordedEvents = "recorded_events"
    case recordedValues = "recorded_values"
    case conversionValue = "conversion_value"
    case priority
    case conversionTimestamp = "conversion_timestamp"
    case isAggregated = "is_aggregated"
    case hasStoreKitAdNetwork = "has_skan"
    case isConversionFilteringEligible = "is_conversion_filtering_eligible"
    case facebookContent = "fb_content"
    case facebookContentIdentifier = "fb_content_id"
  }

  enum ConfigurationMode: String {
    case `default` = "DEFAULT"
    case brand = "BRAND"
    case cpas = "CPAS"
  }

  convenience init?(appLinkData: [AnyHashable: Any]?) {
    guard
      let appLinkData = appLinkData,
      let campaignID = appLinkData[Key.campaignIdentifier.rawValue] as? String,
      let acsToken = appLinkData[Key.acsToken.rawValue] as? String
    else { return nil }

    let acsSharedSecret = appLinkData[Key.acsSharedSecret.rawValue] as? String
    let acsConfigurationID = appLinkData[Key.configurationIdentifier.rawValue] as? String
    let businessID = appLinkData[Key.businessIdentifier.rawValue] as? String
    let catalogID = appLinkData[Key.catalogIdentifier.rawValue] as? String
    let isTestMode = (appLinkData[Key.testDeepLink.rawValue] as? NSNumber)?.boolValue ?? false
    let hasStoreKitAdNetwork = (appLinkData[Key.hasStoreKitAdNetwork.rawValue] as? NSNumber)?.boolValue ?? false

    self.init(
      campaignID: campaignID,
      acsToken: acsToken,
      acsSharedSecret: acsSharedSecret,
      acsConfigurationID: acsConfigurationID,
      businessID: businessID,
      catalogID: catalogID,
      isTestMode: isTestMode,
      hasStoreKitAdNetwork: hasStoreKitAdNetwork,
      isConversionFilteringEligible: true
    )
  }

  convenience init?(
    campaignID: String,
    acsToken: String,
    acsSharedSecret: String?,
    acsConfigurationID: String?,
    businessID: String?,
    catalogID: String?,
    isTestMode: Bool,
    hasStoreKitAdNetwork: Bool,
    isConversionFilteringEligible: Bool
  ) {
    self.init(
      campaignID: campaignID,
      acsToken: acsToken,
      acsSharedSecret: acsSharedSecret,
      acsConfigurationID: acsConfigurationID,
      businessID: businessID,
      catalogID: catalogID,
      timestamp: nil,
      configurationMode: "DEFAULT",
      configurationID: -1,
      recordedEvents: nil,
      recordedValues: nil,
      conversionValue: -1,
      priority: -1,
      conversionTimestamp: nil,
      isAggregated: true,
      isTestMode: isTestMode,
      hasStoreKitAdNetwork: hasStoreKitAdNetwork,
      isConversionFilteringEligible: isConversionFilteringEligible
    )
  }

  init?(
    campaignID: String,
    acsToken: String,
    acsSharedSecret: String?,
    acsConfigurationID: String?,
    businessID: String?,
    catalogID: String?,
    timestamp: Date?,
    configurationMode: String,
    configurationID: Int,
    recordedEvents: Set<String>?,
    recordedValues: [String: [String: Any]]?,
    conversionValue: Int,
    priority: Int,
    conversionTimestamp: Date?,
    isAggregated: Bool,
    isTestMode: Bool,
    hasStoreKitAdNetwork: Bool,
    isConversionFilteringEligible: Bool
  ) {
    self.campaignID = campaignID
    self.acsToken = acsToken
    self.acsSharedSecret = acsSharedSecret
    self.acsConfigurationID = acsConfigurationID
    self.businessID = businessID
    self.catalogID = catalogID
    self.timestamp = timestamp ?? Date()
    self.configurationMode = configurationMode
    self.configurationID = configurationID
    self.recordedEvents = recordedEvents ?? []
    self.recordedValues = recordedValues ?? [:]
    self.conversionValue = conversionValue
    self.priority = priority
    self.conversionTimestamp = conversionTimestamp
    self.isAggregated = isAggregated
    self.isTestMode = isTestMode
    self.hasStoreKitAdNetwork = hasStoreKitAdNetwork
    self.isConversionFilteringEligible = isConversionFilteringEligible

    super.init()
  }

  @discardableResult
  // swiftlint:disable:next function_parameter_count
  func attributeEvent(
    _ event: String,
    currency potentialValueCurrency: String?,
    value potentialValue: NSNumber?,
    parameters: [String: Any]?,
    configurations: [String: [AEMConfiguration]]?,
    shouldUpdateCache: Bool,
    isRuleMatchInServer: Bool
  ) -> Bool {
    guard
      let configuration = findConfiguration(in: configurations),
      !isOutOfWindow(configuration: configuration),
      configuration.eventSet.contains(event)
    else { return false }

    var processedParameters: [String: Any]?
    if !isRuleMatchInServer {
      // Check advertiser rule matching
      processedParameters = getProcessedParameters(from: parameters)
      if let matchingRule = configuration.matchingRule,
         !matchingRule.isMatchedEventParameters(processedParameters) {
        return false
      }
    }

    var isAttributed = false

    if !recordedEvents.contains(event) {
      if shouldUpdateCache {
        recordedEvents.insert(event)
      }

      isAttributed = true
    }

    // Change currency to default currency if currency is not found in currencySet
    var valueCurrency = configuration.defaultCurrency
    if let currency = potentialValueCurrency?.uppercased(),
       configuration.currencySet.contains(currency) {
      valueCurrency = currency
    }

    var value = potentialValue
    if !isRuleMatchInServer {
      // Use in-segment value for CPAS
      if configuration.mode == ConfigurationMode.cpas.rawValue {
        value = AEMUtility.shared.getInSegmentValue(processedParameters, matchingRule: configuration.matchingRule)
      }
    }

    if let value = value {
      var mapping = recordedValues[event] ?? [:]
      let valueInMapping = (mapping[valueCurrency] as? NSNumber)?.doubleValue ?? 0.0

      // Overwrite values when the incoming event's value is greater than the cached one
      if value.doubleValue > valueInMapping {
        if shouldUpdateCache {
          mapping[valueCurrency] = value
          recordedValues[event] = mapping
        }

        isAttributed = true
      }
    }

    return isAttributed
  }

  func updateConversionValue(
    configurations: [String: [AEMConfiguration]]?,
    event: String,
    shouldBoostPriority: Bool
  ) -> Bool {
    guard let configuration = findConfiguration(in: configurations)
    else { return false }

    var isConversionValueUpdated = false

    // Update conversion value if a rule is matched
    for rule in configuration.conversionValueRules {
      var rulePriority = rule.priority

      if isConversionFilteringEligible,
         shouldBoostPriority,
         rule.containsEvent(event),
         isOptimizedEvent(event, configuration: configuration) {
        rulePriority += Self.topOutPriority
      }

      guard rulePriority > priority else { continue }

      if rule.isMatched(withRecordedEvents: recordedEvents, recordedValues: recordedValues) {
        conversionValue = rule.conversionValue
        priority = rulePriority
        conversionTimestamp = Date()
        isAggregated = false
        isConversionValueUpdated = true
      }
    }

    return isConversionValueUpdated
  }

  func isOptimizedEvent(_ event: String, configurations: [String: [AEMConfiguration]]?) -> Bool {
    guard
      catalogID != nil,
      let configuration = findConfiguration(in: configurations)
    else { return false }

    return isOptimizedEvent(event, configuration: configuration)
  }

  private func isOptimizedEvent(_ event: String, configuration: AEMConfiguration) -> Bool {
    // Look up conversion bit mapping to check if an event is optimized
    configuration.conversionValueRules.contains { rule in
      guard
        let campaign = Int(campaignID),
        (campaign % Self.catalogOptimizationModulus) == (rule.conversionValue % Self.catalogOptimizationModulus)
      else { return false }

      return rule.events.contains { $0.eventName == event }
    }
  }

  func isOutOfWindow(configurations: [String: [AEMConfiguration]]?) -> Bool {
    isOutOfWindow(configuration: findConfiguration(in: configurations))
  }

  // Second attempt

  func getHMAC(delay: Int) -> String? {
    guard
      acsConfigurationID != nil,
      let secretKey = acsSharedSecret,
      let secretKeyData = decodeBase64URLSafeString(secretKey),
      let hmac = NSMutableData(length: Int(CC_SHA512_DIGEST_LENGTH))
    else { return nil }

    let message = "\(campaignID)|\(conversionValue)|\(delay)|server"
    guard let messageData = message.data(using: .utf8) else { return nil }

    let secretKeyNSData = NSData(data: secretKeyData)
    let messageNSData = NSData(data: messageData)

    CCHmac(
      CCHmacAlgorithm(kCCHmacAlgSHA512),
      secretKeyNSData.bytes,
      secretKeyNSData.length,
      messageNSData.bytes,
      messageNSData.length,
      hmac.mutableBytes
    )

    return hmac
      .base64EncodedString()
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "=", with: "")
  }

  func decodeBase64URLSafeString(_ string: String) -> Data? {
    guard !string.isEmpty else { return nil }

    let length = string.count
    let paddedLength = length + (4 - (length % 4))
    let decoded = string
      .replacingOccurrences(of: "-", with: "+")
      .replacingOccurrences(of: "_", with: "/")
      .padding(toLength: paddedLength, withPad: "=", startingAt: 0)

    return Data(base64Encoded: decoded)
  }

  func getProcessedParameters(from parameters: [String: Any]?) -> [String: Any]? {
    guard var processed = parameters else { return nil }

    if let content = processed[Key.facebookContent.rawValue] as? String,
       let contentData = content.data(using: .utf8),
       let jsonObject = try? JSONSerialization.jsonObject(with: contentData) {
      processed[Key.facebookContent.rawValue] = jsonObject
    }

    if let contentID = processed[Key.facebookContentIdentifier.rawValue] as? String,
       let stringData = contentID.data(using: .utf8),
       let data = try? JSONSerialization.jsonObject(with: stringData) {
      processed[Key.facebookContentIdentifier.rawValue] = data
    }

    return processed
  }

  private func isOutOfWindow(configuration: AEMConfiguration?) -> Bool {
    guard let configuration = configuration else { return true }

    let cutoff = TimeInterval(configuration.cutoffTime * Self.secondsInDay)
    let isCutoff = Date().timeIntervalSince(timestamp) > cutoff

    var isOverLastConversionWindow = false
    if let conversionTimestamp = conversionTimestamp {
      let oneDay = TimeInterval(Self.secondsInDay)
      isOverLastConversionWindow = Date().timeIntervalSince(conversionTimestamp) > oneDay
    }

    return isCutoff || isOverLastConversionWindow
  }

  func findConfiguration(in configurations: [String: [AEMConfiguration]]?) -> AEMConfiguration? {
    let configurationMode = (businessID != nil) ? ConfigurationMode.brand : .default
    let configurationList = getConfigurationList(mode: configurationMode, configurations: configurations)

    guard !configurationList.isEmpty else { return nil }

    if configurationID > 0 {
      return configurationList.first {
        $0.isSame(validFrom: configurationID, businessID: businessID)
      }
    } else {
      let configuration = configurationList.reversed().first {
        TimeInterval($0.validFrom) <= timestamp.timeIntervalSince1970
          && $0.isSameBusinessID(businessID)
      }

      if let configuration = configuration {
        setConfiguration(configuration)
      }

      return configuration
    }
  }

  func getConfigurationList(
    mode: ConfigurationMode,
    configurations: [String: [AEMConfiguration]]?
  ) -> [AEMConfiguration] {
    guard let configurations = configurations else { return [] }

    if mode == .brand {
      return (configurations[ConfigurationMode.cpas.rawValue] ?? [])
        + (configurations[ConfigurationMode.brand.rawValue] ?? [])
    } else {
      return configurations[mode.rawValue] ?? []
    }
  }

  func setConfiguration(_ configuration: AEMConfiguration) {
    configurationID = configuration.validFrom
    configurationMode = configuration.mode
  }

  // MARK: - NSCoding

  static var supportsSecureCoding: Bool { true }

  required init?(coder decoder: NSCoder) {
    guard
      let campaignID = decoder.decodeObject(of: NSString.self, forKey: Key.campaignIdentifier.rawValue),
      let acsToken = decoder.decodeObject(of: NSString.self, forKey: Key.acsToken.rawValue),
      let configurationMode = decoder.decodeObject(of: NSString.self, forKey: Key.configurationMode.rawValue)
    else { return nil }

    let acsSharedSecret = decoder.decodeObject(of: NSString.self, forKey: Key.acsSharedSecret.rawValue)
    let acsConfigurationID = decoder.decodeObject(of: NSString.self, forKey: Key.acsConfigurationIdentifier.rawValue)
    let businessID = decoder.decodeObject(of: NSString.self, forKey: Key.businessIdentifier.rawValue)
    let catalogID = decoder.decodeObject(of: NSString.self, forKey: Key.catalogIdentifier.rawValue)
    let timestamp = decoder.decodeObject(of: NSDate.self, forKey: Key.timestamp.rawValue) ?? NSDate()
    let configurationID = decoder.decodeInteger(forKey: Key.configurationIdentifier.rawValue)
    let recordedEvents = decoder.decodeObject(
      of: [NSSet.self, NSString.self],
      forKey: Key.recordedEvents.rawValue
    ) as? NSSet
    let recordedValues = decoder.decodeObject(
      of: [NSDictionary.self, NSString.self, NSNumber.self],
      forKey: Key.recordedValues.rawValue
    ) as? [String: [String: Any]]
    let conversionValue = decoder.decodeInteger(forKey: Key.conversionValue.rawValue)
    let priority = decoder.decodeInteger(forKey: Key.priority.rawValue)
    let conversionTimestamp = decoder.decodeObject(of: NSDate.self, forKey: Key.conversionTimestamp.rawValue)
    let isAggregated = decoder.decodeBool(forKey: Key.isAggregated.rawValue)
    let hasStoreKitAdNetwork = decoder.decodeBool(forKey: Key.hasStoreKitAdNetwork.rawValue)
    let isConversionFilteringEligible = decoder.decodeBool(forKey: Key.isConversionFilteringEligible.rawValue)

    self.campaignID = campaignID as String
    self.acsToken = acsToken as String
    self.acsSharedSecret = acsSharedSecret as String?
    self.acsConfigurationID = acsConfigurationID as String?
    self.businessID = businessID as String?
    self.catalogID = catalogID as String?
    self.timestamp = timestamp as Date
    self.configurationMode = configurationMode as String
    self.configurationID = configurationID
    self.recordedEvents = (recordedEvents as? Set<String>) ?? []
    self.recordedValues = recordedValues ?? [:]
    self.conversionValue = conversionValue
    self.priority = priority
    self.conversionTimestamp = conversionTimestamp as Date?
    self.isAggregated = isAggregated
    isTestMode = false
    self.hasStoreKitAdNetwork = hasStoreKitAdNetwork
    self.isConversionFilteringEligible = isConversionFilteringEligible
  }

  func encode(with encoder: NSCoder) {
    encoder.encode(campaignID, forKey: Key.campaignIdentifier.rawValue)
    encoder.encode(acsToken, forKey: Key.acsToken.rawValue)
    encoder.encode(acsSharedSecret, forKey: Key.acsSharedSecret.rawValue)
    encoder.encode(acsConfigurationID, forKey: Key.acsConfigurationIdentifier.rawValue)
    encoder.encode(businessID, forKey: Key.businessIdentifier.rawValue)
    encoder.encode(catalogID, forKey: Key.catalogIdentifier.rawValue)
    encoder.encode(timestamp, forKey: Key.timestamp.rawValue)
    encoder.encode(configurationMode, forKey: Key.configurationMode.rawValue)
    encoder.encode(configurationID, forKey: Key.configurationIdentifier.rawValue)
    encoder.encode(recordedEvents, forKey: Key.recordedEvents.rawValue)
    encoder.encode(recordedValues, forKey: Key.recordedValues.rawValue)
    encoder.encode(conversionValue, forKey: Key.conversionValue.rawValue)
    encoder.encode(priority, forKey: Key.priority.rawValue)
    encoder.encode(conversionTimestamp, forKey: Key.conversionTimestamp.rawValue)
    encoder.encode(isAggregated, forKey: Key.isAggregated.rawValue)
    encoder.encode(hasStoreKitAdNetwork, forKey: Key.hasStoreKitAdNetwork.rawValue)
    encoder.encode(isConversionFilteringEligible, forKey: Key.isConversionFilteringEligible.rawValue)
  }

  #if DEBUG
  func reset() {
    timestamp = Date()
    configurationMode = "DEFAULT"
    configurationID = -1
    businessID = nil
    catalogID = nil
    recordedEvents = []
    recordedValues = [:]
    conversionValue = -1
    priority = -1
    conversionTimestamp = Date()
    isAggregated = true
    hasStoreKitAdNetwork = false
    isConversionFilteringEligible = true
  }
  #endif
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMNetworker: NSObject, AEMNetworking, URLSessionDataDelegate {

  enum Error: Swift.Error {
    case missingOperationQueue
    case failedToCreateURL
    case failedToParseJSON
  }

  private enum Values {
    static let newline = "\r\n"
    static let versionString = "16.1.2"
    static let defaultGraphAPIVersion = "v16.0"
    static let SDK = "ios"
    static let userAgentBase = "FBiOSAEM"
    static let graphAPIEndpoint = "https://graph.facebook.com/v16.0/"
    static let graphAPIContentType = "application/json"
    static let errorDomain = "com.facebook.aemkit"
    static let agent = "\(Values.userAgentBase).\(Values.versionString)"
  }

  var userAgentSuffix: String?

  lazy var userAgent: String = {
    var agentWithSuffix = Values.agent
    if let userAgentSuffix = userAgentSuffix, !userAgentSuffix.isEmpty {
      agentWithSuffix += "/\(userAgentSuffix)"
    }

    if #available(iOS 13.0, *),
       ProcessInfo.processInfo.isMacCatalystApp {
      return agentWithSuffix + "/macOS"
    }

    return agentWithSuffix
  }()

  // MARK: - AEMNetworking

  func startGraphRequest(
    withGraphPath graphPath: String,
    parameters: [String: Any],
    tokenString: String?,
    httpMethod method: String?,
    completion: @escaping FBGraphRequestCompletion
  ) {
    guard let url = URL(string: Values.graphAPIEndpoint + graphPath) else {
      completion(nil, Error.failedToCreateURL)
      return
    }

    var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 60.0)
    request.httpMethod = method
    request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
    request.setValue(Values.graphAPIContentType, forHTTPHeaderField: "Content-Type")
    request.httpShouldHandleCookies = false

    // add parameters to body
    let body = AEMRequestBody()

    var params = parameters
    params["format"] = "json"
    params["sdk"] = Values.SDK
    params["include_headers"] = "false"

    appendAttachments(attachments: params, toBody: body, addFormData: method == "POST")

    if request.httpMethod == "POST" {
      request.httpBody = body.compressedData()
      request.setValue("gzip", forHTTPHeaderField: "Content-Encoding")
    } else {
      request.httpBody = body.data
    }

    guard let queue = OperationQueue.current else {
      completion(nil, Error.missingOperationQueue)
      return
    }

    let session = FBSDKURLSession(delegate: self, delegateQueue: queue)
    session.execute(request) { [weak self] responseData, response, error in
      guard let httpResponse = response as? HTTPURLResponse else {
        completion(nil, URLError(.badServerResponse))
        return
      }

      if let error = error {
        completion(nil, error)
        return
      }

      var parseError: Swift.Error?
      let result = self?.parseJSONResponse(
        data: responseData,
        error: &parseError,
        statusCode: httpResponse.statusCode
      )

      if let error = parseError {
        completion(nil, error)
        return
      }

      guard let result = result else {
        completion(nil, Error.failedToParseJSON)
        return
      }

      completion(result, error)
    }
  }

  func parseJSONResponse(
    data: Data?,
    error: inout Swift.Error?,
    statusCode: Int
  ) -> [String: Any] {
    guard let data = data else {
      return [:]
    }

    let rawResponse = String(data: data, encoding: .utf8)
    let response = parseJSONOrOtherwise(unsafeString: rawResponse, error: &error)
    var result = [String: Any]()

    if rawResponse == nil {
      let base64Data = !data.isEmpty ? data.base64EncodedString(options: .lineLength64Characters) : ""
      if !base64Data.isEmpty {
        print("fb_response_invalid_utf8")
      }
    }

    if response == nil,
       error == nil {
      error = NSError(domain: Values.errorDomain, code: statusCode, userInfo: nil)
    } else if error != nil, response != nil {
      return [:]
    } else if let response = response as? [String: Any] {
      result = response
    }

    return result
  }

  func parseJSONOrOtherwise(
    unsafeString: String?,
    error: inout Swift.Error?
  ) -> Any? {
    var parsed: Any?
    if let utf8 = unsafeString, error == nil {
      guard let data = utf8.data(using: .utf8) else {
        return nil
      }
      do {
        parsed = try JSONSerialization.jsonObject(with: data, options: [])
        // swiftlint:disable:next untyped_error_in_catch
      } catch let serializationError {
        error = serializationError
        return nil
      }
    }
    return parsed
  }

  func appendAttachments(
    attachments: [String: Any],
    toBody body: AEMRequestBody,
    addFormData: Bool
  ) {
    for (key, value) in attachments {
      var typedvalue: String?
      if let integer = value as? Int {
        typedvalue = "\(integer)"
      } else if let number = value as? NSNumber {
        typedvalue = number.stringValue
      } else if let url = value as? URL {
        typedvalue = url.absoluteString
      }

      if let string = typedvalue,
         addFormData {
        body.append(withKey: key, formValue: string)
      } else {
        print("Unsupported attachment:\(String(describing: value)), skipping.")
      }
    }
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

public typealias FBGraphRequestCompletion = (Any?, Error?) -> Void

@objc(FBAEMNetworking)
public protocol AEMNetworking {
  @objc(startGraphRequestWithGraphPath:parameters:tokenString:HTTPMethod:completion:)
  func startGraphRequest(
    withGraphPath graphPath: String,
    parameters: [String: Any],
    tokenString: String?,
    httpMethod method: String?,
    completion: @escaping FBGraphRequestCompletion
  )
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

typealias FBAEMReporterBlock = (NSError?) -> Void

@objcMembers
@objc(FBAEMReporter)
public final class AEMReporter: NSObject {
  private enum Keys {
    static let businessID = "advertiser_id"
    static let businessIDs = "advertiser_ids"
    static let fbContentData = "fb_content_data"
    static let alApplinkData = "al_applink_data"
    static let campaignId = "campaign_id"
    static let conversionData = "conversion_data"
    static let consumptionHour = "consumption_hour"
    static let token = "token"
    static let hmac = "hmac"
    static let configId = "config_id"
    static let delayFlow = "delay_flow"
    static let isConversionFiltering = "is_conversion_filtering"
    static let fbContentIds = "fb_content_ids"
    static let catalogId = "catalog_id"
    static let minAggregationRequestTimestamp = "com.facebook.sdk:FBAEMMinAggregationRequestTimestamp"
    static let fields = "fields"
    static let data = "data"
    static let aemConversions = "aem_conversions"
    static let success = "success"
    static let isValidMatch = "is_valid_match"
    static let matchedAdvertiserId = "matched_advertiser_id"
    static let inSegmentValue = "in_segment_value"
    static let contentIdBelongsToCatalogId = "content_id_belongs_to_catalog_id"
  }

  private enum DispatchQueueLabels {
    static let appEvents = "com.facebook.appevents.AEM.FBAEMReporter"
  }

  private enum TimeIntervals {
    static let aemConfigTimeout: TimeInterval = 86400
    static let aemDelay: TimeInterval = 3
  }

  private enum FileNames {
    static let aemReporter = "FBSDKAEMReportData.report"
    static let aemConfig = "FBSDKAEMReportData.config"
  }

  private enum Paths {
    static let aemConversionConfigs = "aem_conversion_configs"
    static let aemConversionFilter = "aem_conversion_filter"
    static let aemAttribution = "aem_attribution"
  }

  private enum HTTPMethods {
    static let GET = "GET"
    static let POST = "POST"
  }

  static var networker: AEMNetworking?
  static var appID: String?
  static let nullAppID = "(null)" // Objective-C uses "(null)" if there are nil objects in an interpolated string
  static var analyticsAppID: String?
  static var reporter: SKAdNetworkReporting?
  static var dataStore: DataPersisting?

  static var isAEMReportEnabled = false
  static var isLoadingConfiguration = false
  static var isConversionFilteringEnabled = false
  static var isCatalogMatchingEnabled = false
  static var isAdvertiserRuleMatchInServerEnabled = false
  static var serialQueue = DispatchQueue(label: DispatchQueueLabels.appEvents)
  static var reportFile: String?
  private static var configFile: String?
  static var configurations: [String: [AEMConfiguration]] = [:]
  static var invocations: [AEMInvocation] = []
  static var configRefreshTimestamp: Date?
  static var minAggregationRequestTimestamp: Date?
  static var completionBlocks: [FBAEMReporterBlock] = []

  public static func configure(
    networker: AEMNetworking?,
    appID: String?,
    reporter: SKAdNetworkReporting?
  ) {
    configure(
      networker: networker,
      appID: appID,
      reporter: reporter,
      analyticsAppID: nil
    )
  }

  private static func configure(
    networker: AEMNetworking?,
    appID: String?,
    reporter: SKAdNetworkReporting?,
    analyticsAppID: String?
  ) {
    configure(
      networker: networker,
      appID: appID,
      reporter: reporter,
      analyticsAppID: analyticsAppID,
      store: UserDefaults.standard
    )
  }

  public static func configure(
    networker: AEMNetworking?,
    appID: String?,
    reporter: SKAdNetworkReporting?,
    analyticsAppID: String?,
    store: DataPersisting?
  ) {
    Self.networker = networker
    Self.appID = appID
    Self.reporter = reporter
    Self.analyticsAppID = analyticsAppID
    Self.dataStore = store
  }

  /**
   Enable AEM reporting

   This function should be called in application(_:open:options:) from ApplicationDelegate
   */
  public static func enable() {
    guard
      #available(iOS 14.0, *),
      !isAEMReportEnabled
    else {
      return
    }

    isAEMReportEnabled = true

    AEMConfiguration.configure(withRuleProvider: AEMAdvertiserRuleFactory())
    reportFile = BasicUtility.persistenceFilePath(FileNames.aemReporter)
    configFile = BasicUtility.persistenceFilePath(FileNames.aemConfig)
    completionBlocks = []

    dispatchOnQueue(serialQueue) {
      minAggregationRequestTimestamp = loadMinAggregationRequestTimestamp()
      configurations = loadConfigurations()
      invocations = loadReportData()
    }

    loadConfiguration(withRefreshForced: false) { error in
      if error != nil {
        return
      }

      sendAggregationRequest()
      clearCache()
    }

    // If developers forget to call configureWithNetworker:appID:
    // or pass nil for networker,
    // we use default networker in FBAEMKit
    if networker == nil {
      let networker = AEMNetworker()
      networker.userAgentSuffix = analyticsAppID
      self.networker = networker
    }

    // If developers forget to call configureWithNetworker:appID:,
    // we will look up app Any in plist file, key is FacebookAppID
    if appID == nil || appID?.isEmpty == true {
      appID = AEMSettings.appID()
    }

    // If appID is still nil/empty, we don't enable AEM and throw warning here
    if appID == nil || appID?.isEmpty == true {
      // swiftlint:disable:next line_length
      print("App ID is not set up correctly, please call configureWithNetworker:appID: and pass correct FB App ID OR add FacebookAppID in Info.plist")
    }
  }

  /**
   Control whether to enable conversion filtering

   This function should be called in `application(_:open:options:)` from ApplicationDelegate
   */
  public static func setConversionFilteringEnabled(_ enabled: Bool) {
    isConversionFilteringEnabled = enabled
  }

  /**
   Control whether to enable catalog matching

   This function should be called in `application(_:open:options:)` from ApplicationDelegate
   */
  public static func setCatalogMatchingEnabled(_ enabled: Bool) {
    isCatalogMatchingEnabled = enabled
  }

  /**
   Control whether to enable advertiser rule match enabled in server side. This is expected
   to be called internally by FB SDK and will be removed in the future

   This function should be called in `application(_:open:options:)` from ApplicationDelegate
   */
  public static func setAdvertiserRuleMatchInServerEnabled(_ enabled: Bool) {
    isAdvertiserRuleMatchInServerEnabled = enabled
  }

  /**
   Handle deeplink

   This function should be called in `application(_:open:options:) `from ApplicationDelegate
   */
  public static func handle(_ url: URL?) {
    guard
      isAEMReportEnabled,
      let invocation = parseURL(url)
    else {
      return
    }

    guard !invocation.isTestMode else {
      sendDebuggingRequest(invocation)
      return
    }

    loadConfiguration(withRefreshForced: true, block: nil)
    appendAndSaveInvocation(invocation)
  }

  static func parseURL(_ url: URL?) -> AEMInvocation? {
    guard let url = url else {
      return nil
    }

    let params = BasicUtility.dictionary(withQueryString: url.query ?? "")
    guard let applinkDataString = params[Keys.alApplinkData] else {
      return nil
    }

    guard let applinkData = try? BasicUtility.object(forJSONString: applinkDataString) as? [AnyHashable: Any] else {
      return nil
    }

    return AEMInvocation(appLinkData: applinkData)
  }

  /**
   Calculate the conversion value for the app event based on the AEM configuration

   This function should be called when you log any in-app events
   */
  @objc(recordAndUpdateEvent:currency:value:parameters:)
  public static func recordAndUpdate(
    event: String,
    currency: String?,
    value: NSNumber?,
    parameters: [String: Any]?
  ) {
    guard #available(iOS 14.0, *) else {
      return
    }

    if !isAEMReportEnabled || event.isEmpty {
      return
    }

    loadConfiguration(withRefreshForced: false) { _ in
      if configurations.isEmpty || invocations.isEmpty {
        return
      }

      let businessIDs = AEMUtility.shared.getBusinessIDsInOrder(invocations)
      if isAdvertiserRuleMatchInServerEnabled,
         let businessID = businessIDs.first,
         !businessID.isEmpty {
        self.loadRuleMatch(businessIDs, event: event, currency: currency, value: value, parameters: parameters)
      } else {
        self.attributionV1WithEvent(event, currency: currency, value: value, parameters: parameters)
      }
    }
  }

  private static func attributionV1WithEvent(
    _ event: String,
    currency: String?,
    value: NSNumber?,
    parameters: [String: Any]?
  ) {
    guard let attributedInvocation = attributedInvocation(
      invocations,
      event: event,
      currency: currency,
      value: value,
      parameters: parameters,
      configurations: configurations
    ) else {
      return
    }

    attributionWithInvocation(
      attributedInvocation,
      event: event,
      currency: currency,
      value: value,
      parameters: parameters,
      isRuleMatchInServer: false
    )
  }

  // swiftlint:disable:next function_parameter_count
  private static func attributionWithInvocation(
    _ invocation: AEMInvocation,
    event: String,
    currency: String?,
    value: NSNumber?,
    parameters: [String: Any]?,
    isRuleMatchInServer: Bool
  ) {
    // We will report conversion in catalog level if
    // 1. conversion filtering and catalog matching are enabled
    // 2. invocation has catalog Any
    // 3. event is optimized
    // 4. event's content Any belongs to the catalog
    if shouldReportConversion(inCatalogLevel: invocation, event: event) {
      let contentID = AEMUtility.shared.getContentID(parameters)
      loadCatalogOptimization(with: invocation, contentID: contentID) {
        self.updateAttributedInvocation(
          invocation,
          event: event,
          currency: currency,
          value: value,
          parameters: parameters,
          shouldBoostPriority: true,
          isRuleMatchInServer: isRuleMatchInServer
        )
      }
    } else {
      updateAttributedInvocation(
        invocation,
        event: event,
        currency: currency,
        value: value,
        parameters: parameters,
        shouldBoostPriority: isConversionFilteringEnabled,
        isRuleMatchInServer: isRuleMatchInServer
      )
    }
  }

  // swiftlint:disable:next function_parameter_count
  private static func updateAttributedInvocation(
    _ invocation: AEMInvocation,
    event: String,
    currency: String?,
    value: NSNumber?,
    parameters: [String: Any]?,
    shouldBoostPriority: Bool,
    isRuleMatchInServer: Bool
  ) {
    invocation.attributeEvent(
      event,
      currency: currency,
      value: value,
      parameters: parameters,
      configurations: configurations,
      shouldUpdateCache: true,
      isRuleMatchInServer: isRuleMatchInServer
    )

    if invocation.updateConversionValue(
      configurations: configurations,
      event: event,
      shouldBoostPriority: shouldBoostPriority
    ) {
      sendAggregationRequest()
    }

    saveReportData()
  }

  // swiftlint:disable:next function_parameter_count
  static func attributedInvocation(
    _ invocations: [AEMInvocation],
    event: String,
    currency: String?,
    value: NSNumber?,
    parameters: [String: Any]?,
    configurations: [String: [AEMConfiguration]]
  ) -> AEMInvocation? {
    var isGeneralInvocationVisited = false
    var attributedInvocation: AEMInvocation?
    for invocation in invocations.reversed() {
      if isDoubleCounting(invocation, event: event) {
        break
      }

      if invocation.businessID == nil,
         isGeneralInvocationVisited {
        continue
      }

      if invocation.attributeEvent(
        event,
        currency: currency,
        value: value,
        parameters: parameters,
        configurations: configurations,
        shouldUpdateCache: false,
        isRuleMatchInServer: false
      ) {
        attributedInvocation = invocation
        break
      }

      if invocation.businessID == nil {
        isGeneralInvocationVisited = true
      }
    }
    return attributedInvocation
  }

  static func isDoubleCounting(
    _ invocation: AEMInvocation,
    event: String
  ) -> Bool {
    // We consider it as Double counting if following conditions meet simultaneously
    // 1. The field hasStoreKitAdNetwork is true
    // 2. The conversion happens before SKAdNetwork cutoff
    // 3. The event is also being reported by SKAdNetwork
    return invocation.hasStoreKitAdNetwork
      && reporter?.shouldCutoff() == false
      && reporter?.isReportingEvent(event) == true
  }

  private static func appendAndSaveInvocation(_ invocation: AEMInvocation) {
    dispatchOnQueue(serialQueue) {
      invocations.append(invocation)
      saveReportData()
    }
  }

  static func loadConfiguration(withRefreshForced forced: Bool, block: FBAEMReporterBlock?) {
    dispatchOnQueue(serialQueue) {
      if let block = block {
        completionBlocks.append(block)
      }

      // Executes blocks if there is cache
      if !shouldRefresh(withIsForced: forced) {
        for executionBlock in completionBlocks {
          executionBlock(nil)
        }
        completionBlocks.removeAll()
        return
      }

      if isLoadingConfiguration {
        return
      }

      isLoadingConfiguration = true

      networker?.startGraphRequest(
        withGraphPath: "\(appID ?? nullAppID)/\(Paths.aemConversionConfigs)",
        parameters: requestParameters(),
        tokenString: nil,
        httpMethod: HTTPMethods.GET
      ) { result, error in
        dispatchOnQueue(serialQueue) {
          if let error = error {
            for executionBlock in completionBlocks {
              executionBlock(error as NSError)
            }
            completionBlocks.removeAll()
            isLoadingConfiguration = false
            return
          }

          if let json = result as? [String: Any] {
            configRefreshTimestamp = Date()
            if let configurations = json[Keys.data] as? [[String: Any]] {
              self.addConfigurations(configurations)
            }

            for executionBlock in completionBlocks {
              executionBlock(nil)
            }
            completionBlocks.removeAll()
          } else {
            print("Received invalid AEM configuration")
          }

          isLoadingConfiguration = false
        }
      }
    }
  }

  static func loadCatalogOptimization(
    with invocation: AEMInvocation,
    contentID: String?,
    block: @escaping () -> Void
  ) {
    networker?.startGraphRequest(
      withGraphPath: "\(appID ?? nullAppID)/\(Paths.aemConversionFilter)",
      parameters: catalogRequestParameters(invocation.catalogID, contentID: contentID),
      tokenString: nil,
      httpMethod: HTTPMethods.GET
    ) { result, error in
      dispatchOnQueue(serialQueue) {
        guard error == nil else {
          return
        }

        if self.isContentOptimized(result) {
          block()
        }
      }
    }
  }

  static func loadRuleMatch(
    _ businessIDs: [String],
    event: String,
    currency potentialCurrency: String?,
    value potentialValue: NSNumber?,
    parameters: [String: Any]?
  ) {
    let content = AEMUtility.shared.getContent(parameters)
    networker?.startGraphRequest(
      withGraphPath: "\(appID ?? nullAppID)/\(Paths.aemAttribution)",
      parameters: ruleMatchRequestParameters(businessIDs, content: content),
      tokenString: nil,
      httpMethod: HTTPMethods.GET
    ) { result, error in
      if error != nil || result == nil {
        return
      }

      guard
        let result = result as? [String: Any],
        let data: [Any] = result[Keys.data] as? [Any],
        let json = data.first as? [String: Any]
      else {
        return
      }

      guard let success = json[Keys.success] as? NSNumber else { return }

      if success.boolValue {
        guard let isValidMatch = json[Keys.isValidMatch] as? NSNumber else {
          return
        }

        let matchedBusinessID = json[Keys.matchedAdvertiserId] as? String
        let inSegmentValue = json[Keys.inSegmentValue] as? NSNumber
        let matchedInvocation = AEMUtility.shared.getMatchedInvocation(invocations, businessID: matchedBusinessID)
        // Drop the conversion if not a valid match or no matched invocation
        if !isValidMatch.boolValue || matchedInvocation == nil {
          return
        }

        guard let matchedInvocation = matchedInvocation else { return }

        dispatchOnQueue(serialQueue) {
          var currency = potentialCurrency
          var value = potentialValue
          if matchedInvocation.businessID != nil {
            currency = "USD"
            value = inSegmentValue
          }

          self.attributionWithInvocation(
            matchedInvocation,
            event: event,
            currency: currency,
            value: value,
            parameters: parameters,
            isRuleMatchInServer: true
          )
        }
      } else {
        // Fall back to attribution v1 if fails
        dispatchOnQueue(serialQueue) {
          self.attributionV1WithEvent(
            event,
            currency: potentialCurrency,
            value: potentialValue,
            parameters: parameters
          )
        }
      }
    }
  }

  static func shouldReportConversion(
    inCatalogLevel invocation: AEMInvocation,
    event: String
  ) -> Bool {
    isConversionFilteringEnabled
      && isCatalogMatchingEnabled
      && invocation.catalogID != nil
      && invocation.isOptimizedEvent(event, configurations: configurations)
  }

  static func isContentOptimized(_ result: Any?) -> Bool {
    let json = result as? [String: Any]
    let data = json?[Keys.data] as? [Any]
    let catalogData = data?.first as? [String: Any]
    let isOptimized = catalogData?[Keys.contentIdBelongsToCatalogId] as? NSNumber ?? false
    return isOptimized.boolValue
  }

  static func requestParameters() -> [String: Any] {
    var params: [String: Any] = [:]
    // append business ids to the request params
    var businessIDs: [String] = []
    for invocation in invocations {
      if let businessID = invocation.businessID {
        businessIDs.append(businessID)
      }
    }

    let businessIDsString = try? BasicUtility.jsonString(for: businessIDs)
    params[Keys.businessIDs] = businessIDsString
    params[Keys.fields] = ""
    return params
  }

  static func catalogRequestParameters(
    _ catalogID: String?,
    contentID: String?
  ) -> [String: Any] {
    [
      Keys.fbContentIds: contentID,
      Keys.catalogId: catalogID,
    ].compactMapValues { $0 }
  }

  static func ruleMatchRequestParameters(
    _ businessIDs: [String],
    content: String?
  ) -> [String: Any] {
    let businessIDsString = try? BasicUtility.jsonString(for: businessIDs)

    return [
      Keys.businessIDs: businessIDsString,
      Keys.fbContentData: content,
    ].compactMapValues { $0 }
  }

  static func isConfigRefreshTimestampValid() -> Bool {
    guard let timestamp = configRefreshTimestamp else {
      return false
    }

    return Date().timeIntervalSince(timestamp) < TimeIntervals.aemConfigTimeout
  }

  static func shouldRefresh(withIsForced isForced: Bool) -> Bool {
    if isForced {
      return true
    }

    // Refresh if there exists invocation which has business ID
    for invocation in invocations where invocation.businessID != nil {
      return true
    }

    // Refresh if timestamp is expired or cached configuration is empty
    return !isConfigRefreshTimestampValid() || configurations.isEmpty
  }

  static func shouldDelayAggregationRequest() -> Bool {
    guard let timestamp = minAggregationRequestTimestamp else {
      return false
    }
    return Date().timeIntervalSince(timestamp) < 0
  }

  // MARK: - Deeplink debugging methods

  static func sendDebuggingRequest(_ invocation: AEMInvocation) {
    var params: [[String: Any]] = []
    params.append(debuggingRequestParameters(invocation))
    if params.isEmpty {
      return
    }

    guard
      let jsonData = try? TypeUtility.data(withJSONObject: params),
      let reports = String(data: jsonData, encoding: .utf8)
    else {
      return
    }

    networker?.startGraphRequest(
      withGraphPath: "\(appID ?? nullAppID)/\(Keys.aemConversions)",
      parameters: [Keys.aemConversions: reports],
      tokenString: nil,
      httpMethod: HTTPMethods.POST
    ) { _, error in
      if let error = error {
        print("Fail to send AEM debugging request with error: \(error)")
      }
    }
  }

  static func debuggingRequestParameters(_ invocation: AEMInvocation) -> [String: Any] {
    [
      Keys.campaignId: invocation.campaignID,
      Keys.conversionData: 0,
      Keys.consumptionHour: 0,
      Keys.token: invocation.acsToken,
      Keys.delayFlow: "server",
    ]
  }

  // MARK: - Background methods

  static func loadMinAggregationRequestTimestamp() -> Date? {
    dataStore?.fb_object(forKey: Keys.minAggregationRequestTimestamp) as? Date
  }

  static func updateAggregationRequestTimestamp(_ timeInterval: TimeInterval) {
    let newTimestamp = Date(timeIntervalSince1970: timeInterval)
    minAggregationRequestTimestamp = newTimestamp
    dataStore?.fb_setObject(newTimestamp, forKey: Keys.minAggregationRequestTimestamp)
  }

  static func loadConfigurations() -> [String: [AEMConfiguration]] {
    guard let configFile = configFile else { return [:] }

    if let cachedConfiguration = try? NSData(contentsOfFile: configFile, options: .mappedIfSafe) {
      let cache = try? NSKeyedUnarchiver.unarchivedObject(
        ofClasses: [
          NSDictionary.self,
          NSArray.self,
          NSString.self,
          AEMConfiguration.self,
          AEMRule.self,
          AEMEvent.self,
        ],
        from: cachedConfiguration as Data
      ) as? [String: [AEMConfiguration]]

      if let cache = cache {
        return cache
      }
    }

    return [:]
  }

  private static func saveConfigurations() {
    let cache = try? NSKeyedArchiver.archivedData(withRootObject: configurations, requiringSecureCoding: false)
    guard
      let cache = cache,
      let configFile = configFile
    else {
      return
    }

    (cache as NSData).write(toFile: configFile, atomically: true)
  }

  static func addConfigurations(_ configurations: [[String: Any]]) {
    if configurations.isEmpty {
      return
    }

    for configuration in configurations {
      addConfiguration(AEMConfiguration(json: configuration))
    }
    saveConfigurations()
  }

  private static func addConfiguration(_ configuration: AEMConfiguration?) {
    guard let configuration = configuration else { return }

    let configurationsForMode = configurations[configuration.mode] ?? []
    // Remove the configuration in the array that has the same "validFrom" and "businessID" as the added configuration
    var newConfigurations: [AEMConfiguration] = []
    for candidateConfiguration in configurationsForMode {
      if configuration.isSame(
        validFrom: candidateConfiguration.validFrom,
        businessID: candidateConfiguration.businessID
      ) {
        continue
      }

      newConfigurations.append(candidateConfiguration)
    }
    newConfigurations.append(configuration)
    // Sort the configurations via "validFrom"

    if #available(iOS 15.0, *) {
      newConfigurations.sort(using: KeyPathComparator(\.validFrom))
    } else {
      newConfigurations.sort { $0.validFrom < $1.validFrom }
    }
    configurations[configuration.mode] = newConfigurations
  }

  static func loadReportData() -> [AEMInvocation] {
    guard let reportFile = reportFile else { return [] }
    if let cachedReportData = try? NSData(contentsOfFile: reportFile, options: .mappedIfSafe) {
      let cache = try? NSKeyedUnarchiver.unarchivedObject(
        ofClasses: [NSArray.self, AEMInvocation.self],
        from: cachedReportData as Data
      ) as? [AEMInvocation]

      if let cache = cache {
        return cache
      }
    }
    return []
  }

  static func saveReportData() {
    let cache = try? NSKeyedArchiver.archivedData(withRootObject: invocations, requiringSecureCoding: false)
    if let cache = cache,
       let reportFile = reportFile {
      (cache as NSData).write(toFile: reportFile, atomically: true)
    }
  }

  static func sendAggregationRequest() {
    var params: [[String: Any]] = []
    var aggregatedInvocations: [AEMInvocation] = []
    for invocation in invocations {
      if !invocation.isAggregated {
        params.append(aggregationRequestParameters(invocation))
        aggregatedInvocations.append(invocation)
      }
    }

    if params.isEmpty {
      return
    }

    let block = {
      guard
        let jsonData = try? TypeUtility.data(withJSONObject: params),
        let reports = String(data: jsonData, encoding: .utf8)
      else {
        return
      }

      networker?.startGraphRequest(
        withGraphPath: "\(appID ?? nullAppID)/\(Keys.aemConversions)",
        parameters: [Keys.aemConversions: reports],
        tokenString: nil,
        httpMethod: HTTPMethods.POST
      ) { _, error in
        if error != nil {
          return
        }

        dispatchOnQueue(serialQueue) {
          for invocation in aggregatedInvocations {
            invocation.isAggregated = true
          }
          saveReportData()
        }
      }
    }

    if shouldDelayAggregationRequest() {
      var timestampDelay: TimeInterval = 0
      if let timestamp = minAggregationRequestTimestamp {
        timestampDelay = timestamp.timeIntervalSince1970 - Date().timeIntervalSince1970
      }

      let delay = max(TimeIntervals.aemDelay, timestampDelay)
      dispatchOnQueue(serialQueue, delay: delay, block: block)
    } else {
      block()
    }

    let minAggregationRequestTimestampDelay = minAggregationRequestTimestamp?.timeIntervalSince1970 ?? 0
    updateAggregationRequestTimestamp(
      max(
        Date().timeIntervalSince1970 + TimeIntervals.aemDelay,
        minAggregationRequestTimestampDelay + TimeIntervals.aemDelay
      )
    )
  }

  static func aggregationRequestParameters(_ invocation: AEMInvocation) -> [String: Any] {
    let delay = 24 + arc4random_uniform(24)
    let enableConversionFiltering = invocation.isConversionFilteringEligible && isConversionFilteringEnabled
    return [
      Keys.campaignId: invocation.campaignID,
      Keys.conversionData: invocation.conversionValue,
      Keys.consumptionHour: delay,
      Keys.token: invocation.acsToken,
      Keys.delayFlow: "server",
      Keys.configId: invocation.acsConfigurationID,
      Keys.hmac: invocation.getHMAC(delay: Int(delay)),
      Keys.businessID: invocation.businessID,
      Keys.isConversionFiltering: enableConversionFiltering,
    ].compactMapValues { $0 }
  }

  private static func dispatchOnQueue(
    _ queue: DispatchQueue,
    delay: TimeInterval? = nil,
    block: (() -> Void)?
  ) {
    guard let block = block else { return }

    if queue.label == DispatchQueueLabels.appEvents {
      if let delay = delay {
        queue.asyncAfter(deadline: .now() + delay, execute: block)
      } else {
        queue.async(execute: block)
      }
    } else { // For tests the queue's label is different
      if delay == nil {
        block()
      }
    }
  }

  static func clearCache() {
    // step 1: clear aggregated invocations that are outside attribution window
    clearInvocations()
    // step 2: clear old configurations that are not used anymore and keep the most recent configuration
    clearConfigurations()
  }

  static func clearConfigurations() {
    var shouldSaveCache = false
    if !configurations.isEmpty {
      var newConfigurationsDict: [String: [AEMConfiguration]] = [:]
      for (key, value) in configurations {
        var oldConfigurations: [AEMConfiguration] = value
        var newConfigurations: [AEMConfiguration] = []

        // Removes the last of the old default mode configurations and stores it so it can be
        // added to the array-to-save
        var lastConfiguration: AEMConfiguration?
        if key == "DEFAULT" {
          lastConfiguration = oldConfigurations.last
          oldConfigurations.removeLast()
        }

        for oldConfiguration in oldConfigurations {
          if !isUsingConfiguration(oldConfiguration, forInvocations: invocations) {
            shouldSaveCache = true
            continue
          }
          newConfigurations.append(oldConfiguration)
        }

        if let lastConfiguration = lastConfiguration {
          newConfigurations.append(lastConfiguration)
        }
        newConfigurationsDict[key] = newConfigurations
      }
      configurations = newConfigurationsDict
    }
    if shouldSaveCache {
      saveConfigurations()
    }
  }

  private static func clearInvocations() {
    var isInvocationCacheUpdated = false
    if !invocations.isEmpty {
      var newInvocations: [AEMInvocation] = []
      for invocation in invocations {
        if invocation.isOutOfWindow(configurations: configurations), invocation.isAggregated {
          isInvocationCacheUpdated = true
          continue
        }
        newInvocations.append(invocation)
      }
      invocations = newInvocations
    }

    if isInvocationCacheUpdated {
      saveReportData()
    }
  }

  private static func isUsingConfiguration(
    _ configuration: AEMConfiguration,
    forInvocations invocations: [AEMInvocation]
  ) -> Bool {
    for invocation in invocations {
      if configuration.isSame(validFrom: invocation.configurationID, businessID: invocation.businessID) {
        return true
      }
    }
    return false
  }

  // MARK: - Testability

  #if DEBUG

  public static func reset() {
    isAEMReportEnabled = false
    isLoadingConfiguration = false
    isConversionFilteringEnabled = false
    isCatalogMatchingEnabled = false
    isAdvertiserRuleMatchInServerEnabled = false
    completionBlocks = []
    configurations = [:]
    minAggregationRequestTimestamp = nil
    networker = nil
    appID = nil
    reporter = nil
    dataStore = nil
    clearCache()
  }

  #endif
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMRequestBody {

  /// Compressed version of `data`
  func compressedData() -> Data? {
    if data.isEmpty {
      return nil
    }
    return BasicUtility.gzip(data)
  }

  #if DEBUG
  var multipartData: Data {
    _data
  }
  #endif

  /// Requests Constants
  private enum Constants {
    static let kNewline = "\r\n"
  }

  /// Callback alias
  typealias AEMCodeBlock = () -> Void

  var data: Data {
    var jsonData = Data()
    if !json.keys.isEmpty,
       let data = try? TypeUtility.data(withJSONObject: json, options: .sortedKeys) {
      jsonData = data
    }
    return jsonData
  }

  private var _data = Data()

  /// JSON Dictionary
  private var json = [String: Any]()

  func append(withKey key: String?, formValue value: String?) {
    _append(with: key, filename: nil, contentType: nil) { [weak self] in
      guard let value = value else {
        return
      }
      self?.append(utf8: value)
    }

    if let key = key,
       let value = value {
      json[key] = value
    }
  }

  private func append(utf8: String) {
    if _data.isEmpty {
      let headerUTF8 = String(format: "--%@", Constants.kNewline)
      let headerData = headerUTF8.data(using: .utf8) ?? Data()
      _data.append(headerData)
    }
    guard let data = utf8.data(using: .utf8) else {
      return
    }
    _data.append(data)
  }

  private func _append(
    with key: String?,
    filename: String?,
    contentType: String?,
    contentBlock: AEMCodeBlock?
  ) {
    var disposition = [String]()
    disposition.append("Content-Disposition: form-data")
    if let key = key {
      disposition.append("name=\"\(key)\"")
    }

    if let filename = filename {
      disposition.append("filename=\"\(filename)\"")
    }
    append(utf8: "\(disposition.joined(separator: "; "))\(Constants.kNewline)")
    if let contentType = contentType {
      append(utf8: "Content-Type: \(contentType)\(Constants.kNewline)")
    }
    append(utf8: Constants.kNewline)
    contentBlock?()
    append(utf8: Constants.kNewline)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMRule: NSObject, NSSecureCoding {
  let conversionValue: Int
  let priority: Int
  let events: [AEMEvent]

  private enum Keys {
    static let conversionValueKey = "conversion_value"
    static let priorityKey = "priority"
    static let eventsKey = "events"
  }

  init?(json dict: [String: Any]) {
    let conversionValue = dict[Keys.conversionValueKey] as? NSNumber
    let priority = dict[Keys.priorityKey] as? NSNumber
    let events = AEMRule.parse(events: dict[Keys.eventsKey] as? [[String: Any]] ?? []) ?? []

    guard let conversionValue = conversionValue,
          let priority = priority,
          !events.isEmpty
    else {
      return nil
    }

    self.conversionValue = conversionValue.intValue
    self.priority = priority.intValue
    self.events = events

    super.init()
  }

  /// Check if event contains target event with name
  /// - Parameter event: Event name to check for
  /// - Returns: Boolean
  func containsEvent(_ event: String) -> Bool {
    events.contains { $0.eventName == event }
  }

  /// Check if recorded events and values match `events`
  /// - Parameters:
  ///   - recordedEvents: Recorded events to check
  ///   - recordedValues: Recorded values to check
  /// - Returns: Boolean
  func isMatched(
    withRecordedEvents recordedEvents: Set<String>?,
    recordedValues: [String: [String: Any]]?
  ) -> Bool {
    guard let recordedEvents = recordedEvents else {
      return false
    }

    for event in events {
      // Check if event name matches
      if !recordedEvents.contains(event.eventName) {
        return false
      }
      // Check if event value matches when values is not nil
      if let values = event.values,
         !values.isEmpty {
        let recordedEventValues = recordedValues?[event.eventName] as? [String: Double]
        if !isMatched(values: values, recordedEventValues: recordedEventValues) {
          return false
        }
      }
    }

    return true
  }

  /// Attempts value matching for event values
  /// - Parameters:
  ///   - values: Event values
  ///   - recordedEventValues: Recorded event values to check (nullable)
  /// - Returns: Boolean
  private func isMatched(values: [String: Double], recordedEventValues: [String: Double]?) -> Bool {
    for (currency, valueInMapping) in values {
      let value = recordedEventValues?[currency] ?? 0
      if value >= valueInMapping {
        return true
      }
    }

    return false
  }

  /// Parse json dictionary to collection of `AEMEvent`
  /// - Parameter events: Collection of dictionaries to parse
  /// - Returns: Collection of `AEMEvent` objects
  private static func parse(
    events: [[String: Any]]
  ) -> [AEMEvent]? {
    guard !events.isEmpty else {
      return nil
    }

    return events.compactMap(AEMEvent.init(dict:))
  }

  // MARK: - NSCoding

  static var supportsSecureCoding: Bool {
    true
  }

  init?(coder: NSCoder) {
    let conversionValue = coder.decodeInteger(forKey: Keys.conversionValueKey)
    let priority = coder.decodeInteger(forKey: Keys.priorityKey)
    let events: [AEMEvent] = coder.decodeObject(
      of: [NSArray.classForCoder(), AEMEvent.classForCoder()],
      forKey: Keys.eventsKey
    ) as? [AEMEvent] ?? []

    self.conversionValue = conversionValue
    self.priority = priority
    self.events = events
  }

  func encode(with coder: NSCoder) {
    coder.encode(conversionValue, forKey: Keys.conversionValueKey)
    coder.encode(priority, forKey: Keys.priorityKey)
    coder.encode(events, forKey: Keys.eventsKey)
  }

  override func isEqual(_ object: Any?) -> Bool {
    guard let rule = object as? AEMRule else {
      return false
    }

    if self === rule {
      return true
    }

    return conversionValue == rule.conversionValue
      && priority == rule.priority
      && events == rule.events
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

enum AEMSettings {
  static func appID() -> String? {
    // swiftformat:disable:next redundantSelf
    self.bundle?.object(forInfoDictionaryKey: "FacebookAppID") as? String
  }
}

extension AEMSettings: DependentAsType {
  struct TypeDependencies {
    var bundle: Bundle
  }

  static var configuredDependencies: TypeDependencies?

  static var defaultDependencies: TypeDependencies? = TypeDependencies(bundle: .main)
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import FBSDKCoreKit_Basics
import Foundation

final class AEMUtility {
  private enum Keys {
    static let content = "fb_content"
    static let contentID = "fb_content_id"
    static let itemPrice = "item_price"
    static let identity = "id"
    static let quantity = "quantity"
  }

  static let shared = AEMUtility()

  func getMatchedInvocation(_ invocations: [AEMInvocation], businessID: String?) -> AEMInvocation? {
    guard let businessID = businessID else {
      for invocation in invocations.reversed() where invocation.businessID == nil {
        return invocation
      }
      return nil
    }

    for invocation in invocations.reversed() {
      if let thisID = invocation.businessID, thisID == businessID {
        return invocation
      }
    }
    return nil
  }

  func getInSegmentValue(
    _ parameters: [String: Any]?,
    matchingRule: AEMAdvertiserRuleMatching?
  ) -> NSNumber {
    guard let parameters = parameters,
          let contentsData = parameters[Keys.content] as? [[String: Any]] else {
      return 0
    }

    let value = contentsData.reduce(0.0) { value, entry in
      if let matchingRule = matchingRule,
         matchingRule.isMatchedEventParameters([Keys.content: [entry]]) {
        let itemPrice = entry[Keys.itemPrice] as? NSNumber ?? 0
        let quantity = entry[Keys.quantity] as? NSNumber ?? 1

        return value + itemPrice.doubleValue * quantity.doubleValue
      } else {
        return value
      }
    }

    return NSNumber(value: value)
  }

  func getContent(_ parameters: [String: Any]?) -> String? {
    guard let parameters = parameters else {
      return nil
    }

    return parameters[Keys.content] as? String
  }

  func getContentID(_ parameters: [String: Any]?) -> String? {
    guard let parameters = parameters else {
      return nil
    }

    let content = parameters[Keys.content] as? String

    return content.flatMap { content in
      do {
        return try getContentIDs(content)
      } catch {
        NSLog("Fail to parse AEM fb_content")
        return nil
      }
    } ?? (parameters[Keys.contentID] as? String)
  }

  func getBusinessIDsInOrder(_ invocations: [AEMInvocation]) -> [String] {
    var res: [String] = []

    for invocation in invocations.reversed() {
      res.append(invocation.businessID ?? "")
    }

    return res
  }

  private func getContentIDs(_ content: String) throws -> String {
    guard let json = try TypeUtility.jsonObject(
      with: Data(content.utf8),
      options: .mutableContainers
    ) as? [[String: Any]] else {
      throw Error.invalidContentIDsJSONObject
    }

    let contentIDs = json.reduce(into: [String]()) { result, entry in
      if let contentID = entry[Keys.identity] as? NSNumber {
        result.append(contentID.stringValue)
      } else if let contentID = entry[Keys.identity] as? String {
        result.append(contentID)
      }
    }

    return try BasicUtility.jsonString(for: contentIDs, invalidObjectHandler: nil)
  }

  private enum Error: Swift.Error {
    case invalidContentIDsJSONObject
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

@dynamicMemberLookup
protocol DependentAsType {
  associatedtype TypeDependencies

  static var configuredDependencies: TypeDependencies? { get set }
  static var defaultDependencies: TypeDependencies? { get }

  static func setDependencies(_ dependencies: TypeDependencies)

  #if DEBUG
  static func resetDependencies()
  #endif
}

extension DependentAsType {
  static func setDependencies(_ dependencies: TypeDependencies) {
    configuredDependencies = dependencies
  }

  #if DEBUG
  static func resetDependencies() {
    configuredDependencies = nil
  }
  #endif

  static func getDependencies() throws -> TypeDependencies {
    guard let dependencies = configuredDependencies ?? defaultDependencies else {
      throw MissingDependenciesError(for: Self.self)
    }

    return dependencies
  }

  static subscript<Dependency>(dynamicMember keyPath: KeyPath<TypeDependencies, Dependency>) -> Dependency? {
    try? getDependencies()[keyPath: keyPath]
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

struct MissingDependenciesError<Dependent>: Error, CustomStringConvertible {
  private let dependentType: Dependent.Type

  init(for dependentType: Dependent.Type) {
    self.dependentType = dependentType
  }

  var description: String {
    "The dependencies for the type '\(dependentType)' or an instance of it have not been set"
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 */

import Foundation

@objc(FBSKAdNetworkReporting)
public protocol SKAdNetworkReporting {
  @objc
  func shouldCutoff() -> Bool

  @objc(isReportingEvent:)
  func isReportingEvent(_ event: String) -> Bool

  @objc
  func checkAndRevokeTimer()
}