// 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 FirebaseCoreExtension
import FirebaseCoreInternal
import Foundation

/// The prefix string for keychain item account attribute before the key.
///
/// A number "1" is encoded in the prefix in case we need to upgrade the scheme in future.
private let kAccountPrefix = "firebase_auth_1_"

/// The utility class to manipulate data in iOS Keychain.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
final class AuthKeychainServices: Sendable {
  /// The name of the keychain service.
  private let service: String

  private let keychainStorage: AuthKeychainStorage

  // MARK: - Internal methods for shared keychain operations

  required init(service: String = "Unset service", storage: AuthKeychainStorage) {
    self.service = service
    keychainStorage = storage
  }

  /// Get the item from keychain by given query.
  /// - Parameter query: The query to query the keychain.
  /// - Returns: The item of the given query.  `nil` if it doesn't  exist.
  func getItem(query: [String: Any]) throws -> Data? {
    var mutableQuery = query
    mutableQuery[kSecReturnData as String] = true
    mutableQuery[kSecReturnAttributes as String] = true
    // Using a match limit of 2 means that we can check whether more than one
    // item is returned by the query.
    mutableQuery[kSecMatchLimit as String] = 2

    var result: AnyObject?
    let status = keychainStorage.get(query: mutableQuery, result: &result)

    if let items = result as? [[String: Any]], status == noErr {
      if items.count != 1 {
        throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
      }

      return items[0][kSecValueData as String] as? Data
    }
    if status == errSecItemNotFound {
      return nil
    } else {
      throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
    }
  }

  /// Set the item into keychain with given query.
  /// - Parameter item: The item to be added into keychain.
  /// - Parameter query: The query to query the keychain.
  /// - Returns: Whether the operation succeed.
  func setItem(_ item: Data, withQuery query: [String: Any]) throws {
    let status: OSStatus
    let function: String
    if try (getItem(query: query)) != nil {
      let attributes: [String: Any] = [kSecValueData as String: item]
      status = keychainStorage.update(query: query, attributes: attributes)
      function = "SecItemUpdate"
    } else {
      var queryWithItem = query
      queryWithItem[kSecValueData as String] = item
      status = keychainStorage.add(query: queryWithItem)
      function = "SecItemAdd"
    }

    if status == noErr {
      return
    }
    throw AuthErrorUtils.keychainError(function: function, status: status)
  }

  /// Remove the item with given queryfrom keychain.
  /// - Parameter query: The query to query the keychain.
  func removeItem(query: [String: Any]) throws {
    let status = keychainStorage.delete(query: query)
    if status == noErr || status == errSecItemNotFound {
      return
    }
    throw AuthErrorUtils.keychainError(function: "SecItemDelete", status: status)
  }

  /// Indicates whether or not this class knows that the legacy item for a particular key has
  /// been deleted.
  ///
  /// This dictionary is to avoid unnecessary keychain operations against legacy items.
  private let legacyEntryDeletedForKey = FIRAllocatedUnfairLock<Set<String>>(initialState: [])

  func data(forKey key: String) throws -> Data? {
    if let data = try getItemLegacy(query: genericPasswordQuery(key: key)) {
      return data
    }

    // Check for legacy form.
    if legacyEntryDeletedForKey.value().contains(key) {
      return nil
    }
    if let data = try getItemLegacy(query: legacyGenericPasswordQuery(key: key)) {
      // Move the data to current form.
      try setData(data, forKey: key)
      deleteLegacyItem(key: key)
      return data
    } else {
      // Mark legacy data as non-existing so we don't have to query it again.
      legacyEntryDeletedForKey.withLock { $0.insert(key) }
      return nil
    }
  }

  func setData(_ data: Data, forKey key: String) throws {
    try setItemLegacy(data, withQuery: genericPasswordQuery(key: key))
  }

  func removeData(forKey key: String) throws {
    try removeItem(query: genericPasswordQuery(key: key))

    // Legacy form item, if exists, also needs to be removed, otherwise it will be exposed when
    // current form item is removed, leading to incorrect semantics.
    deleteLegacyItem(key: key)
  }

  // MARK: - Internal methods for non-sharing keychain operations

  // TODO: This function can go away in favor of `getItem` if we can delete the legacy processing.
  func getItemLegacy(query: [String: Any]) throws -> Data? {
    var returningQuery = query
    returningQuery[kSecReturnData as String] = true
    returningQuery[kSecReturnAttributes as String] = true

    // Using a match limit of 2 means that we can check whether there is more than one item.
    // If we used a match limit of 1 we would never find out.
    returningQuery[kSecMatchLimit as String] = 2

    var result: AnyObject?
    let status = keychainStorage.get(query: returningQuery, result: &result)

    if let items = result as? [[String: Any]], status == noErr {
      if items.isEmpty {
        // The keychain query returned no error, but there were no items found.
        throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
      } else if items.count > 1 {
        // More than one keychain item was found, all but the first will be ignored.
        FirebaseLogger.log(
          level: .warning,
          service: "[FirebaseAuth]",
          code: "I-AUT000005",
          message: "Keychain query returned multiple results, all but the first will be ignored: \(items)"
        )
      }

      // Return the non-legacy item.
      for item in items {
        if item[kSecAttrService as String] != nil {
          return item[kSecValueData as String] as? Data
        }
      }

      // If they were all legacy items, just return the first one.
      // This should not happen, since only one account should be
      // stored.
      return items[0][kSecValueData as String] as? Data
    }

    if status == errSecItemNotFound {
      return nil
    } else {
      throw AuthErrorUtils.keychainError(function: "SecItemCopyMatching", status: status)
    }
  }

  // TODO: This function can go away in favor of `setItem` if we can delete the legacy processing.
  func setItemLegacy(_ item: Data, withQuery query: [String: Any]) throws {
    let attributes: [String: Any] = [
      kSecValueData as String: item,
      kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    ]
    let combined = attributes.merging(query, uniquingKeysWith: { _, last in last })
    var hasItem = false

    var status = keychainStorage.add(query: combined)
    if status == errSecDuplicateItem {
      hasItem = true
      status = keychainStorage.update(query: query, attributes: attributes)
    }
    if status == noErr {
      return
    }
    let function = hasItem ? "SecItemUpdate" : "SecItemAdd"
    throw AuthErrorUtils.keychainError(function: function, status: status)
  }

  /// Deletes legacy item from the keychain if it is not already known to be deleted.
  /// - Parameter key: The key for the item.
  private func deleteLegacyItem(key: String) {
    if legacyEntryDeletedForKey.value().contains(key) {
      return
    }
    let query = legacyGenericPasswordQuery(key: key)
    keychainStorage.delete(query: query)
    legacyEntryDeletedForKey.withLock { $0.insert(key) }
  }

  /// Returns a keychain query of generic password to be used to manipulate key'ed value.
  /// - Parameter key: The key for the value being manipulated, used as the account field in the
  /// query.
  private func genericPasswordQuery(key: String) -> [String: Any] {
    if key.isEmpty {
      fatalError("The key cannot be empty.")
    }
    var query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccount as String: kAccountPrefix + key,
      kSecAttrService as String: service,
    ]
    query[kSecUseDataProtectionKeychain as String] = true
    return query
  }

  /// Returns a keychain query of generic password without service field, which is used by
  /// previous version of this class .
  /// - Parameter key: The key for the value being manipulated, used as the account field in the
  /// query.
  private func legacyGenericPasswordQuery(key: String) -> [String: Any] {
    [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrAccount as String: key,
    ]
  }
}
