/**
 * @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 { FirebaseError, querystring } from '@firebase/util';

import { AuthErrorCode, NamedErrorParams } from '../core/errors';
import { _createError, _fail } from '../core/util/assert';
import { Delay } from '../core/util/delay';
import { _emulatorUrl } from '../core/util/emulator';
import { FetchProvider } from '../core/util/fetch_provider';
import { Auth } from '../model/public_types';
import { AuthInternal, ConfigInternal } from '../model/auth';
import { IdTokenResponse, TaggedWithTokenResponse } from '../model/id_token';
import { IdTokenMfaResponse } from './authentication/mfa';
import { SERVER_ERROR_MAP, ServerError, ServerErrorMap } from './errors';

export const enum HttpMethod {
  POST = 'POST',
  GET = 'GET'
}

export const enum HttpHeader {
  CONTENT_TYPE = 'Content-Type',
  X_FIREBASE_LOCALE = 'X-Firebase-Locale',
  X_CLIENT_VERSION = 'X-Client-Version'
}

export const enum Endpoint {
  CREATE_AUTH_URI = '/v1/accounts:createAuthUri',
  DELETE_ACCOUNT = '/v1/accounts:delete',
  RESET_PASSWORD = '/v1/accounts:resetPassword',
  SIGN_UP = '/v1/accounts:signUp',
  SIGN_IN_WITH_CUSTOM_TOKEN = '/v1/accounts:signInWithCustomToken',
  SIGN_IN_WITH_EMAIL_LINK = '/v1/accounts:signInWithEmailLink',
  SIGN_IN_WITH_IDP = '/v1/accounts:signInWithIdp',
  SIGN_IN_WITH_PASSWORD = '/v1/accounts:signInWithPassword',
  SIGN_IN_WITH_PHONE_NUMBER = '/v1/accounts:signInWithPhoneNumber',
  SEND_VERIFICATION_CODE = '/v1/accounts:sendVerificationCode',
  SEND_OOB_CODE = '/v1/accounts:sendOobCode',
  SET_ACCOUNT_INFO = '/v1/accounts:update',
  GET_ACCOUNT_INFO = '/v1/accounts:lookup',
  GET_RECAPTCHA_PARAM = '/v1/recaptchaParams',
  START_PHONE_MFA_ENROLLMENT = '/v2/accounts/mfaEnrollment:start',
  FINALIZE_PHONE_MFA_ENROLLMENT = '/v2/accounts/mfaEnrollment:finalize',
  START_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:start',
  FINALIZE_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:finalize',
  WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw',
  GET_PROJECT_CONFIG = '/v1/projects'
}

export const DEFAULT_API_TIMEOUT_MS = new Delay(30_000, 60_000);

export function _addTidIfNecessary<T extends { tenantId?: string }>(
  auth: Auth,
  request: T
): T {
  if (auth.tenantId && !request.tenantId) {
    return {
      ...request,
      tenantId: auth.tenantId
    };
  }
  return request;
}

export async function _performApiRequest<T, V>(
  auth: Auth,
  method: HttpMethod,
  path: Endpoint,
  request?: T,
  customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
): Promise<V> {
  return _performFetchWithErrorHandling(auth, customErrorMap, () => {
    let body = {};
    let params = {};
    if (request) {
      if (method === HttpMethod.GET) {
        params = request;
      } else {
        body = {
          body: JSON.stringify(request)
        };
      }
    }

    const query = querystring({
      key: auth.config.apiKey,
      ...params
    }).slice(1);

    const headers = new (FetchProvider.headers())();
    headers.set(HttpHeader.CONTENT_TYPE, 'application/json');
    headers.set(
      HttpHeader.X_CLIENT_VERSION,
      (auth as AuthInternal)._getSdkClientVersion()
    );

    if (auth.languageCode) {
      headers.set(HttpHeader.X_FIREBASE_LOCALE, auth.languageCode);
    }

    return FetchProvider.fetch()(
      _getFinalTarget(auth, auth.config.apiHost, path, query),
      {
        method,
        headers,
        referrerPolicy: 'no-referrer',
        ...body
      }
    );
  });
}

export async function _performFetchWithErrorHandling<V>(
  auth: Auth,
  customErrorMap: Partial<ServerErrorMap<ServerError>>,
  fetchFn: () => Promise<Response>
): Promise<V> {
  (auth as AuthInternal)._canInitEmulator = false;
  const errorMap = { ...SERVER_ERROR_MAP, ...customErrorMap };
  try {
    const networkTimeout = new NetworkTimeout<Response>(auth);
    const response: Response = await Promise.race<Promise<Response>>([
      fetchFn(),
      networkTimeout.promise
    ]);

    // If we've reached this point, the fetch succeeded and the networkTimeout
    // didn't throw; clear the network timeout delay so that Node won't hang
    networkTimeout.clearNetworkTimeout();

    const json = await response.json();
    if ('needConfirmation' in json) {
      throw _makeTaggedError(auth, AuthErrorCode.NEED_CONFIRMATION, json);
    }

    if (response.ok && !('errorMessage' in json)) {
      return json;
    } else {
      const errorMessage = response.ok ? json.errorMessage : json.error.message;
      const serverErrorCode = errorMessage.split(' : ')[0] as ServerError;
      if (serverErrorCode === ServerError.FEDERATED_USER_ID_ALREADY_LINKED) {
        throw _makeTaggedError(
          auth,
          AuthErrorCode.CREDENTIAL_ALREADY_IN_USE,
          json
        );
      } else if (serverErrorCode === ServerError.EMAIL_EXISTS) {
        throw _makeTaggedError(auth, AuthErrorCode.EMAIL_EXISTS, json);
      }
      const authError =
        errorMap[serverErrorCode] ||
        ((serverErrorCode
          .toLowerCase()
          .replace(/[_\s]+/g, '-') as unknown) as AuthErrorCode);
      _fail(auth, authError);
    }
  } catch (e) {
    if (e instanceof FirebaseError) {
      throw e;
    }
    _fail(auth, AuthErrorCode.NETWORK_REQUEST_FAILED);
  }
}

export async function _performSignInRequest<T, V extends IdTokenResponse>(
  auth: Auth,
  method: HttpMethod,
  path: Endpoint,
  request?: T,
  customErrorMap: Partial<ServerErrorMap<ServerError>> = {}
): Promise<V> {
  const serverResponse = (await _performApiRequest<T, V | IdTokenMfaResponse>(
    auth,
    method,
    path,
    request,
    customErrorMap
  )) as V;
  if ('mfaPendingCredential' in serverResponse) {
    _fail(auth, AuthErrorCode.MFA_REQUIRED, {
      serverResponse
    });
  }

  return serverResponse;
}

export function _getFinalTarget(
  auth: Auth,
  host: string,
  path: string,
  query: string
): string {
  const base = `${host}${path}?${query}`;

  if (!(auth as AuthInternal).config.emulator) {
    return `${auth.config.apiScheme}://${base}`;
  }

  return _emulatorUrl(auth.config as ConfigInternal, base);
}

class NetworkTimeout<T> {
  // Node timers and browser timers are fundamentally incompatible, but we
  // don't care about the value here
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private timer: any | null = null;
  readonly promise = new Promise<T>((_, reject) => {
    this.timer = setTimeout(() => {
      return reject(_createError(this.auth, AuthErrorCode.TIMEOUT));
    }, DEFAULT_API_TIMEOUT_MS.get());
  });

  clearNetworkTimeout(): void {
    clearTimeout(this.timer);
  }

  constructor(private readonly auth: Auth) {}
}

interface PotentialResponse extends IdTokenResponse {
  email?: string;
  phoneNumber?: string;
}

export function _makeTaggedError(
  auth: Auth,
  code: AuthErrorCode,
  response: PotentialResponse
): FirebaseError {
  const errorParams: NamedErrorParams = {
    appName: auth.name
  };

  if (response.email) {
    errorParams.email = response.email;
  }
  if (response.phoneNumber) {
    errorParams.phoneNumber = response.phoneNumber;
  }

  const error = _createError(auth, code, errorParams);

  // We know customData is defined on error because errorParams is defined
  (error.customData! as TaggedWithTokenResponse)._tokenResponse = response;
  return error;
}
