import {devAssert, userAssert} from '#core/assert';
import {fromIterator, isArray} from '#core/types/array';
import {isObject, map} from '#core/types/object';

import {isExperimentOn} from '#experiments';

import {Services} from '#service';

import {user} from '#utils/log';

import {isFormDataWrapper} from '../form-data-wrapper';
import {getMode} from '../mode';
import {
  getCorsUrl,
  getWinOrigin,
  isProxyOrigin,
  parseUrlDeprecated,
  serializeQueryString,
} from '../url';

/** @private @const {!Array<string>} */
const allowedMethods_ = ['GET', 'POST'];

/** @private @const {!Array<function(*):boolean>} */
const allowedJsonBodyTypes_ = [isArray, isObject];

/**
 * Serializes a fetch request so that it can be passed to `postMessage()`,
 * i.e., can be cloned using the
 * [structured clone algorithm](http://mdn.io/Structured_clone_algorithm).
 *
 * The request is serialized in the following way:
 *
 * 1. If the `init.body` is a `FormData`, set content-type header to
 * `multipart/form-data` and transform `init.body` into an
 * `!Array<!Array<string>>` holding the list of form entries, where each
 * element in the array is a form entry (key-value pair) represented as a
 * 2-element array.
 *
 * 2. Return a new object having properties `input` and the transformed
 * `init`.
 *
 * The serialized request is assumed to be de-serialized in the following way:
 *
 * 1.If content-type header starts with `multipart/form-data`
 * (case-insensitive), transform the entry array in `init.body` into a
 * `FormData` object.
 *
 * 2. Pass `input` and transformed `init` to `fetch` (or the constructor of
 * `Request`).
 *
 * Currently only `FormData` used in `init.body` is handled as it's the only
 * type being used in AMP runtime that needs serialization. The `Headers` type
 * also needs serialization, but callers should not be passing `Headers`
 * object in `init`, as that fails `fetchPolyfill` on browsers that don't
 * support fetch. Some serialization-needing types for `init.body` such as
 * `ArrayBuffer` and `Blob` are already supported by the structured clone
 * algorithm. Other serialization-needing types such as `URLSearchParams`
 * (which is not supported in IE and Safari) and `FederatedCredentials` are
 * not used in AMP runtime. `init.body` can also be a string
 * (application/x-www-form-urlencoded) but that doesn't require serialization.
 *
 * @param {string} input The URL of the XHR to convert to structured
 *     cloneable.
 * @param {!FetchInitDef} init The options of the XHR to convert to structured
 *     cloneable.
 * @return {{input: string, init: !FetchInitDef}} The serialized structurally-
 *     cloneable request.
 */
export function toStructuredCloneable(input, init) {
  const newInit = /** @type {!FetchInitDef} */ ({...init});
  if (isFormDataWrapper(init.body)) {
    const wrapper = /** @type {!FormDataWrapperInterface} */ (init.body);
    newInit.headers['Content-Type'] = 'multipart/form-data;charset=utf-8';
    newInit.body = fromIterator(wrapper.entries());
  }
  return {input, init: newInit};
}

/**
 * De-serializes a fetch response that was made possible to be passed to
 * `postMessage()`, i.e., can be cloned using the
 * [structured clone algorithm](http://mdn.io/Structured_clone_algorithm).
 *
 * The response is assumed to be serialized in the following way:
 *
 * 1. Transform the entries in the headers of the response into an
 * `!Array<!Array<string>>` holding the list of header entries, where each
 * element in the array is a header entry (key-value pair) represented as a
 * 2-element array. The header key is case-insensitive.
 *
 * 2. Include the header entry list and `status` and `statusText` properties
 * of the response in as `headers`, `status` and `statusText` properties of
 * `init`.
 *
 * 3. Include the body of the response serialized as string in `body`.
 *
 * 4. Return a new object having properties `body` and `init`.
 *
 * The response is de-serialized in the following way:
 *
 * 1. If the `Response` type is supported and `responseType` is not
 * document, pass `body` and `init` directly to the constructor of `Response`.
 *
 * 2. Otherwise, populate a fake XHR object to pass to `FetchResponse` as if
 * the response is returned by the fetch polyfill.
 *
 * 3. If `responseType` is `document`, also parse the body and populate
 * `responseXML` as a `Document` type.
 *
 * @param {JsonObject|string|undefined} response The structurally-cloneable
 *     response to convert back to a regular Response.
 * @param {string|undefined} responseType The original response type used to
 *     initiate the XHR.
 * @return {!Response} The deserialized regular response.
 * @private
 */
export function fromStructuredCloneable(response, responseType) {
  userAssert(isObject(response), 'Object expected: %s', response);

  const isDocumentType = responseType == 'document';
  if (!isDocumentType) {
    // Use native `Response` type if available for performance. If response
    // type is `document`, we must fall back to `FetchResponse` polyfill
    // because callers would then rely on the `responseXML` property being
    // present, which is not supported by the Response type.
    return new Response(response['body'], response['init']);
  }

  const lowercasedHeaders = map();
  const data = {
    status: 200,
    statusText: 'OK',
    /**
     * @param {string} name
     * @return {string}
     */
    getResponseHeader(name) {
      return lowercasedHeaders[String(name).toLowerCase()] || null;
    },
  };

  if (response['init']) {
    const init = response['init'];
    if (isArray(init.headers)) {
      /** @type {!Array} */ (init.headers).forEach((entry) => {
        const headerName = entry[0];
        const headerValue = entry[1];
        lowercasedHeaders[String(headerName).toLowerCase()] =
          String(headerValue);
      });
    }
    if (init.status) {
      data.status = parseInt(init.status, 10);
    }
    if (init.statusText) {
      data.statusText = String(init.statusText);
    }
  }

  return new Response(response['body'] ? String(response['body']) : '', data);
}

/**
 * Intercepts the XHR and proxies it through the viewer if necessary.
 *
 * XHRs are intercepted if all of the following are true:
 * - The AMP doc is in single doc mode
 * - The requested resource is not a 1p request.
 * - The viewer has the `xhrInterceptor` capability
 * - The Viewer is a trusted viewer or AMP is currently in development mode
 * - The AMP doc is opted-in for XHR interception (`<html>` tag has
 *   `allow-xhr-interception` attribute)
 *
 * @param {!Window} win
 * @param {?../service/ampdoc-impl.AmpDoc} ampdocSingle
 * @param {string} input The URL of the XHR which may get intercepted.
 * @param {!FetchInitDef} init The options of the XHR which may get
 *     intercepted.
 * @return {!Promise<!Response|undefined>}
 *     A response returned by the interceptor if XHR is intercepted or
 *     `Promise<undefined>` otherwise.
 */
export function getViewerInterceptResponse(win, ampdocSingle, input, init) {
  if (!ampdocSingle) {
    return Promise.resolve();
  }

  const whenUnblocked = init.prerenderSafe
    ? Promise.resolve()
    : ampdocSingle.whenFirstVisible();
  const viewer = Services.viewerForDoc(ampdocSingle);
  const urlIsProxy = isProxyOrigin(input);
  const viewerCanIntercept = viewer.hasCapability('xhrInterceptor');
  const interceptorDisabledForLocalDev =
    init.bypassInterceptorForDev && getMode(win).localDev;
  if (urlIsProxy || !viewerCanIntercept || interceptorDisabledForLocalDev) {
    return whenUnblocked;
  }

  const htmlElement = ampdocSingle.getRootNode().documentElement;
  const docOptedIn = htmlElement.hasAttribute('allow-xhr-interception');
  if (!docOptedIn) {
    return whenUnblocked;
  }

  return whenUnblocked
    .then(() => viewer.isTrustedViewer())
    .then((viewerTrusted) => {
      if (
        !(
          viewerTrusted ||
          getMode(win).localDev ||
          isExperimentOn(win, 'untrusted-xhr-interception')
        )
      ) {
        return;
      }
      const messagePayload = {
        'originalRequest': toStructuredCloneable(input, init),
      };
      return viewer
        .sendMessageAwaitResponse('xhr', messagePayload)
        .then((response) =>
          fromStructuredCloneable(response, init.responseType)
        );
    });
}

/**
 * Sets up URL based on ampCors
 * @param {!Window} win
 * @param {string} input
 * @param {!FetchInitDef} init The options of the XHR which may get
 * intercepted.
 * @return {string}
 */
export function setupInput(win, input, init) {
  devAssert(typeof input == 'string', 'Only URL supported: %s', input);
  if (init.ampCors !== false) {
    input = getCorsUrl(win, input);
  }
  return input;
}

/**
 * Sets up and normalizes the FetchInitDef
 *
 * @param {?FetchInitDef=} opt_init Fetch options object.
 * @param {string=} opt_accept The HTTP Accept header value.
 * @return {!FetchInitDef}
 */
export function setupInit(opt_init, opt_accept) {
  const init = opt_init || {};

  // In particular, Firefox does not tolerate `null` values for
  // `credentials`.
  const creds = init.credentials;
  devAssert(
    creds === undefined || creds == 'include' || creds == 'omit',
    'Only credentials=include|omit support: %s',
    creds
  );

  init.method = normalizeMethod_(init.method);
  init.headers = init.headers || {};
  if (opt_accept) {
    init.headers['Accept'] = opt_accept;
  }

  // In edge a `TypeMismatchError` is thrown when body is set to null.
  devAssert(init.body !== null, 'fetch `body` can not be `null`');

  return init;
}

/**
 *
 * Sets up AMPSpecific CORS headers.
 * @param {!Window} win
 * @param {string} input
 * @param {?FetchInitDef=} init
 * @return {!FetchInitDef}
 */
export function setupAMPCors(win, input, init) {
  init = init || {};
  // For some same origin requests, add AMP-Same-Origin: true header to allow
  // publishers to validate that this request came from their own origin.
  const currentOrigin = getWinOrigin(win);
  const targetOrigin = parseUrlDeprecated(input).origin;
  if (currentOrigin == targetOrigin) {
    init['headers'] = init['headers'] || {};
    init['headers']['AMP-Same-Origin'] = 'true';
  }
  return init;
}

/**
 * @param {?FetchInitDef=} init
 * @return {!FetchInitDef}
 */
export function setupJsonFetchInit(init) {
  const fetchInit = setupInit(init, 'application/json');
  if (fetchInit.method == 'POST' && !isFormDataWrapper(fetchInit.body)) {
    // Assume JSON strict mode where only objects or arrays are allowed
    // as body.
    devAssert(
      allowedJsonBodyTypes_.some((test) => test(fetchInit.body)),
      'body must be of type object or array. %s',
      fetchInit.body
    );

    // Content should be 'text/plain' to avoid CORS preflight.
    fetchInit.headers['Content-Type'] =
      fetchInit.headers['Content-Type'] || 'text/plain;charset=utf-8';
    const headerContentType = fetchInit.headers['Content-Type'];
    // Cast is valid, because we checked that it is not form data above.
    if (headerContentType === 'application/x-www-form-urlencoded') {
      fetchInit.body = serializeQueryString(
        /** @type {!JsonObject} */ (fetchInit.body)
      );
    } else {
      fetchInit.body = JSON.stringify(
        /** @type {!JsonObject} */ (fetchInit.body)
      );
    }
  }
  return fetchInit;
}

/**
 * Normalized method name by uppercasing.
 * @param {string|undefined} method
 * @return {string}
 * @private
 */
function normalizeMethod_(method) {
  if (method === undefined) {
    return 'GET';
  }
  method = method.toUpperCase();
  devAssert(
    allowedMethods_.includes(method),
    'Only one of %s is currently allowed. Got %s',
    allowedMethods_.join(', '),
    method
  );
  return method;
}

/**
 * If 415 or in the 5xx range.
 * @param {number} status
 * @return {boolean}
 */
function isRetriable(status) {
  return status == 415 || (status >= 500 && status < 600);
}

/**
 * Returns the response if successful or otherwise throws an error.
 * @param {!Response} response
 * @return {!Promise<!Response>}
 */
export function assertSuccess(response) {
  return new Promise((resolve) => {
    if (response.ok) {
      return resolve(response);
    }

    const {status} = response;
    const err = user().createError(`HTTP error ${status}`);
    err['retriable'] = isRetriable(status);
    // TODO(@jridgewell, #9448): Callers who need the response should
    // skip processing.
    err['response'] = response;
    throw err;
  });
}
