//  Copyright © 2021 650 Industries. All rights reserved.

// this uses abstract class patterns
// swiftlint:disable unavailable_function

import Foundation
import UIKit

/**
 Uses objective-c NSExceptions for field validation. This is to maintain
 backwards compatibility with the previous objective-c implementation so that we don't need to do
 error handling at every callsite. When all the code is swift, we might be able to use these exceptions.
 */
public extension Dictionary where Key == String {
  func optionalValue<T>(forKey: String) -> T? {
    guard let value = self[forKey] else {
      return nil
    }

    if !(value is T) {
      let exception = NSException(
        name: NSExceptionName.internalInconsistencyException,
        reason: "Value for (key = \(forKey)) has incorrect type",
        userInfo: ["key": forKey]
      )
      exception.raise()
    }
    return value as? T
  }

  func requiredValue<T>(forKey: String) -> T {
    let value = self[forKey]

    if value == nil {
      let exception = NSException(
        name: NSExceptionName.internalInconsistencyException,
        reason: "Value for (key = \(forKey)) is null",
        userInfo: ["key": forKey]
      )
      exception.raise()
    }

    if !(value is T) {
      let exception = NSException(
        name: NSExceptionName.internalInconsistencyException,
        reason: "Value for (key = \(forKey)) has incorrect type",
        userInfo: ["key": forKey]
      )
      exception.raise()
    }

    // exception above will preempt force_cast
    // swiftlint:disable:next force_cast
    return value as! T
  }
}

public extension Optional {
  func `let`<U>(_ transform: (_ it: Wrapped) throws -> U?) rethrows -> U? {
    if let x = self {
      return try transform(x)
    }
    return nil
  }
}

@objc(EXManifestsManifest)
@objcMembers
public class Manifest: NSObject {
  private let rawManifestJSONInternal: [String: Any]

  public required init(rawManifestJSON: [String: Any]) {
    rawManifestJSONInternal = rawManifestJSON
  }

  public override var debugDescription: String {
    return rawManifestJSONInternal.description
  }

  public func rawManifestJSON() -> [String: Any] {
    return rawManifestJSONInternal
  }

  // MARK: - Abstract methods

  /**
   A best-effort immutable legacy ID for this experience. Stable through project transfers.
   Should be used for calling Expo and EAS APIs during their transition to easProjectId.
   */
  @available(*, deprecated, message: "Prefer scopeKey or easProjectId depending on use case")
  public func stableLegacyId() -> String {
    preconditionFailure("Must override in concrete class")
  }

  /**
   A stable immutable scoping key for this experience. Should be used for scoping data on the
   client for this project when running in Expo Go.
   */
  public func scopeKey() -> String {
    preconditionFailure("Must override in concrete class")
  }

  /**
   A stable UUID for this EAS project. Should be used to call EAS APIs.
   */
  public func easProjectId() -> String? {
    preconditionFailure("Must override in concrete class")
  }

  /**
   Get the SDK version that should be attempted to be used in Expo Go. If no SDK version can be
   determined, returns null
   */
  public func expoGoSDKVersion() -> String? {
    preconditionFailure("Must override in concrete class")
  }

  public func bundleUrl() -> String {
    preconditionFailure("Must override in concrete class")
  }

  public func expoGoConfigRootObject() -> [String: Any]? {
    preconditionFailure("Must override in concrete class")
  }

  public func expoClientConfigRootObject() -> [String: Any]? {
    preconditionFailure("Must override in concrete class")
  }

  // MARK: - Field Getters
  /**
   The legacy ID of this experience.
   - For Bare manifests, formatted as a UUID.
   - For Legacy manifests, formatted as @owner/slug. Not stable through project transfers.
   - For New manifests, currently incorrect value is UUID.
   Use this in cases where an identifier of the current manifest is needed (experience loading for example).
   Use scopeKey for cases where a stable key is needed to scope data to this experience.
   Use easProjectId for cases where a stable UUID identifier of the experience is needed to identify over EAS APIs.
   Use stableLegacyId for cases where a stable legacy format identifier of the experience is needed
    (experience scoping for example).
   */
  @available(*, deprecated, message: "Prefer scopeKey or projectId depending on use case")
  public func legacyId() -> String {
    return rawManifestJSONInternal.requiredValue(forKey: "id")
  }

  public func revisionId() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "revisionId")
  }

  public func getMetadata() -> [String: Any]? {
    return rawManifestJSONInternal.optionalValue(forKey: "metadata")
  }

  public func slug() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "slug")
  }

  public func appKey() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "appKey")
  }

  public func isVerified() -> Bool {
    return rawManifestJSONInternal.optionalValue(forKey: "isVerified") ?? false
  }

  public func name() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "name")
  }

  public func version() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "version")
  }

  public func notificationPreferences() -> [String: Any]? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "notification")
  }

  public func updatesInfo() -> [String: Any]? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "updates")
  }

  public func iosConfig() -> [String: Any]? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "ios")
  }

  public func hostUri() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "hostUri")
  }

  public func orientation() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "orientation")
  }

  public func experiments() -> [String: Any]? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "experiments")
  }

  public func developer() -> [String: Any]? {
    return expoGoConfigRootObject()?.optionalValue(forKey: "developer")
  }

  public func facebookAppId() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "facebookAppId")
  }

  public func facebookApplicationName() -> String? {
    return expoClientConfigRootObject()?.optionalValue(forKey: "facebookDisplayName")
  }

  public func facebookAutoInitEnabled() -> Bool {
    return expoClientConfigRootObject()?.optionalValue(forKey: "facebookAutoInitEnabled") ?? false
  }

  // MARK: - Derived Methods
  public func isDevelopmentMode() -> Bool {
    guard let expoGoConfigRootObject = expoGoConfigRootObject(),
      let packagerOptsConfig: [String: Any]? = expoGoConfigRootObject.optionalValue(forKey: "packagerOpts"),
      let dev = packagerOptsConfig?["dev"] else {
      return false
    }

    if developer() == nil {
      return false
    }

    guard let dev = dev as? Bool else {
      return false
    }
    return dev
  }

  public func isDevelopmentSilentLaunch() -> Bool {
    guard let expoGoConfigRootObject = expoGoConfigRootObject(),
      let developmentClientSettings: [String: Any]? =
        expoGoConfigRootObject.optionalValue(forKey: "developmentClient"),
      let silentLaunch = developmentClientSettings?["silentLaunch"] else {
      return false
    }

    guard let silentLaunch = silentLaunch as? Bool else {
      return false
    }
    return silentLaunch
  }

  public func isUsingDeveloperTool() -> Bool {
    return developer()?["tool"] != nil
  }

  public func userInterfaceStyle() -> String? {
    return iosConfig()?.optionalValue(forKey: "userInterfaceStyle") ??
    (expoClientConfigRootObject()?.optionalValue(forKey: "userInterfaceStyle"))
  }

  public func iosOrRootBackgroundColor() -> String? {
    return iosConfig()?.optionalValue(forKey: "backgroundColor") ??
    (expoClientConfigRootObject()?.optionalValue(forKey: "backgroundColor"))
  }

  public func iosSplashBackgroundColor() -> String? {
    return expoClientConfigRootObject().let { it in
      Manifest.string(fromManifest: it, atPaths: [
        ["ios", "splash", "backgroundColor"],
        ["splash", "backgroundColor"]
      ])
    }
  }

  public func iosSplashImageUrl() -> String? {
    return expoClientConfigRootObject().let { it in
      Manifest.string(fromManifest: it, atPaths: [
        UIDevice.current.userInterfaceIdiom == UIUserInterfaceIdiom.pad
          ? ["ios", "splash", "tabletImageUrl"] : [],
        ["ios", "splash", "imageUrl"],
        ["splash", "imageUrl"]
      ])
    }
  }

  public func iosSplashImageResizeMode() -> String? {
    return expoClientConfigRootObject().let { it in
      Manifest.string(fromManifest: it, atPaths: [
        ["ios", "splash", "resizeMode"],
        ["splash", "resizeMode"]
      ])
    }
  }

  public func iosGoogleServicesFile() -> String? {
    return iosConfig()?.optionalValue(forKey: "googleServicesFile")
  }

  public func supportsRTL() -> Bool {
    guard let expoClientConfigRootObject = expoClientConfigRootObject(),
      let extra: [String: Any]? = expoClientConfigRootObject.optionalValue(forKey: "extra"),
      let supportsRTL: Bool = extra?.optionalValue(forKey: "supportsRTL") else {
      return false
    }

    return supportsRTL
  }

  public func forcesRTL() -> Bool {
    guard let expoClientConfigRootObject = expoClientConfigRootObject(),
      let extra: [String: Any]? = expoClientConfigRootObject.optionalValue(forKey: "extra"),
      let forcesRTL: Bool = extra?.optionalValue(forKey: "forcesRTL") else {
      return false
    }

    return forcesRTL
  }

  public func jsEngine() -> String {
    let jsEngine = expoClientConfigRootObject().let { it in
      Manifest.string(fromManifest: it, atPaths: [
        ["ios", "jsEngine"],
        ["jsEngine"]
      ])
    }

    guard let jsEngine = jsEngine else {
      let sdkMajorVersion = expoGoSDKMajorVersion()
      if sdkMajorVersion > 0 && sdkMajorVersion < 48 {
        return "jsc"
      }
      return "hermes"
    }
    return jsEngine
  }

  /**
   Queries the dedicated package properties in `plugins`
   */
  public func getPluginProperties(packageName: String) -> [String: Any]? {
    typealias PluginWithProps = (String, [String: Any])
    typealias PluginWithoutProps = String
    enum PluginType {
      case withProps (PluginWithProps)
      case withoutProps (PluginWithoutProps)

      private static func fromRawValue(_ optionalValue: Any?) -> PluginType? {
        guard let value = optionalValue else {
          return nil
        }
        if let valueArray = value as? [Any],
          let name = valueArray[0] as? String {
          if valueArray.count > 1 {
            guard let props = valueArray[1] as? [String: Any] else {
              return .withoutProps((name))
            }
            return .withProps((name, props))
          }
          return .withoutProps((name))
        }
        if let value = value as? String {
          return .withoutProps(value)
        }
        let exception = NSException(
          name: NSExceptionName.internalInconsistencyException,
          reason: "Value for (key = plugins) has incorrect type",
          userInfo: ["key": "plugins"]
        )
        exception.raise()
        return nil
      }

      static func fromRawArrayValue(_ value: [Any]) -> [PluginType]? {
        return value.compactMap { fromRawValue($0) }
      }
    }

    guard let pluginsRawValue = expoClientConfigRootObject()?.optionalValue(forKey: "plugins") as [Any]?,
      let plugins = PluginType.fromRawArrayValue(pluginsRawValue) else {
      return nil
    }

    let firstMatchedPlugin = plugins.compactMap { item in
      if case .withProps(let tuple) = item, tuple.0 == packageName {
        return tuple
      }
      return nil
    }.first

    return firstMatchedPlugin?.1
  }

  private func expoGoSDKMajorVersion() -> Int {
    let sdkVersion = expoGoSDKVersion()
    let components = sdkVersion?.components(separatedBy: ".")
    guard let components = components else {
      return 0
    }
    if components.count == 3 {
      return Int(components[0]) ?? 0
    }
    return 0
  }

  private static func string(fromManifest: [String: Any], atPaths: [[String]]) -> String? {
    for path in atPaths {
      if let result = string(fromManifest: fromManifest, atPath: path) {
        return result
      }
    }
    return nil
  }

  private static func string(fromManifest: [String: Any], atPath: [String]) -> String? {
    var json = fromManifest
    for i in 0..<atPath.count {
      let isLastKey = i == atPath.count - 1
      let key = atPath[i]
      let value = json[key]
      if isLastKey, let value = value as? String {
        return value
      }
      guard let newJson = value else {
        return nil
      }
      // swiftlint:disable:next force_cast
      json = newJson as! [String: Any]
    }
    return nil
  }
}

// swiftlint:enable unavailable_function
