// Copyright 2020 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.

#if canImport(Combine) && swift(>=5.0)

  import Combine
  import FirebaseAuth

  // Make this class discoverable from Objective-C. Don't instantiate directly.
  @objc(FIRCombineAuthLibrary) private class __CombineAuthLibrary: NSObject {}

  @available(swift 5.0)
  @available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 6.0, *)
  public extension Auth {
    // MARK: - Authentication State Management

    /// Registers a publisher that publishes authentication state changes.
    ///
    /// The publisher emits values when:
    ///
    /// - It is registered,
    /// - a user with a different UID from the current user has signed in, or
    /// - the current user has signed out.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Returns: A publisher emitting a `User` instance (if the user has signed in) or `nil` (if the user has signed out).
    /// The publisher will emit on the *main* thread.
    func authStateDidChangePublisher() -> AnyPublisher<User?, Never> {
      let subject = PassthroughSubject<User?, Never>()
      let handle = addStateDidChangeListener { auth, user in
        subject.send(user)
      }
      return subject
        .handleEvents(receiveCancel: {
          self.removeStateDidChangeListener(handle)
        })
        .eraseToAnyPublisher()
    }

    /// Registers a publisher that publishes ID token state changes.
    ///
    /// The publisher emits values when:
    ///
    /// - It is registered,
    /// - a user with a different UID from the current user has signed in,
    /// - the ID token of the current user has been refreshed, or
    /// - the current user has signed out.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Returns: A publisher emitting a `User` instance (if a different user is signed in or
    ///   the ID token of the current user has changed) or `nil` (if the user has signed out).
    ///   The publisher will emit on the *main* thread.
    func idTokenDidChangePublisher() -> AnyPublisher<User?, Never> {
      let subject = PassthroughSubject<User?, Never>()
      let handle = addIDTokenDidChangeListener { auth, user in
        subject.send(user)
      }
      return subject
        .handleEvents(receiveCancel: {
          self.removeIDTokenDidChangeListener(handle)
        })
        .eraseToAnyPublisher()
    }

    /// Sets the `currentUser` on the calling Auth instance to the provided `user` object.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter user: The user object to be set as the current user of the calling Auth instance.
    /// - Returns: A publisher that emits when the user of the calling Auth instance has been updated or
    /// an error was encountered. The publisher will emit on the **main** thread.
    @discardableResult
    func updateCurrentUser(_ user: User) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.updateCurrentUser(user) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    // MARK: - Anonymous Authentication

    /// Asynchronously creates an anonymous user and assigns it as the calling Auth instance's current user.
    ///
    /// If there is already an anonymous user signed in, that user will be returned instead.
    /// If there is any other existing user signed in, that user will be signed out.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Returns: A publisher that emits the result of the sign in flow. The publisher will emit on the *main* thread.
    /// - Remark:
    ///   Possible error codes:
    ///   - `AuthErrorCodeOperationNotAllowed` - Indicates that anonymous accounts are
    ///     not enabled. Enable them in the Auth section of the Firebase console.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func signInAnonymously() -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.signInAnonymously { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }

    // MARK: - Email/Password Authentication

    /// Creates and, on success, signs in a user with the given email address and password.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameters:
    ///   - email: The user's email address.
    ///   - password: The user's desired password.
    /// - Returns: A publisher that emits the result of the sign in flow. The publisher will emit on the *main* thread.
    /// - Remark:
    ///   Possible error codes:
    ///   - `AuthErrorCodeInvalidEmail` - Indicates the email address is malformed.
    ///   - `AuthErrorCodeEmailAlreadyInUse` - Indicates the email used to attempt sign up
    ///     already exists. Call fetchProvidersForEmail to check which sign-in mechanisms the user
    ///     used, and prompt the user to sign in with one of those.
    ///   - `AuthErrorCodeOperationNotAllowed` - Indicates that email and password accounts
    ///     are not enabled. Enable them in the Auth section of the Firebase console.
    ///   - `AuthErrorCodeWeakPassword` - Indicates an attempt to set a password that is
    ///     considered too weak. The NSLocalizedFailureReasonErrorKey field in the NSError.userInfo
    ///     dictionary object will contain more detailed explanation that can be shown to the user.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func createUser(withEmail email: String,
                    password: String) -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.createUser(withEmail: email, password: password) { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }

    /// Signs in a user with the given email address and password.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameters:
    ///   - email: The user's email address.
    ///   - password: The user's password.
    /// - Returns: A publisher that emits the result of the sign in flow. The publisher will emit on the *main* thread.
    /// - Remark:
    ///   Possible error codes:
    ///   - `AuthErrorCodeOperationNotAllowed` - Indicates that email and password
    ///     accounts are not enabled. Enable them in the Auth section of the
    ///     Firebase console.
    ///   - `AuthErrorCodeUserDisabled` - Indicates the user's account is disabled.
    ///   - `AuthErrorCodeWrongPassword` - Indicates the user attempted
    ///     sign in with an incorrect password.
    ///   - `AuthErrorCodeInvalidEmail` - Indicates the email address is malformed.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func signIn(withEmail email: String,
                password: String) -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.signIn(withEmail: email, password: password) { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }

    // MARK: - Email/Link Authentication

    /// Signs in using an email address and email sign-in link.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameters:
    ///   - email: The user's email address.
    ///   - link: The email sign-in link.
    /// - Returns: A publisher that emits the result of the sign in flow. The publisher will emit on the *main* thread.
    /// - Remark:
    ///   Possible error codes:
    ///   - `AuthErrorCodeOperationNotAllowed` - Indicates that email and password
    ///     accounts are not enabled. Enable them in the Auth section of the
    ///     Firebase console.
    ///   - `AuthErrorCodeUserDisabled` - Indicates the user's account is disabled.
    ///   - `AuthErrorCodeInvalidEmail` - Indicates the email address is malformed.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @available(watchOS, unavailable)
    @discardableResult
    func signIn(withEmail email: String,
                link: String) -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.signIn(withEmail: email, link: link) { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }

    /// Sends a sign in with email link to provided email address.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameters:
    ///   - email: The email address of the user.
    ///   - actionCodeSettings: An `ActionCodeSettings` object containing settings related to
    ///     handling action codes.
    /// - Returns: A publisher that emits whether the call was successful or not. The publisher will emit on the *main* thread.
    @available(watchOS, unavailable)
    @discardableResult
    func sendSignInLink(toEmail email: String,
                        actionCodeSettings: ActionCodeSettings) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    //  MARK: - Email-based Authentication Helpers

    /// Fetches the list of all sign-in methods previously used for the provided email address.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter email: The email address for which to obtain a list of sign-in methods.
    /// - Returns: A publisher that emits a list of sign-in methods for the specified email
    ///   address, or an error if one occurred. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `AuthErrorCodeInvalidEmail` - Indicates the email address is malformed.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    func fetchSignInMethods(forEmail email: String) -> Future<[String], Error> {
      Future<[String], Error> { promise in
        self.fetchSignInMethods(forEmail: email) { signInMethods, error in
          if let error = error {
            promise(.failure(error))
          } else if let signInMethods = signInMethods {
            promise(.success(signInMethods))
          }
        }
      }
    }

    // MARK: - Password Reset

    /// Resets the password given a code sent to the user outside of the app and a new password for the user.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameters:
    ///   - code: Out-of-band (OOB) code given to the user outside of the app.
    ///   - newPassword: The new password.
    /// - Returns: A publisher that emits whether the call was successful or not. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `AuthErrorCodeWeakPassword` - Indicates an attempt to set a password that is considered too weak.
    ///   - `AuthErrorCodeOperationNotAllowed` - Indicates the admin disabled sign in with the specified identity provider.
    ///   - `AuthErrorCodeExpiredActionCode` - Indicates the OOB code is expired.
    ///   - `AuthErrorCodeInvalidActionCode` - Indicates the OOB code is invalid.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func confirmPasswordReset(withCode code: String,
                              newPassword: String) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.confirmPasswordReset(withCode: code, newPassword: newPassword) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    /// Checks the validity of a verify password reset code.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter code: The password reset code to be verified.
    /// - Returns: A publisher that emits an error if the code could not be verified. If the code could be
    ///   verified, the publisher will emit the email address of the account the code was issued for.
    ///   The publisher will emit on the *main* thread.
    @discardableResult
    func verifyPasswordResetCode(_ code: String) -> Future<String, Error> {
      Future<String, Error> { promise in
        self.verifyPasswordResetCode(code) { email, error in
          if let error = error {
            promise(.failure(error))
          } else if let email = email {
            promise(.success(email))
          }
        }
      }
    }

    /// Checks the validity of an out of band code.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter code: The out of band code to check validity.
    /// - Returns: A publisher that emits the email address of the account the code was issued for or an error if
    ///   the code could not be verified. The publisher will emit on the *main* thread.
    @discardableResult
    func checkActionCode(code: String) -> Future<ActionCodeInfo, Error> {
      Future<ActionCodeInfo, Error> { promise in
        self.checkActionCode(code) { actionCodeInfo, error in
          if let error = error {
            promise(.failure(error))
          } else if let actionCodeInfo = actionCodeInfo {
            promise(.success(actionCodeInfo))
          }
        }
      }
    }

    /// Applies out of band code.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter code: The out-of-band (OOB) code to be applied.
    /// - Returns: A publisher that emits an error if the code could not be applied. The publisher will emit on the *main* thread.
    /// - Remark: This method will not work for out-of-band codes which require an additional parameter,
    ///   such as password reset codes.
    @discardableResult
    func applyActionCode(code: String) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.applyActionCode(code) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    /// Initiates a password reset for the given email address.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter email: The email address of the user.
    /// - Returns: A publisher that emits whether the call was successful or not. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `AuthErrorCodeInvalidRecipientEmail` - Indicates an invalid recipient email was sent in the request.
    ///   - `AuthErrorCodeInvalidSender` - Indicates an invalid sender email is set in the console for this action.
    ///   - `AuthErrorCodeInvalidMessagePayload` - Indicates an invalid email template for sending update email.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func sendPasswordReset(withEmail email: String) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.sendPasswordReset(withEmail: email) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    /// Initiates a password reset for the given email address and `ActionCodeSettings`.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter email: The email address of the user.
    /// - Parameter actionCodeSettings: An `ActionCodeSettings` object containing settings related to
    /// handling action codes.
    /// - Returns: A publisher that emits whether the call was successful or not. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `AuthErrorCodeInvalidRecipientEmail` - Indicates an invalid recipient email was sent in the request.
    ///   - `AuthErrorCodeInvalidSender` - Indicates an invalid sender email is set in the console for this action.
    ///   - `AuthErrorCodeInvalidMessagePayload` - Indicates an invalid email template for sending update email.
    ///   - `AuthErrorCodeMissingIosBundleID` - Indicates that the iOS bundle ID is missing
    ///   when `handleCodeInApp` is set to YES.
    ///   - `AuthErrorCodeMissingAndroidPackageName` - Indicates that the android package name is missing
    ///   when the `androidInstallApp` flag is set to true.
    ///   - `AuthErrorCodeUnauthorizedDomain` - Indicates that the domain specified in the continue URL is not whitelisted
    ///    in the Firebase console.
    ///   - `AuthErrorCodeInvalidContinueURI` - Indicates that the domain specified in the continue URI is not valid.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func sendPasswordReset(withEmail email: String,
                           actionCodeSettings: ActionCodeSettings) -> Future<Void, Error> {
      Future<Void, Error> { promise in
        self.sendPasswordReset(withEmail: email, actionCodeSettings: actionCodeSettings) { error in
          if let error = error {
            promise(.failure(error))
          } else {
            promise(.success(()))
          }
        }
      }
    }

    // MARK: - Other Authentication providers

    #if os(iOS) || targetEnvironment(macCatalyst)
      /// Signs in using the provided auth provider instance.
      ///
      /// The publisher will emit events on the **main** thread.
      ///
      /// - Parameters:
      ///   - provider: An instance of an auth provider used to initiate the sign-in flow.
      ///   - uiDelegate: Optionally, an instance of a class conforming to the `AuthUIDelegate`
      ///   protocol. This is used for presenting the web context. If `nil`, a default `AuthUIDelegate`
      ///   will be used.
      /// - Returns: A publisher that emits an `AuthDataResult` when the sign-in flow completed
      ///   successfully, or an error otherwise. The publisher will emit on the *main* thread.
      /// - Remark: Possible error codes:
      ///   - `AuthErrorCodeOperationNotAllowed` - Indicates that email and password accounts are not enabled.
      ///     Enable them in the Auth section of the Firebase console.
      ///   - `AuthErrorCodeUserDisabled` - Indicates the user's account is disabled.
      ///   - `AuthErrorCodeWebNetworkRequestFailed` - Indicates that a network request within a
      ///     `SFSafariViewController` or `WKWebView` failed.
      ///   - `AuthErrorCodeWebInternalError` - Indicates that an internal error occurred within a
      ///     `SFSafariViewController` or `WKWebView`.`
      ///   - `AuthErrorCodeWebSignInUserInteractionFailure` - Indicates a general failure during a web sign-in flow.`
      ///   - `AuthErrorCodeWebContextAlreadyPresented` - Indicates that an attempt was made to present a new web
      ///     context while one was already being presented.`
      ///   - `AuthErrorCodeWebContextCancelled` - Indicates that the URL presentation was cancelled prematurely
      ///     by the user.`
      ///   - `AuthErrorCodeAccountExistsWithDifferentCredential` - Indicates the email asserted by the credential
      ///     (e.g. the email in a Facebook access token) is already in use by an existing account that cannot be
      ///     authenticated with this sign-in method. Call `fetchProvidersForEmail` for this user’s email and then
      ///     prompt them to sign in with any of the sign-in providers returned. This error will only be thrown if
      ///     the "One account per email address" setting is enabled in the Firebase console, under Auth settings.
      ///
      ///   See `AuthErrors` for a list of error codes that are common to all API methods
      @discardableResult
      func signIn(with provider: FederatedAuthProvider,
                  uiDelegate: AuthUIDelegate?) -> Future<AuthDataResult, Error> {
        Future<AuthDataResult, Error> { promise in
          self.signIn(with: provider, uiDelegate: uiDelegate) { authDataResult, error in
            if let error = error {
              promise(.failure(error))
            } else if let authDataResult = authDataResult {
              promise(.success(authDataResult))
            }
          }
        }
      }
    #endif // os(iOS) || targetEnvironment(macCatalyst)

    /// Asynchronously signs in to Firebase with the given Auth token.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter token: A self-signed custom auth token.
    /// - Returns: A publisher that emits an `AuthDataResult` when the sign-in flow completed
    ///   successfully, or an error otherwise. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `AuthErrorCodeInvalidCustomToken` - Indicates a validation error with the custom token.
    ///   - `AuthErrorCodeUserDisabled` - Indicates the user's account is disabled.
    ///   - `AuthErrorCodeCustomTokenMismatch` - Indicates the service account and the API key
    ///     belong to different projects.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func signIn(withCustomToken token: String) -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.signIn(withCustomToken: token) { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }

    /// Asynchronously signs in to Firebase with the given 3rd-party credentials (e.g. a Facebook
    /// login Access Token, a Google ID Token/Access Token pair, etc.) and returns additional
    /// identity provider data.
    ///
    /// The publisher will emit events on the **main** thread.
    ///
    /// - Parameter credential: The credential supplied by the IdP.
    /// - Returns: A publisher that emits an `AuthDataResult` when the sign-in flow completed
    ///   successfully, or an error otherwise. The publisher will emit on the *main* thread.
    /// - Remark: Possible error codes:
    ///   - `FIRAuthErrorCodeInvalidCredential` - Indicates the supplied credential is invalid.
    ///     This could happen if it has expired or it is malformed.
    ///   - `FIRAuthErrorCodeOperationNotAllowed` - Indicates that accounts
    ///     with the identity provider represented by the credential are not enabled.
    ///     Enable them in the Auth section of the Firebase console.
    ///   - `FIRAuthErrorCodeAccountExistsWithDifferentCredential` - Indicates the email asserted
    ///     by the credential (e.g. the email in a Facebook access token) is already in use by an
    ///     existing account, that cannot be authenticated with this sign-in method. Call
    ///     fetchProvidersForEmail for this user’s email and then prompt them to sign in with any of
    ///     the sign-in providers returned. This error will only be thrown if the "One account per
    ///     email address" setting is enabled in the Firebase console, under Auth settings.
    ///   - `FIRAuthErrorCodeUserDisabled` - Indicates the user's account is disabled.
    ///   - `FIRAuthErrorCodeWrongPassword` - Indicates the user attempted sign in with an
    ///     incorrect password, if credential is of the type EmailPasswordAuthCredential.
    ///   - `FIRAuthErrorCodeInvalidEmail` - Indicates the email address is malformed.
    ///   - `FIRAuthErrorCodeMissingVerificationID` - Indicates that the phone auth credential was
    ///     created with an empty verification ID.
    ///   - `FIRAuthErrorCodeMissingVerificationCode` - Indicates that the phone auth credential
    ///     was created with an empty verification code.
    ///   - `FIRAuthErrorCodeInvalidVerificationCode` - Indicates that the phone auth credential
    ///     was created with an invalid verification Code.
    ///   - `FIRAuthErrorCodeInvalidVerificationID` - Indicates that the phone auth credential was
    ///     created with an invalid verification ID.
    ///   - `FIRAuthErrorCodeSessionExpired` - Indicates that the SMS code has expired.
    ///
    ///   See `AuthErrors` for a list of error codes that are common to all API methods
    @discardableResult
    func signIn(with credential: AuthCredential) -> Future<AuthDataResult, Error> {
      Future<AuthDataResult, Error> { promise in
        self.signIn(with: credential) { authDataResult, error in
          if let error = error {
            promise(.failure(error))
          } else if let authDataResult = authDataResult {
            promise(.success(authDataResult))
          }
        }
      }
    }
  }

#endif // canImport(Combine) && swift(>=5.0)
