import ExpoModulesCore
import CryptoKit
import CloudKit

public class ExpoNativeExtensionsModule: Module {
  private enum Constants {
    // This is the record type we tell developers to create in their CloudKit database
    static let recordType = "PrivyWallets"
    
    // Record keys
    static let recoverySecretRecordKey = "recovery_secret"
    static let appIdRecordKey = "app_id"
    static let userIdRecordKey = "user_id"
  }
  
  public func definition() -> ModuleDefinition {
    Name("ExpoNativeExtensions")
    
    Function("vibeCheck") {
      return "Hello from privy native extensions! 👋"
    }
    
    AsyncFunction("writeRecoverySecretToICloud") { (options: WriteICloudRecoverySecretOptions) in
      // Ensure user is logged into iCloud, or throw an error
      let isUserLoggedIntoICloud = await isUserLoggedIntoICloud(containerIdentifier: options.containerId)
      if !isUserLoggedIntoICloud {
        throw userNotSignedInException()
      }
      
      // Generate recover secret
      let recoverySecret = generateRecoverySecret()
      
      do {
        let iCloudRecord = try await saveRecoverySecretToICloud(
          containerIdentifier: options.containerId,
          recoverySecret: recoverySecret,
          appId: options.appId,
          userId: options.userId
        )
        
        // return the recovery secret & record name
        return WriteICloudRecoverySecretResponse(
          recoverySecret: Field(wrappedValue: recoverySecret),
          iCloudRecordName:  Field(wrappedValue: iCloudRecord.recordID.recordName)
        )
      } catch {
        throw error.toICloudRecoveryError()
      }
    }
    
    // TODO: after finalizing how we want to handle async functions, update this function to match
    AsyncFunction("readRecoverySecretFromICloud") { (options: ReadICloudRecoverySecretOptions) in
      // Ensure user is logged into iCloud, or throw an error
      let isUserLoggedIntoICloud = await isUserLoggedIntoICloud(containerIdentifier: options.containerId)
      if !isUserLoggedIntoICloud {
        throw userNotSignedInException()
      }
      
      do {
        let record = try await retrieveRecoverSecretFromICloud(
          containerIdentifier: options.containerId,
          recordName: options.recordName
        )
        
        if let recoverySecret = record.value(forKey: Constants.recoverySecretRecordKey) as? String {
          // Recovery secret retrieved
          return ReadICloudRecoverySecretResponse(
            recoverySecret: Field(wrappedValue: recoverySecret)
          )
        } else {
          // Recovery secret is missing from CKRecord
          throw recoverySecretMissingException()
        }
      } catch {
        throw error.toICloudRecoveryError()
      }
    }
  }
  
  // MARK: iCloud helpers
  private func isUserLoggedIntoICloud(containerIdentifier: String) async -> Bool {
    let container = CKContainer(identifier: containerIdentifier)
    
    do {
      let iCloudStatus = try await container.accountStatus()
      return iCloudStatus == .available
    } catch {
      // if iCloud status check fails, assume user is not logged in
      return false
    }
  }
  
  private func saveRecoverySecretToICloud(
    containerIdentifier: String,
    recoverySecret: String,
    appId: String,
    userId: String
  ) async throws -> CKRecord {
    // Create a new record and specify record values
    let newRecord = CKRecord(recordType: Constants.recordType)
    let recordValues = [
      Constants.recoverySecretRecordKey: recoverySecret,
      Constants.appIdRecordKey: appId,
      Constants.userIdRecordKey: userId,
      // TODO: determine if wallet address has to be sent up or not
      // "wallet_address":""
    ]
    
    newRecord.setValuesForKeys(recordValues)
    
    // Attempt to save the record to user's private DB
    let privateDatabase = privateDatabase(containerIdentifier: containerIdentifier)
    
    // save the record and return the newly created one
    let createdRecord = try await privateDatabase.save(newRecord)
    
    return createdRecord
  }
  
  private func retrieveRecoverSecretFromICloud(
    containerIdentifier: String,
    recordName: String
  ) async throws -> CKRecord {
    // Read the record from the user's private DB, using record name as identifier
    let privateDatabase = privateDatabase(containerIdentifier: containerIdentifier)
    let recordId = CKRecord.ID(recordName: recordName)
    return try await privateDatabase.record(for: recordId)
  }
    
  private func privateDatabase(containerIdentifier: String) -> CKDatabase {
    // Grab cloudkit container with specified identifer
    let container = CKContainer(identifier: containerIdentifier)
    // Important - grab user's private DB so it's not saved in public or shared DB
    return container.privateCloudDatabase
  }
  
  // MARK: AES helper methods
  private func generateRecoverySecret() -> String {
    // generate AES key
    let key = SymmetricKey(size: .bits256)
    
    // extract data from key
    let rawKeyData = key.withUnsafeBytes { Data($0) }
    
    // base64 encode raw key data to generate recovery secret
    let recoverySecret = rawKeyData.base64EncodedString()
    
    return recoverySecret
  }
  
  // MARK: Custom error types
  private func userNotSignedInException() -> ICloudRecoveryError {
    return ICloudRecoveryError(reason: "The user is not signed into iCloud on their device")
  }
  
  private func recoverySecretMissingException() -> ICloudRecoveryError {
    return ICloudRecoveryError(reason: "Recovery key is missing in CKRecord")
  }
}

// MARK: Custom error handling
private extension Error {
  func toICloudRecoveryError() -> ICloudRecoveryError {
    return if let ckError = self as? CKError {
      handleCKError(ckError)
    } else {
      ICloudRecoveryError(reason: self.localizedDescription)
    }
  }
  
  private func handleCKError(_ ckError: CKError) -> ICloudRecoveryError {
    let errorReason = switch ckError.code {
      case .internalError:
        "iCloud internal error"
      case .networkUnavailable:
        "Network unavailable"
      case .networkFailure:
        "Network failure"
      case .badContainer:
        "Unknown or unauthorized container"
      case .serviceUnavailable:
        "iCloud unavailable"
      case .requestRateLimited:
        "Rate limited"
      case .invalidArguments:
        "The request contains invalid arguments"
      case .serverRejectedRequest:
        "Server rejected request"
      case .badDatabase:
        "Invalid database"
      case .quotaExceeded:
        "Quota exceeded, iCloud storage is full"
      case .accountTemporarilyUnavailable:
        "Account temporarily unavailable"
      default:
        // We are not explicitly handling other CK error types, so just return the error's description
        ckError.localizedDescription
    }
    
    return ICloudRecoveryError(reason: errorReason)
  }
}

// MARK: Response and input types
// Records are equivalent to a dictionary, but represents a Javascript object to
// with native type safetey
struct WriteICloudRecoverySecretOptions: ExpoModulesCore.Record {
  @Field
  var containerId: String
  
  @Field
  var appId: String
  
  @Field
  var userId: String
}

struct WriteICloudRecoverySecretResponse: ExpoModulesCore.Record {
  @Field
  var recoverySecret: String
  
  @Field
  var iCloudRecordName: String
}

struct ReadICloudRecoverySecretOptions: ExpoModulesCore.Record {
  @Field
  var recordName: String
  
  @Field
  var containerId: String
}

struct ReadICloudRecoverySecretResponse: ExpoModulesCore.Record {
  @Field
  var recoverySecret: String
}

// MARK: Exceptions
internal class ICloudRecoveryError: Exception {
  var _reason: String

  override var reason: String {
    return self._reason
  }

  init(reason: String) {
    self._reason = reason
    super.init()
  }
}