/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-disable block-scoped-var */

"use strict";

const {
  EVENTS,
  TEST_EVENTS,
} = require("resource://devtools/client/netmonitor/src/constants.js");
const { CurlUtils } = require("resource://devtools/client/shared/curl.js");
const {
  fetchHeaders,
} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");

const {
  getLongStringFullText,
} = require("resource://devtools/client/shared/string-utils.js");

/**
 * This object is responsible for fetching additional HTTP
 * data from the backend over RDP protocol.
 *
 * The object also keeps track of RDP requests in-progress,
 * so it's possible to determine whether all has been fetched
 * or not.
 */
class FirefoxDataProvider {
  /**
   * Constructor for data provider
   *
   * @param {Object} commands Object defined from devtools/shared/commands to interact with the devtools backend
   * @param {Object} actions set of actions fired during data fetching process.
   * @param {Object} owner all events are fired on this object.
   */
  constructor({ commands, actions, owner }) {
    // Options
    this.commands = commands;
    this.actions = actions || {};
    this.actionsEnabled = true;

    // Allow requesting of on-demand network data, this would be `false` when requests
    // are cleared (as we clear also on the backend), and will be flipped back
    // to true on the next `onNetworkResourceAvailable` call.
    this._requestDataEnabled = true;

    // `_requestDataEnabled` can only be used to prevent new calls to
    // requestData. For pending/already started calls, we need to check if
    // clear() was called during the call, which is the purpose of this counter.
    this._lastRequestDataClearId = 0;

    this.owner = owner;

    // This holds stacktraces infomation temporarily. Stacktrace resources
    // can come before or after (out of order) their related network events.
    // This will hold stacktrace related info from the NETWORK_EVENT_STACKTRACE resource
    // for the NETWORK_EVENT resource and vice versa.
    this.stackTraces = new Map();
    // Map of the stacktrace information keyed by the actor id's
    this.stackTraceRequestInfoByActorID = new Map();

    // For tracking unfinished requests
    this.pendingRequests = new Set();

    // Map[key string => Promise] used by `requestData` to prevent requesting the same
    // request data twice.
    this.lazyRequestData = new Map();

    // Fetching data from the backend
    this.getLongString = this.getLongString.bind(this);

    // Event handlers
    this.onNetworkResourceAvailable =
      this.onNetworkResourceAvailable.bind(this);
    this.onNetworkResourceUpdated = this.onNetworkResourceUpdated.bind(this);

    this.onWebSocketOpened = this.onWebSocketOpened.bind(this);
    this.onWebSocketClosed = this.onWebSocketClosed.bind(this);
    this.onFrameSent = this.onFrameSent.bind(this);
    this.onFrameReceived = this.onFrameReceived.bind(this);

    this.onEventSourceConnectionClosed =
      this.onEventSourceConnectionClosed.bind(this);
    this.onEventReceived = this.onEventReceived.bind(this);
    this.setEventStreamFlag = this.setEventStreamFlag.bind(this);
  }

  /*
   * Cleans up all the internal states, this usually done before navigation
   * (without the persist flag on).
   */
  clear() {
    this.stackTraces.clear();
    this.pendingRequests.clear();
    this.lazyRequestData.clear();
    this.stackTraceRequestInfoByActorID.clear();
    this._requestDataEnabled = false;
    this._lastRequestDataClearId++;
  }

  destroy() {
    // TODO: clear() is called in the middle of the lifecycle of the
    // FirefoxDataProvider, for clarity we are exposing it as a separate method.
    // `destroy` should be updated to nullify relevant instance properties.
    this.clear();
  }

  /**
   * Enable/disable firing redux actions (enabled by default).
   *
   * @param {boolean} enable Set to true to fire actions.
   */
  enableActions(enable) {
    this.actionsEnabled = enable;
  }

  /**
   * Add a new network request to application state.
   *
   * @param {string} id request id
   * @param {object} resource resource payload will be added to application state
   */
  async addRequest(id, resource) {
    // Add to the pending requests which helps when deciding if the request is complete.
    this.pendingRequests.add(id);

    if (this.actionsEnabled && this.actions.addRequest) {
      await this.actions.addRequest(id, resource, true);
    }

    this.emit(EVENTS.REQUEST_ADDED, id);
  }

  /**
   * Update a network request if it already exists in application state.
   *
   * @param {string} id request id
   * @param {object} data data payload will be updated to application state
   */
  async updateRequest(id, data) {
    const {
      responseContent,
      responseCookies,
      responseHeaders,
      requestCookies,
      requestHeaders,
      requestPostData,
      responseCache,
    } = data;

    // fetch request detail contents in parallel
    const [
      responseContentObj,
      requestHeadersObj,
      responseHeadersObj,
      postDataObj,
      requestCookiesObj,
      responseCookiesObj,
      responseCacheObj,
    ] = await Promise.all([
      this.fetchResponseContent(responseContent),
      this.fetchRequestHeaders(requestHeaders),
      this.fetchResponseHeaders(responseHeaders),
      this.fetchPostData(requestPostData),
      this.fetchRequestCookies(requestCookies),
      this.fetchResponseCookies(responseCookies),
      this.fetchResponseCache(responseCache),
    ]);

    const payload = Object.assign(
      {},
      data,
      responseContentObj,
      requestHeadersObj,
      responseHeadersObj,
      postDataObj,
      requestCookiesObj,
      responseCookiesObj,
      responseCacheObj
    );

    if (this.actionsEnabled && this.actions.updateRequest) {
      await this.actions.updateRequest(id, payload, true);
    }

    return payload;
  }

  async fetchResponseContent(responseContent) {
    const payload = {};
    if (responseContent?.content) {
      const { text } = responseContent.content;
      const response = await this.getLongString(text);
      responseContent.content.text = response;
      payload.responseContent = responseContent;
    }
    return payload;
  }

  async fetchRequestHeaders(requestHeaders) {
    const payload = {};
    if (requestHeaders?.headers?.length) {
      const headers = await fetchHeaders(requestHeaders, this.getLongString);
      if (headers) {
        payload.requestHeaders = headers;
      }
    }
    return payload;
  }

  async fetchResponseHeaders(responseHeaders) {
    const payload = {};
    if (responseHeaders?.headers?.length) {
      const headers = await fetchHeaders(responseHeaders, this.getLongString);
      if (headers) {
        payload.responseHeaders = headers;
      }
    }
    return payload;
  }

  async fetchPostData(requestPostData) {
    const payload = {};
    if (requestPostData?.postData) {
      const { text } = requestPostData.postData;
      const postData = await this.getLongString(text);
      const headers = CurlUtils.getHeadersFromMultipartText(postData);

      // Calculate total header size and don't forget to include
      // two new-line characters at the end.
      const headersSize = headers.reduce((acc, { name, value }) => {
        return acc + name.length + value.length + 2;
      }, 0);

      requestPostData.postData.text = postData;
      payload.requestPostData = {
        ...requestPostData,
        uploadHeaders: { headers, headersSize },
      };
    }
    return payload;
  }

  async fetchRequestCookies(requestCookies) {
    const payload = {};
    if (requestCookies) {
      const reqCookies = [];
      // request store cookies in requestCookies or requestCookies.cookies
      const cookies = requestCookies.cookies
        ? requestCookies.cookies
        : requestCookies;
      // make sure cookies is iterable
      if (typeof cookies[Symbol.iterator] === "function") {
        for (const cookie of cookies) {
          reqCookies.push(
            Object.assign({}, cookie, {
              value: await this.getLongString(cookie.value),
            })
          );
        }
        if (reqCookies.length) {
          payload.requestCookies = reqCookies;
        }
      }
    }
    return payload;
  }

  async fetchResponseCookies(responseCookies) {
    const payload = {};
    if (responseCookies) {
      const resCookies = [];
      // response store cookies in responseCookies or responseCookies.cookies
      const cookies = responseCookies.cookies
        ? responseCookies.cookies
        : responseCookies;
      // make sure cookies is iterable
      if (typeof cookies[Symbol.iterator] === "function") {
        for (const cookie of cookies) {
          resCookies.push(
            Object.assign({}, cookie, {
              value: await this.getLongString(cookie.value),
            })
          );
        }
        if (resCookies.length) {
          payload.responseCookies = resCookies;
        }
      }
    }
    return payload;
  }

  async fetchResponseCache(responseCache) {
    const payload = {};
    if (responseCache) {
      payload.responseCache = await responseCache;
      payload.responseCacheAvailable = false;
    }
    return payload;
  }

  /**
   * Public API used by the Toolbox: Tells if there is still any pending request.
   *
   * @return {boolean} returns true if pending requests still exist in the queue.
   */
  hasPendingRequests() {
    return this.pendingRequests.size > 0;
  }

  /**
   * Fetches the full text of a LongString.
   *
   * @param {object|string} stringGrip
   *        The long string grip containing the corresponding actor.
   *        If you pass in a plain string (by accident or because you're lazy),
   *        then a promise of the same string is simply returned.
   * @return {object}
   *         A promise that is resolved when the full string contents
   *         are available, or rejected if something goes wrong.
   */
  async getLongString(stringGrip) {
    const payload = await getLongStringFullText(
      this.commands.client,
      stringGrip
    );
    this.emitForTests(TEST_EVENTS.LONGSTRING_RESOLVED, { payload });
    return payload;
  }

  /**
   * Retrieve the stack-trace information for the given StackTracesActor.
   *
   * @param object actor
   *        - {Object} targetFront: the target front.
   *
   *        - {String} resourceId: the resource id for the network request".
   * @return {object}
   */
  async _getStackTraceFromWatcher(actor) {
    // If we request the stack trace for the navigation request,
    // t was coming from previous page content process, which may no longer be around.
    // In any case, the previous target is destroyed and we can't fetch the stack anymore.
    let stacktrace = [];
    if (!actor.targetFront.isDestroyed()) {
      const networkContentFront = await actor.targetFront.getFront(
        "networkContent"
      );
      stacktrace = await networkContentFront.getStackTrace(
        actor.stacktraceResourceId
      );
    }
    return { stacktrace };
  }

  /**
   * The handler for when the network event stacktrace resource is available.
   * The resource contains basic info, the actual stacktrace is fetched lazily
   * using requestData.
   * @param {object} resource The network event stacktrace resource
   */
  async onStackTraceAvailable(resource) {
    if (!this.stackTraces.has(resource.resourceId)) {
      // If no stacktrace info exists, this means the network event
      // has not fired yet, lets store useful info for the NETWORK_EVENT
      // resource.
      this.stackTraces.set(resource.resourceId, resource);
    } else {
      // If stacktrace info exists, this means the network event has already
      // fired, so lets just update the reducer with the necessary stacktrace info.
      const request = this.stackTraces.get(resource.resourceId);
      request.cause.stacktraceAvailable = resource.stacktraceAvailable;
      request.cause.lastFrame = resource.lastFrame;

      this.stackTraces.delete(resource.resourceId);

      this.stackTraceRequestInfoByActorID.set(request.actor, {
        targetFront: resource.targetFront,
        stacktraceResourceId: resource.resourceId,
      });

      if (this.actionsEnabled && this.actions.updateRequest) {
        await this.actions.updateRequest(request.actor, request, true);
      }
    }
  }

  /**
   * The handler for when the network event resource is available.
   *
   * @param {object} resource The network event resource
   */
  async onNetworkResourceAvailable(resource) {
    const { actor, stacktraceResourceId, cause } = resource;

    if (!this._requestDataEnabled) {
      this._requestDataEnabled = true;
    }

    // Check if a stacktrace resource already exists for this network resource.
    if (this.stackTraces.has(stacktraceResourceId)) {
      const { stacktraceAvailable, lastFrame, targetFront } =
        this.stackTraces.get(stacktraceResourceId);

      resource.cause.stacktraceAvailable = stacktraceAvailable;
      resource.cause.lastFrame = lastFrame;

      this.stackTraces.delete(stacktraceResourceId);
      // We retrieve preliminary information about the stacktrace from the
      // NETWORK_EVENT_STACKTRACE resource via `this.stackTraces` Map,
      // The actual stacktrace is fetched lazily based on the actor id, using
      // the targetFront and the stacktrace resource id therefore we
      // map these for easy access.
      this.stackTraceRequestInfoByActorID.set(actor, {
        targetFront,
        stacktraceResourceId,
      });
    } else if (cause) {
      // If the stacktrace for this request is not available, and we
      // expect that this request should have a stacktrace, lets store
      // some useful info for when the NETWORK_EVENT_STACKTRACE resource
      // finally comes.
      this.stackTraces.set(stacktraceResourceId, { actor, cause });
    }
    await this.addRequest(actor, resource);
    this.emitForTests(TEST_EVENTS.NETWORK_EVENT, resource);
  }

  /**
   * The handler for when the network event resource is updated.
   *
   * @param {object} resource The updated network event resource.
   */
  async onNetworkResourceUpdated(resource) {
    // Identify the channel as SSE if mimeType is event-stream.
    if (resource?.mimeType?.includes("text/event-stream")) {
      await this.setEventStreamFlag(resource.actor);
    }

    this.pendingRequests.delete(resource.actor);
    if (this.actionsEnabled && this.actions.updateRequest) {
      await this.actions.updateRequest(resource.actor, resource, true);
    }

    // This event is fired only once per request, once all the properties are fetched
    // from `onNetworkResourceUpdated`. There should be no more RDP requests after this.
    // Note that this event might be consumed by extension so, emit it in production
    // release as well.
    this.emitForTests(TEST_EVENTS.NETWORK_EVENT_UPDATED, resource.actor);
    this.emit(EVENTS.PAYLOAD_READY, resource);
  }

  /**
   * The "webSocketOpened" message type handler.
   *
   * @param {number} httpChannelId the channel ID
   * @param {string} effectiveURI the effective URI of the page
   * @param {string} protocols webSocket protocols
   * @param {string} extensions
   */
  async onWebSocketOpened(httpChannelId, effectiveURI, protocols, extensions) {}

  /**
   * The "webSocketClosed" message type handler.
   *
   * @param {number} httpChannelId
   * @param {boolean} wasClean
   * @param {number} code
   * @param {string} reason
   */
  async onWebSocketClosed(httpChannelId, wasClean, code, reason) {
    if (this.actionsEnabled && this.actions.closeConnection) {
      await this.actions.closeConnection(httpChannelId, wasClean, code, reason);
    }
  }

  /**
   * The "frameSent" message type handler.
   *
   * @param {number} httpChannelId the channel ID
   * @param {object} data websocket frame information
   */
  async onFrameSent(httpChannelId, data) {
    this.addMessage(httpChannelId, data);
  }

  /**
   * The "frameReceived" message type handler.
   *
   * @param {number} httpChannelId the channel ID
   * @param {object} data websocket frame information
   */
  async onFrameReceived(httpChannelId, data) {
    this.addMessage(httpChannelId, data);
  }

  /**
   * Add a new WebSocket frame to application state.
   *
   * @param {number} httpChannelId the channel ID
   * @param {object} data websocket frame information
   */
  async addMessage(httpChannelId, data) {
    if (this.actionsEnabled && this.actions.addMessage) {
      await this.actions.addMessage(httpChannelId, data, true);
    }
    // TODO: Emit an event for test here
  }

  /**
   * Public connector API to lazily request HTTP details from the backend.
   *
   * The method focus on:
   * - calling the right actor method,
   * - emitting an event to tell we start fetching some request data,
   * - call data processing method.
   *
   * @param {string} actor actor id (used as request id)
   * @param {string} method identifier of the data we want to fetch
   *
   * @return {Promise} return a promise resolved when data is received.
   */
  requestData(actor, method) {
    // if this is `false`, do not try to request data as requests on the backend
    // might no longer exist (usually `false` after requests are cleared).
    if (!this._requestDataEnabled) {
      return Promise.resolve();
    }
    // Key string used in `lazyRequestData`. We use this Map to prevent requesting
    // the same data twice at the same time.
    const key = `${actor}-${method}`;
    let promise = this.lazyRequestData.get(key);
    // If a request is pending, reuse it.
    if (promise) {
      return promise;
    }
    // Fetch the data
    promise = this._requestData(actor, method).then(async payload => {
      // Remove the request from the cache, any new call to requestData will fetch the
      // data again.
      this.lazyRequestData.delete(key);

      if (this.actionsEnabled && this.actions.updateRequest) {
        await this.actions.updateRequest(
          actor,
          {
            ...payload,
            // Lockdown *Available property once we fetch data from back-end.
            // Using this as a flag to prevent fetching arrived data again.
            [`${method}Available`]: false,
          },
          true
        );
      }

      return payload;
    });

    this.lazyRequestData.set(key, promise);

    return promise;
  }

  /**
   * Internal helper used to request HTTP details from the backend.
   *
   * This is internal method that focus on:
   * - calling the right actor method,
   * - emitting an event to tell we start fetching some request data,
   * - call data processing method.
   *
   * @param {string} actor actor id (used as request id)
   * @param {string} method identifier of the data we want to fetch
   *
   * @return {Promise} return a promise resolved when data is received.
   */
  async _requestData(actor, method) {
    // Backup the lastRequestDataClearId before doing any async processing.
    const lastRequestDataClearId = this._lastRequestDataClearId;

    // Calculate real name of the client getter.
    const clientMethodName = `get${method
      .charAt(0)
      .toUpperCase()}${method.slice(1)}`;
    // The name of the callback that processes request response
    const callbackMethodName = `on${method
      .charAt(0)
      .toUpperCase()}${method.slice(1)}`;
    // And the event to fire before updating this data
    const updatingEventName = `UPDATING_${method
      .replace(/([A-Z])/g, "_$1")
      .toUpperCase()}`;

    // Emit event that tell we just start fetching some data
    this.emitForTests(EVENTS[updatingEventName], actor);

    // Make sure we fetch the real actor data instead of cloned actor
    // e.g. CustomRequestPanel will clone a request with additional '-clone' actor id
    const actorID = actor.replace("-clone", "");

    // 'getStackTrace' is the only one to be fetched via the NetworkContent actor in content process
    // while all other attributes are fetched from the NetworkEvent actors, running in the parent process
    let response;
    if (
      clientMethodName == "getStackTrace" &&
      this.commands.resourceCommand.hasResourceCommandSupport(
        this.commands.resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE
      )
    ) {
      const requestInfo = this.stackTraceRequestInfoByActorID.get(actorID);
      const { stacktrace } = await this._getStackTraceFromWatcher(requestInfo);
      this.stackTraceRequestInfoByActorID.delete(actorID);
      response = { from: actor, stacktrace };
    } else {
      // We don't create fronts for NetworkEvent actors,
      // so that we have to do the request manually via DevToolsClient.request()
      try {
        const packet = {
          to: actorID,
          type: clientMethodName,
        };
        response = await this.commands.client.request(packet);
      } catch (e) {
        if (this._lastRequestDataClearId !== lastRequestDataClearId) {
          // If lastRequestDataClearId was updated, FirefoxDataProvider:clear()
          // was called and all network event actors have been destroyed.
          // Swallow errors to avoid unhandled promise rejections in tests.
          console.warn(
            `Firefox Data Provider destroyed while requesting data: ${e.message}`
          );
          // Return an empty response packet to avoid too many callback errors.
          response = { from: actor };
        } else {
          throw new Error(
            `Error while calling method ${clientMethodName}: ${e.message}`
          );
        }
      }
    }

    // Restore clone actor id
    if (actor.includes("-clone")) {
      // Because response's properties are read-only, we create a new response
      response = { ...response, from: `${response.from}-clone` };
    }

    // Call data processing method.
    return this[callbackMethodName](response);
  }

  /**
   * Handles additional information received for a "requestHeaders" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onRequestHeaders(response) {
    const payload = await this.updateRequest(response.from, {
      requestHeaders: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_HEADERS, response);
    return payload.requestHeaders;
  }

  /**
   * Handles additional information received for a "responseHeaders" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onResponseHeaders(response) {
    const payload = await this.updateRequest(response.from, {
      responseHeaders: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_HEADERS, response);
    return payload.responseHeaders;
  }

  /**
   * Handles additional information received for a "requestCookies" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onRequestCookies(response) {
    const payload = await this.updateRequest(response.from, {
      requestCookies: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_COOKIES, response);
    return payload.requestCookies;
  }

  /**
   * Handles additional information received for a "requestPostData" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onRequestPostData(response) {
    const payload = await this.updateRequest(response.from, {
      requestPostData: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_POST_DATA, response);
    return payload.requestPostData;
  }

  /**
   * Handles additional information received for a "securityInfo" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onSecurityInfo(response) {
    const payload = await this.updateRequest(response.from, {
      securityInfo: response.securityInfo,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_SECURITY_INFO, response);
    return payload.securityInfo;
  }

  /**
   * Handles additional information received for a "responseCookies" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onResponseCookies(response) {
    const payload = await this.updateRequest(response.from, {
      responseCookies: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_COOKIES, response);
    return payload.responseCookies;
  }

  /**
   * Handles additional information received for a "responseCache" packet.
   * @param {object} response the message received from the server.
   */
  async onResponseCache(response) {
    const payload = await this.updateRequest(response.from, {
      responseCache: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_CACHE, response);
    return payload.responseCache;
  }

  /**
   * Handles additional information received via "getResponseContent" request.
   *
   * @param {object} response the message received from the server.
   */
  async onResponseContent(response) {
    const payload = await this.updateRequest(response.from, {
      // We have to ensure passing mimeType as fetchResponseContent needs it from
      // updateRequest. It will convert the LongString in `response.content.text` to a
      // string.
      mimeType: response.content.mimeType,
      responseContent: response,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_CONTENT, response);
    return payload.responseContent;
  }

  /**
   * Handles additional information received for a "eventTimings" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onEventTimings(response) {
    const payload = await this.updateRequest(response.from, {
      eventTimings: response,
    });

    // This event is utilized only in tests but, DAMP is using it too
    // and running DAMP test doesn't set the `devtools.testing` flag.
    // So, emit this event even in the production mode.
    // See also: https://bugzilla.mozilla.org/show_bug.cgi?id=1578215
    this.emit(EVENTS.RECEIVED_EVENT_TIMINGS, response);
    return payload.eventTimings;
  }

  /**
   * Handles information received for a "stackTrace" packet.
   *
   * @param {object} response the message received from the server.
   */
  async onStackTrace(response) {
    const payload = await this.updateRequest(response.from, {
      stacktrace: response.stacktrace,
    });
    this.emitForTests(TEST_EVENTS.RECEIVED_EVENT_STACKTRACE, response);
    return payload.stacktrace;
  }

  /**
   * Handle EventSource events.
   */

  async onEventSourceConnectionClosed(httpChannelId) {
    if (this.actionsEnabled && this.actions.closeConnection) {
      await this.actions.closeConnection(httpChannelId);
    }
  }

  async onEventReceived(httpChannelId, data) {
    // Dispatch the same action used by websocket inspector.
    this.addMessage(httpChannelId, data);
  }

  async setEventStreamFlag(actorId) {
    if (this.actionsEnabled && this.actions.setEventStreamFlag) {
      await this.actions.setEventStreamFlag(actorId, true);
    }
  }

  /**
   * Fire events for the owner object.
   */
  emit(type, data) {
    if (this.owner) {
      this.owner.emit(type, data);
    }
  }

  /**
   * Fire test events for the owner object. These events are
   * emitted only when tests are running.
   */
  emitForTests(type, data) {
    if (this.owner) {
      this.owner.emitForTests(type, data);
    }
  }
}

module.exports = FirefoxDataProvider;
