/**
 * @license
 * 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.
 */

import { _FirebaseService, FirebaseApp } from '@firebase/app-exp';
import {
  Auth,
  AuthErrorMap,
  AuthSettings,
  EmulatorConfig,
  NextOrObserver,
  Persistence,
  PopupRedirectResolver,
  User,
  UserCredential,
  CompleteFn,
  ErrorFn,
  NextFn,
  Unsubscribe
} from '../../model/public_types';
import {
  createSubscribe,
  ErrorFactory,
  getModularInstance,
  Observer,
  Subscribe
} from '@firebase/util';

import { AuthInternal, ConfigInternal } from '../../model/auth';
import { PopupRedirectResolverInternal } from '../../model/popup_redirect';
import { UserInternal } from '../../model/user';
import {
  AuthErrorCode,
  AuthErrorParams,
  ErrorMapRetriever,
  _DEFAULT_AUTH_ERROR_FACTORY
} from '../errors';
import { PersistenceInternal } from '../persistence';
import {
  KeyName,
  PersistenceUserManager
} from '../persistence/persistence_user_manager';
import { _reloadWithoutSaving } from '../user/reload';
import { _assert } from '../util/assert';
import { _getInstance } from '../util/instantiator';
import { _getUserLanguage } from '../util/navigator';
import { _getClientVersion } from '../util/version';

interface AsyncAction {
  (): Promise<void>;
}

export const enum DefaultConfig {
  TOKEN_API_HOST = 'securetoken.googleapis.com',
  API_HOST = 'identitytoolkit.googleapis.com',
  API_SCHEME = 'https'
}

export class AuthImpl implements AuthInternal, _FirebaseService {
  currentUser: User | null = null;
  emulatorConfig: EmulatorConfig | null = null;
  private operations = Promise.resolve();
  private persistenceManager?: PersistenceUserManager;
  private redirectPersistenceManager?: PersistenceUserManager;
  private authStateSubscription = new Subscription<User>(this);
  private idTokenSubscription = new Subscription<User>(this);
  private redirectUser: UserInternal | null = null;
  private isProactiveRefreshEnabled = false;
  private redirectInitializerError: Error | null = null;

  // Any network calls will set this to true and prevent subsequent emulator
  // initialization
  _canInitEmulator = true;
  _isInitialized = false;
  _deleted = false;
  _initializationPromise: Promise<void> | null = null;
  _popupRedirectResolver: PopupRedirectResolverInternal | null = null;
  _errorFactory: ErrorFactory<
    AuthErrorCode,
    AuthErrorParams
  > = _DEFAULT_AUTH_ERROR_FACTORY;
  readonly name: string;

  // Tracks the last notified UID for state change listeners to prevent
  // repeated calls to the callbacks. Undefined means it's never been
  // called, whereas null means it's been called with a signed out user
  private lastNotifiedUid: string | null | undefined = undefined;

  languageCode: string | null = null;
  tenantId: string | null = null;
  settings: AuthSettings = { appVerificationDisabledForTesting: false };

  constructor(
    public readonly app: FirebaseApp,
    public readonly config: ConfigInternal
  ) {
    this.name = app.name;
    this.clientVersion = config.sdkClientVersion;
  }

  _initializeWithPersistence(
    persistenceHierarchy: PersistenceInternal[],
    popupRedirectResolver?: PopupRedirectResolver
  ): Promise<void> {
    if (popupRedirectResolver) {
      this._popupRedirectResolver = _getInstance(popupRedirectResolver);
    }

    // Have to check for app deletion throughout initialization (after each
    // promise resolution)
    this._initializationPromise = this.queue(async () => {
      if (this._deleted) {
        return;
      }

      this.persistenceManager = await PersistenceUserManager.create(
        this,
        persistenceHierarchy
      );

      if (this._deleted) {
        return;
      }

      // Initialize the resolver early if necessary (only applicable to web:
      // this will cause the iframe to load immediately in certain cases)
      if (this._popupRedirectResolver?._shouldInitProactively) {
        await this._popupRedirectResolver._initialize(this);
      }

      await this.initializeCurrentUser(popupRedirectResolver);

      if (this._deleted) {
        return;
      }

      this._isInitialized = true;
    });

    // After initialization completes, throw any error caused by redirect flow
    return this._initializationPromise.then(() => {
      if (this.redirectInitializerError) {
        throw this.redirectInitializerError;
      }
    });
  }

  /**
   * If the persistence is changed in another window, the user manager will let us know
   */
  async _onStorageEvent(): Promise<void> {
    if (this._deleted) {
      return;
    }

    const user = await this.assertedPersistence.getCurrentUser();

    if (!this.currentUser && !user) {
      // No change, do nothing (was signed out and remained signed out).
      return;
    }

    // If the same user is to be synchronized.
    if (this.currentUser && user && this.currentUser.uid === user.uid) {
      // Data update, simply copy data changes.
      this._currentUser._assign(user);
      // If tokens changed from previous user tokens, this will trigger
      // notifyAuthListeners_.
      await this.currentUser.getIdToken();
      return;
    }

    // Update current Auth state. Either a new login or logout.
    await this._updateCurrentUser(user);
  }

  private async initializeCurrentUser(
    popupRedirectResolver?: PopupRedirectResolver
  ): Promise<void> {
    // First check to see if we have a pending redirect event.
    let storedUser = (await this.assertedPersistence.getCurrentUser()) as UserInternal | null;
    if (popupRedirectResolver && this.config.authDomain) {
      await this.getOrInitRedirectPersistenceManager();
      const redirectUserEventId = this.redirectUser?._redirectEventId;
      const storedUserEventId = storedUser?._redirectEventId;
      const result = await this.tryRedirectSignIn(popupRedirectResolver);

      // If the stored user (i.e. the old "currentUser") has a redirectId that
      // matches the redirect user, then we want to initially sign in with the
      // new user object from result.
      // TODO(samgho): More thoroughly test all of this
      if (
        (!redirectUserEventId || redirectUserEventId === storedUserEventId) &&
        result?.user
      ) {
        storedUser = result.user as UserInternal;
      }
    }

    // If no user in persistence, there is no current user. Set to null.
    if (!storedUser) {
      return this.directlySetCurrentUser(null);
    }

    if (!storedUser._redirectEventId) {
      // This isn't a redirect user, we can reload and bail
      // This will also catch the redirected user, if available, as that method
      // strips the _redirectEventId
      return this.reloadAndSetCurrentUserOrClear(storedUser);
    }

    _assert(this._popupRedirectResolver, this, AuthErrorCode.ARGUMENT_ERROR);
    await this.getOrInitRedirectPersistenceManager();

    // If the redirect user's event ID matches the current user's event ID,
    // DO NOT reload the current user, otherwise they'll be cleared from storage.
    // This is important for the reauthenticateWithRedirect() flow.
    if (
      this.redirectUser &&
      this.redirectUser._redirectEventId === storedUser._redirectEventId
    ) {
      return this.directlySetCurrentUser(storedUser);
    }

    return this.reloadAndSetCurrentUserOrClear(storedUser);
  }

  private async tryRedirectSignIn(
    redirectResolver: PopupRedirectResolver
  ): Promise<UserCredential | null> {
    // The redirect user needs to be checked (and signed in if available)
    // during auth initialization. All of the normal sign in and link/reauth
    // flows call back into auth and push things onto the promise queue. We
    // need to await the result of the redirect sign in *inside the promise
    // queue*. This presents a problem: we run into deadlock. See:
    //    ┌> [Initialization] ─────┐
    //    ┌> [<other queue tasks>] │
    //    └─ [getRedirectResult] <─┘
    //    where [] are tasks on the queue and arrows denote awaits
    // Initialization will never complete because it's waiting on something
    // that's waiting for initialization to complete!
    //
    // Instead, this method calls getRedirectResult() (stored in
    // _completeRedirectFn) with an optional parameter that instructs all of
    // the underlying auth operations to skip anything that mutates auth state.

    let result: UserCredential | null = null;
    try {
      // We know this._popupRedirectResolver is set since redirectResolver
      // is passed in. The _completeRedirectFn expects the unwrapped extern.
      result = await this._popupRedirectResolver!._completeRedirectFn(
        this,
        redirectResolver,
        true
      );
    } catch (e) {
      this.redirectInitializerError = e;
      await this._setRedirectUser(null);
    }

    return result;
  }

  private async reloadAndSetCurrentUserOrClear(
    user: UserInternal
  ): Promise<void> {
    try {
      await _reloadWithoutSaving(user);
    } catch (e) {
      if (e.code !== `auth/${AuthErrorCode.NETWORK_REQUEST_FAILED}`) {
        // Something's wrong with the user's token. Log them out and remove
        // them from storage
        return this.directlySetCurrentUser(null);
      }
    }

    return this.directlySetCurrentUser(user);
  }

  useDeviceLanguage(): void {
    this.languageCode = _getUserLanguage();
  }

  async _delete(): Promise<void> {
    this._deleted = true;
  }

  async updateCurrentUser(userExtern: User | null): Promise<void> {
    // The public updateCurrentUser method needs to make a copy of the user,
    // and also check that the project matches
    const user = userExtern
      ? (getModularInstance(userExtern) as UserInternal)
      : null;
    if (user) {
      _assert(
        user.auth.config.apiKey === this.config.apiKey,
        this,
        AuthErrorCode.INVALID_AUTH
      );
    }
    return this._updateCurrentUser(user && user._clone(this));
  }

  async _updateCurrentUser(user: User | null): Promise<void> {
    if (this._deleted) {
      return;
    }
    if (user) {
      _assert(
        this.tenantId === user.tenantId,
        this,
        AuthErrorCode.TENANT_ID_MISMATCH
      );
    }

    return this.queue(async () => {
      await this.directlySetCurrentUser(user as UserInternal | null);
      this.notifyAuthListeners();
    });
  }

  async signOut(): Promise<void> {
    // Clear the redirect user when signOut is called
    if (this.redirectPersistenceManager || this._popupRedirectResolver) {
      await this._setRedirectUser(null);
    }

    return this._updateCurrentUser(null);
  }

  setPersistence(persistence: Persistence): Promise<void> {
    return this.queue(async () => {
      await this.assertedPersistence.setPersistence(_getInstance(persistence));
    });
  }

  _getPersistence(): string {
    return this.assertedPersistence.persistence.type;
  }

  _updateErrorMap(errorMap: AuthErrorMap): void {
    this._errorFactory = new ErrorFactory<AuthErrorCode, AuthErrorParams>(
      'auth',
      'Firebase',
      (errorMap as ErrorMapRetriever)()
    );
  }

  onAuthStateChanged(
    nextOrObserver: NextOrObserver<User>,
    error?: ErrorFn,
    completed?: CompleteFn
  ): Unsubscribe {
    return this.registerStateListener(
      this.authStateSubscription,
      nextOrObserver,
      error,
      completed
    );
  }

  onIdTokenChanged(
    nextOrObserver: NextOrObserver<User>,
    error?: ErrorFn,
    completed?: CompleteFn
  ): Unsubscribe {
    return this.registerStateListener(
      this.idTokenSubscription,
      nextOrObserver,
      error,
      completed
    );
  }

  toJSON(): object {
    return {
      apiKey: this.config.apiKey,
      authDomain: this.config.authDomain,
      appName: this.name,
      currentUser: this._currentUser?.toJSON()
    };
  }

  async _setRedirectUser(
    user: UserInternal | null,
    popupRedirectResolver?: PopupRedirectResolver
  ): Promise<void> {
    const redirectManager = await this.getOrInitRedirectPersistenceManager(
      popupRedirectResolver
    );
    return user === null
      ? redirectManager.removeCurrentUser()
      : redirectManager.setCurrentUser(user);
  }

  private async getOrInitRedirectPersistenceManager(
    popupRedirectResolver?: PopupRedirectResolver
  ): Promise<PersistenceUserManager> {
    if (!this.redirectPersistenceManager) {
      const resolver: PopupRedirectResolverInternal | null =
        (popupRedirectResolver && _getInstance(popupRedirectResolver)) ||
        this._popupRedirectResolver;
      _assert(resolver, this, AuthErrorCode.ARGUMENT_ERROR);
      this.redirectPersistenceManager = await PersistenceUserManager.create(
        this,
        [_getInstance(resolver._redirectPersistence)],
        KeyName.REDIRECT_USER
      );
      this.redirectUser = await this.redirectPersistenceManager.getCurrentUser();
    }

    return this.redirectPersistenceManager;
  }

  async _redirectUserForId(id: string): Promise<UserInternal | null> {
    // Make sure we've cleared any pending persistence actions if we're not in
    // the initializer
    if (this._isInitialized) {
      await this.queue(async () => {});
    }

    if (this._currentUser?._redirectEventId === id) {
      return this._currentUser;
    }

    if (this.redirectUser?._redirectEventId === id) {
      return this.redirectUser;
    }

    return null;
  }

  async _persistUserIfCurrent(user: UserInternal): Promise<void> {
    if (user === this.currentUser) {
      return this.queue(async () => this.directlySetCurrentUser(user));
    }
  }

  /** Notifies listeners only if the user is current */
  _notifyListenersIfCurrent(user: UserInternal): void {
    if (user === this.currentUser) {
      this.notifyAuthListeners();
    }
  }

  _key(): string {
    return `${this.config.authDomain}:${this.config.apiKey}:${this.name}`;
  }

  _startProactiveRefresh(): void {
    this.isProactiveRefreshEnabled = true;
    if (this.currentUser) {
      this._currentUser._startProactiveRefresh();
    }
  }

  _stopProactiveRefresh(): void {
    this.isProactiveRefreshEnabled = false;
    if (this.currentUser) {
      this._currentUser._stopProactiveRefresh();
    }
  }

  /** Returns the current user cast as the internal type */
  get _currentUser(): UserInternal {
    return this.currentUser as UserInternal;
  }

  private notifyAuthListeners(): void {
    if (!this._isInitialized) {
      return;
    }

    this.idTokenSubscription.next(this.currentUser);

    const currentUid = this.currentUser?.uid ?? null;
    if (this.lastNotifiedUid !== currentUid) {
      this.lastNotifiedUid = currentUid;
      this.authStateSubscription.next(this.currentUser);
    }
  }

  private registerStateListener(
    subscription: Subscription<User>,
    nextOrObserver: NextOrObserver<User>,
    error?: ErrorFn,
    completed?: CompleteFn
  ): Unsubscribe {
    if (this._deleted) {
      return () => {};
    }

    const cb =
      typeof nextOrObserver === 'function'
        ? nextOrObserver
        : nextOrObserver.next.bind(nextOrObserver);

    const promise = this._isInitialized
      ? Promise.resolve()
      : this._initializationPromise;
    _assert(promise, this, AuthErrorCode.INTERNAL_ERROR);
    // The callback needs to be called asynchronously per the spec.
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    promise.then(() => cb(this.currentUser));

    if (typeof nextOrObserver === 'function') {
      return subscription.addObserver(nextOrObserver, error, completed);
    } else {
      return subscription.addObserver(nextOrObserver);
    }
  }

  /**
   * Unprotected (from race conditions) method to set the current user. This
   * should only be called from within a queued callback. This is necessary
   * because the queue shouldn't rely on another queued callback.
   */
  private async directlySetCurrentUser(
    user: UserInternal | null
  ): Promise<void> {
    if (this.currentUser && this.currentUser !== user) {
      this._currentUser._stopProactiveRefresh();
      if (user && this.isProactiveRefreshEnabled) {
        user._startProactiveRefresh();
      }
    }

    this.currentUser = user;

    if (user) {
      await this.assertedPersistence.setCurrentUser(user);
    } else {
      await this.assertedPersistence.removeCurrentUser();
    }
  }

  private queue(action: AsyncAction): Promise<void> {
    // In case something errors, the callback still should be called in order
    // to keep the promise chain alive
    this.operations = this.operations.then(action, action);
    return this.operations;
  }

  private get assertedPersistence(): PersistenceUserManager {
    _assert(this.persistenceManager, this, AuthErrorCode.INTERNAL_ERROR);
    return this.persistenceManager;
  }

  private frameworks: string[] = [];
  private clientVersion: string;
  _logFramework(framework: string): void {
    if (!framework || this.frameworks.includes(framework)) {
      return;
    }
    this.frameworks.push(framework);

    // Sort alphabetically so that "FirebaseCore-web,FirebaseUI-web" and
    // "FirebaseUI-web,FirebaseCore-web" aren't viewed as different.
    this.frameworks.sort();
    this.clientVersion = _getClientVersion(
      this.config.clientPlatform,
      this._getFrameworks()
    );
  }
  _getFrameworks(): readonly string[] {
    return this.frameworks;
  }
  _getSdkClientVersion(): string {
    return this.clientVersion;
  }
}

/**
 * Method to be used to cast down to our private implmentation of Auth.
 * It will also handle unwrapping from the compat type if necessary
 *
 * @param auth Auth object passed in from developer
 */
export function _castAuth(auth: Auth): AuthInternal {
  return getModularInstance(auth) as AuthInternal;
}

/** Helper class to wrap subscriber logic */
class Subscription<T> {
  private observer: Observer<T | null> | null = null;
  readonly addObserver: Subscribe<T | null> = createSubscribe(
    observer => (this.observer = observer)
  );

  constructor(readonly auth: AuthInternal) {}

  get next(): NextFn<T | null> {
    _assert(this.observer, this.auth, AuthErrorCode.INTERNAL_ERROR);
    return this.observer.next.bind(this.observer);
  }
}
