// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
class AuthStoredUserManager {
  /// Key of user access group stored in user defaults. Used for retrieve the
  /// user access group at launch.
  private static let storedUserAccessGroupKey = "firebase_auth_stored_user_access_group"

  /// Default value for kSecAttrAccount of shared keychain items.
  private static let sharedKeychainAccountValue = "firebase_auth_firebase_user"

  /// The key to encode and decode the stored user.
  private static let storedUserCoderKey = "firebase_auth_stored_user_coder_key"

  /// Mediator object used to access the keychain.
  private let keychainServices: AuthKeychainServices

  /// Mediator object used to access user defaults.
  private let userDefaults: AuthUserDefaults

  /// Designated initializer.
  /// - Parameter serviceName: The service name to initialize with.
  /// - Parameter keychainServices: The keychain manager (or a fake in unit tests)
  init(serviceName: String, keychainServices: AuthKeychainServices) {
    userDefaults = AuthUserDefaults(service: serviceName)
    self.keychainServices = keychainServices
  }

  /// Get the user access group stored locally.
  /// - Returns: The stored user access group; otherwise, `nil`.
  func getStoredUserAccessGroup() -> String? {
    if let data = userDefaults.data(forKey: Self.storedUserAccessGroupKey) {
      let userAccessGroup = String(data: data, encoding: .utf8)
      return userAccessGroup
    } else {
      return nil
    }
  }

  /// The setter of the user access group stored locally.
  /// - Parameter accessGroup: The access group to be store.
  func setStoredUserAccessGroup(accessGroup: String?) {
    if let data = accessGroup?.data(using: .utf8) {
      userDefaults.setData(data, forKey: Self.storedUserAccessGroupKey)
    } else {
      userDefaults.removeData(forKey: Self.storedUserAccessGroupKey)
    }
  }

  // MARK: - User for Access Group

  /// The getter of the user stored locally.
  /// - Parameters:
  ///   - accessGroup: The access group to retrieve the user from.
  ///   - shareAuthStateAcrossDevices: If `true`, the keychain will be synced
  ///    across the end-user's iCloud.
  ///   - projectIdentifier: An identifier of the project that the user
  ///   associates with.
  /// - Returns: The stored user for the given attributes.
  /// - Throws: An error if the operation failed.
  func getStoredUser(accessGroup: String,
                     shareAuthStateAcrossDevices: Bool,
                     projectIdentifier: String) throws -> User? {
    let query = keychainQuery(
      accessGroup: accessGroup,
      shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
      projectIdentifier: projectIdentifier
    )
    guard let data = try keychainServices.getItem(query: query) else {
      return nil
    }
    let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
    return unarchiver.decodeObject(of: User.self, forKey: Self.storedUserCoderKey)
  }

  /// The setter of the user stored locally.
  /// - Parameters:
  ///   - user: The user to be stored.
  ///   - accessGroup: The access group to store the user in.
  ///   - shareAuthStateAcrossDevices: If `true`, the keychain will be
  ///   synced across the end-user's iCloud.
  ///   - projectIdentifier: An identifier of the project that the user
  ///   associates with.
  /// - Throws: An error if the operation failed.
  func setStoredUser(user: User,
                     accessGroup: String,
                     shareAuthStateAcrossDevices: Bool,
                     projectIdentifier: String) throws {
    var query = keychainQuery(
      accessGroup: accessGroup,
      shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
      projectIdentifier: projectIdentifier
    )

    if shareAuthStateAcrossDevices {
      query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
    } else {
      query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    }

    // TODO(ncooke3): The Objective-C code has an #if for watchOS here.
    // Does this work for watchOS?

    let archiver = NSKeyedArchiver(requiringSecureCoding: false)
    archiver.encode(user, forKey: Self.storedUserCoderKey)
    archiver.finishEncoding()

    // In Firebase 10, the below query contained the `kSecAttrSynchronizable`
    // key set to `true` when `shareAuthStateAcrossDevices == true`. This
    // allows a user entry to be shared across devices via the iCloud keychain.
    // For the purpose of this discussion, such a user entry will be referred
    // to as a "iCloud entry". Conversely, a "non-iCloud entry" will refer to a
    // user entry stored when `shareAuthStateAcrossDevices == false`. Keep in
    // mind that this class exclusively manages user entries stored in
    // device-specific keychain access groups, so both iCloud and non-iCloud
    // entries are implicitly available at the device level to apps that
    // have access rights to the specific keychain access group used.
    //
    // The iCloud/non-iCloud distinction is important because entries stored
    // with `kSecAttrSynchronizable == true` can only be retrieved when the
    // search query includes `kSecAttrSynchronizable == true`. Likewise,
    // entries stored without the `kSecAttrSynchronizable` key (or
    // `kSecAttrSynchronizable == false`) can only be retrieved when
    // the search query omits `kSecAttrSynchronizable` or sets it to `false`.
    //
    // So for each access group, the SDK manages up to two buckets in the
    // keychain, one for iCloud entries and one for non-iCloud entries.
    //
    // From Firebase 11.0.0 up to but not including 11.3.0, the
    // `kSecAttrSynchronizable` key was *not* included in the query when
    // `shareAuthStateAcrossDevices == true`. This had the effect of the iCloud
    // bucket being inaccessible, and iCloud and non-iCloud entries attempting
    // to be written to the same bucket. This was problematic because the
    // two types of entries use another flag, the `kSecAttrAccessible` flag,
    // with different values. If two queries are identical apart from different
    // values for their `kSecAttrAccessible` key, whichever query written to
    // the keychain first won't be accessible for reading or updating via the
    // other query (resulting in a OSStatus of -25300 indicating the queried
    // item cannot be found). And worse, attempting to write the other query to
    // the keychain won't work because the write will conflict with the
    // previously written query (resulting in a OSStatus of -25299 indicating a
    // duplicate item already exists in the keychain). This formed the basis
    // for the issues this bug caused.
    //
    // The missing key was added back in 11.3, but adding back the key
    // introduced a new issue. If the buggy version succeeded at writing an
    // iCloud entry to the non-iCloud bucket (e.g. keychain was empty before
    // iCloud entry was written), then all future non-iCloud writes would fail
    // due to the mismatching `kSecAttrAccessible` flag and throw an
    // unrecoverable error. To address this the below error handling is used to
    // detect such cases, remove the "corrupt" iCloud entry stored by the buggy
    // version in the non-iCloud bucket, and retry writing the current
    // non-iCloud entry again.
    do {
      try keychainServices.setItem(archiver.encodedData, withQuery: query)
    } catch let error as NSError {
      guard shareAuthStateAcrossDevices == false,
            error.localizedFailureReason == "SecItemAdd (-25299)" else {
        // The error is not related to the 11.0 - 11.2 issue described above,
        // and should be rethrown.
        throw error
      }
      // We are trying to write a non-iCloud entry but a corrupt iCloud entry
      // is likely preventing it from happening.
      //
      // The corrupt query was supposed to contain the following keys:
      //   {
      //     kSecAttrSynchronizable: true,
      //     kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
      //   }
      // Instead, it contained:
      //   {
      //     kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
      //   }
      //
      // Excluding `kSecAttrSynchronizable` treats the query as if it's false
      // and the entry won't be shared in iCloud across devices. It is instead
      // written to the non-iCloud bucket. This query is corrupting the
      // non-iCloud bucket because its `kSecAttrAccessible` value is not
      // compatible with the value used for non-iCloud entries. To delete it,
      // a compatible query is formed by swapping the accessibility flag
      // out for `kSecAttrAccessibleAfterFirstUnlock`. This frees up the bucket
      // so the non-iCloud entry can attempt to be written again.
      let corruptQuery = query
        .merging([kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock]) { $1 }
      try keychainServices.removeItem(query: corruptQuery)
      try keychainServices.setItem(archiver.encodedData, withQuery: query)
    }
  }

  /// Remove the user that stored locally.
  /// - Parameters:
  ///   - accessGroup: The access group to remove the user from.
  ///   - shareAuthStateAcrossDevices: If `true`, the keychain will be
  ///   synced across the end-user's iCloud.
  ///   - projectIdentifier: An identifier of the project that the user
  ///   associates with.
  /// - Throws: An error if the operation failed.
  func removeStoredUser(accessGroup: String,
                        shareAuthStateAcrossDevices: Bool,
                        projectIdentifier: String) throws {
    var query = keychainQuery(
      accessGroup: accessGroup,
      shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
      projectIdentifier: projectIdentifier
    )

    if shareAuthStateAcrossDevices {
      query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
    } else {
      query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    }

    try keychainServices.removeItem(query: query)
  }

  // MARK: - Private Helpers

  private func keychainQuery(accessGroup: String,
                             shareAuthStateAcrossDevices: Bool,
                             projectIdentifier: String) -> [String: Any] {
    var query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccessGroup as String: accessGroup,
      kSecAttrService as String: projectIdentifier,
      kSecAttrAccount as String: Self.sharedKeychainAccountValue,
    ]
    query[kSecUseDataProtectionKeychain as String] = true

    if shareAuthStateAcrossDevices {
      query[kSecAttrSynchronizable as String] = true
    }

    return query
  }
}
