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

"use strict";

const {
  AUTOCOMPLETE_CLEAR,
  AUTOCOMPLETE_DATA_RECEIVE,
  AUTOCOMPLETE_PENDING_REQUEST,
  AUTOCOMPLETE_RETRIEVE_FROM_CACHE,
} = require("resource://devtools/client/webconsole/constants.js");

const {
  analyzeInputString,
  shouldInputBeAutocompleted,
} = require("resource://devtools/shared/webconsole/analyze-input-string.js");

loader.lazyRequireGetter(
  this,
  "getSelectedTarget",
  "resource://devtools/shared/commands/target/selectors/targets.js",
  true
);

/**
 * Update the data used for the autocomplete popup in the console input (JsTerm).
 *
 * @param {boolean} force: True to force a call to the server (as opposed to retrieve
 *                         from the cache).
 * @param {Array<string>} getterPath: Array representing the getter access (i.e.
 *                                    `a.b.c.d.` is described as ['a', 'b', 'c', 'd'] ).
 * @param {Array<string>} expressionVars: Array of the variables defined in the expression.
 */
function autocompleteUpdate(force, getterPath, expressionVars) {
  return async ({ dispatch, getState, webConsoleUI, hud }) => {
    if (hud.inputHasSelection()) {
      return dispatch(autocompleteClear());
    }

    const inputValue = hud.getInputValue();
    const mappedVars = hud.getMappedVariables() ?? {};
    const allVars = (expressionVars ?? []).concat(Object.keys(mappedVars));
    const frameActorId = await hud.getSelectedFrameActorID();

    const cursor = webConsoleUI.getInputCursor();

    const state = getState().autocomplete;
    const { cache } = state;
    if (
      !force &&
      (!inputValue || /^[a-zA-Z0-9_$]/.test(inputValue.substring(cursor)))
    ) {
      return dispatch(autocompleteClear());
    }

    const rawInput = inputValue.substring(0, cursor);
    const retrieveFromCache =
      !force &&
      cache &&
      cache.input &&
      rawInput.startsWith(cache.input) &&
      /[a-zA-Z0-9]$/.test(rawInput) &&
      frameActorId === cache.frameActorId;

    if (retrieveFromCache) {
      return dispatch(autoCompleteDataRetrieveFromCache(rawInput));
    }

    const authorizedEvaluations = updateAuthorizedEvaluations(
      state.authorizedEvaluations,
      getterPath,
      mappedVars
    );

    const { input, originalExpression } = await getMappedInput(
      rawInput,
      mappedVars,
      hud
    );

    return dispatch(
      autocompleteDataFetch({
        input,
        frameActorId,
        authorizedEvaluations,
        force,
        allVars,
        mappedVars,
        originalExpression,
      })
    );
  };
}

/**
 * Combine or replace authorizedEvaluations with the newly authorized getter path, if any.
 *
 * @param {Array<Array<string>>} authorizedEvaluations Existing authorized evaluations (may
 * be updated in place)
 * @param {Array<string>} getterPath The new getter path
 * @param {{[string]: string}} mappedVars Map of original to generated variable names.
 * @returns {Array<Array<string>>} The updated authorized evaluations (the original array,
 * if it was updated in place)
 */
function updateAuthorizedEvaluations(
  authorizedEvaluations,
  getterPath,
  mappedVars
) {
  if (!Array.isArray(authorizedEvaluations) || !authorizedEvaluations.length) {
    authorizedEvaluations = [];
  }

  if (Array.isArray(getterPath) && getterPath.length) {
    // We need to check for any previous authorizations. For example, here if getterPath
    // is ["a", "b", "c", "d"], we want to see if there was any other path that was
    // authorized in a previous request. For that, we only add the previous
    // authorizations if the last auth is contained in getterPath. (for the example, we
    // would keep if it is [["a", "b"]], not if [["b"]] nor [["f", "g"]])
    const last = authorizedEvaluations[authorizedEvaluations.length - 1];

    const generatedPath = mappedVars[getterPath[0]]?.split(".");
    if (generatedPath) {
      getterPath = generatedPath.concat(getterPath.slice(1));
    }

    const isMappedVariable =
      generatedPath && getterPath.length === generatedPath.length;
    const concat = !last || last.every((x, index) => x === getterPath[index]);
    if (isMappedVariable) {
      // If the path consists only of an original variable, add all the prefixes of its
      // mapping. For example, for myVar => a.b.c, authorize a, a.b, and a.b.c. This
      // ensures we'll only show a prompt for myVar once even if a.b and a.b.c are both
      // unsafe getters.
      authorizedEvaluations = generatedPath.map((_, i) =>
        generatedPath.slice(0, i + 1)
      );
    } else if (concat) {
      authorizedEvaluations.push(getterPath);
    } else {
      authorizedEvaluations = [getterPath];
    }
  }
  return authorizedEvaluations;
}

/**
 * Apply source mapping to the autocomplete input.
 *
 * @param {string} rawInput The input to map.
 * @param {{[string]: string}} mappedVars Map of original to generated variable names.
 * @param {WebConsole} hud A reference to the webconsole hud.
 * @returns {string} The source-mapped expression to autocomplete.
 */
async function getMappedInput(rawInput, mappedVars, hud) {
  if (!mappedVars || !Object.keys(mappedVars).length) {
    return { input: rawInput, originalExpression: undefined };
  }

  const inputAnalysis = analyzeInputString(rawInput, 500);
  if (!shouldInputBeAutocompleted(inputAnalysis)) {
    return { input: rawInput, originalExpression: undefined };
  }

  const {
    mainExpression: originalExpression,
    isPropertyAccess,
    isElementAccess,
    lastStatement,
  } = inputAnalysis;

  // If we're autocompleting a variable name, pass it through unchanged so that we
  // show original variable names rather than generated ones.
  // For example, if we have the mapping `myVariable` => `x`, show variables starting
  // with myVariable rather than x.
  if (!isPropertyAccess && !isElementAccess) {
    return { input: lastStatement, originalExpression };
  }

  let generated =
    (await hud.getMappedExpression(originalExpression))?.expression ??
    originalExpression;
  // Strip off the semicolon if the expression was converted to a statement
  const trailingSemicolon = /;\s*$/;
  if (
    trailingSemicolon.test(generated) &&
    !trailingSemicolon.test(originalExpression)
  ) {
    generated = generated.slice(0, generated.lastIndexOf(";"));
  }

  const suffix = lastStatement.slice(originalExpression.length);
  return { input: generated + suffix, originalExpression };
}

/**
 * Called when the autocompletion data should be cleared.
 */
function autocompleteClear() {
  return {
    type: AUTOCOMPLETE_CLEAR,
  };
}

/**
 * Called when the autocompletion data should be retrieved from the cache (i.e.
 * client-side).
 *
 * @param {string} input: The input used to filter the cached data.
 */
function autoCompleteDataRetrieveFromCache(input) {
  return {
    type: AUTOCOMPLETE_RETRIEVE_FROM_CACHE,
    input,
  };
}

let currentRequestId = 0;
function generateRequestId() {
  return currentRequestId++;
}

/**
 * Action that fetch autocompletion data from the server.
 *
 * @param {object} Object of the following shape:
 *        - {String} input: the expression that we want to complete.
 *        - {String} frameActorId: The id of the frame we want to autocomplete in.
 *        - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space).
 *        - {Array} authorizedEvaluations: Array of the properties access which can be
 *                  executed by the engine.
 *                   Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]]
 *                  to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`.
 */
function autocompleteDataFetch({
  input,
  frameActorId,
  force,
  authorizedEvaluations,
  allVars,
  mappedVars,
  originalExpression,
}) {
  return async ({ dispatch, commands, webConsoleUI, hud }) => {
    // Retrieve the right WebConsole front that relates either to (by order of priority):
    // - the currently selected target in the context selector
    //   (contextSelectedTargetFront),
    // - the currently selected Node in the inspector (selectedNodeActor),
    // - the currently selected frame in the debugger (when paused) (frameActor),
    // - the currently selected target in the iframe dropdown
    //   (selectedTargetFront from the TargetCommand)
    const selectedNodeActorId = webConsoleUI.getSelectedNodeActorID();

    let targetFront = commands.targetCommand.selectedTargetFront;
    // Note that getSelectedTargetFront will return null if we default to the top level target.
    const contextSelectorTargetFront = getSelectedTarget(
      hud.commands.targetCommand.store.getState()
    );
    const selectedActorId = selectedNodeActorId || frameActorId;
    if (contextSelectorTargetFront) {
      targetFront = contextSelectorTargetFront;
    } else if (selectedActorId) {
      const selectedFront = commands.client.getFrontByID(selectedActorId);
      if (selectedFront) {
        targetFront = selectedFront.targetFront;
      }
    }

    const webconsoleFront = await targetFront.getFront("console");

    const id = generateRequestId();
    dispatch({ type: AUTOCOMPLETE_PENDING_REQUEST, id });

    webconsoleFront
      .autocomplete(
        input,
        undefined,
        frameActorId,
        selectedNodeActorId,
        authorizedEvaluations,
        allVars
      )
      .then(data => {
        if (data.isUnsafeGetter && originalExpression !== undefined) {
          data.getterPath = unmapGetterPath(
            data.getterPath,
            originalExpression,
            mappedVars
          );
        }
        return dispatch(
          autocompleteDataReceive({
            id,
            input,
            force,
            frameActorId,
            data,
            authorizedEvaluations,
          })
        );
      })
      .catch(e => {
        console.error("failed autocomplete", e);
        dispatch(autocompleteClear());
      });
  };
}

/**
 * Replace generated variable names in an unsafe getter path with their original
 * counterparts.
 *
 * @param {Array<string>} getterPath Array of properties leading up to and including the
 * unsafe getter.
 * @param {string} originalExpression The expression that was evaluated, before mapping.
 * @param {{[string]: string}} mappedVars Map of original to generated variable names.
 * @returns {Array<string>} An updated getter path containing original variables.
 */
function unmapGetterPath(getterPath, originalExpression, mappedVars) {
  // We know that the original expression is a sequence of property accesses, that only
  // the first part can be a mapped variable, and that the getter path must start with
  // its generated path or be a prefix of it.

  // Suppose we have the expression `foo.bar`, which maps to `a.b.c.bar`.
  // Get the first part of the expression ("foo")
  const originalVariable = /^[^.[?]*/s.exec(originalExpression)[0].trim();
  const generatedVariable = mappedVars[originalVariable];
  if (generatedVariable) {
    // Get number of properties in "a.b.c"
    const generatedVariableParts = generatedVariable.split(".");
    // Replace ["a", "b", "c"] with "foo" in the getter path.
    // Note that this will also work if the getter path ends inside of the mapped
    // variable, like ["a", "b"].
    return [
      originalVariable,
      ...getterPath.slice(generatedVariableParts.length),
    ];
  }
  return getterPath;
}

/**
 * Called when we receive the autocompletion data from the server.
 *
 * @param {object} Object of the following shape:
 *        - {Integer} id: The autocompletion request id. This will be used in the reducer
 *                        to check that we update the state with the last request results.
 *        - {String} input: the expression that we want to complete.
 *        - {String} frameActorId: The id of the frame we want to autocomplete in.
 *        - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space).
 *        - {Object} data: The actual data returned from the server.
 *        - {Array} authorizedEvaluations: Array of the properties access which can be
 *                  executed by the engine.
 *                   Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]]
 *                  to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`.
 */
function autocompleteDataReceive({
  id,
  input,
  frameActorId,
  force,
  data,
  authorizedEvaluations,
}) {
  return {
    type: AUTOCOMPLETE_DATA_RECEIVE,
    id,
    input,
    force,
    frameActorId,
    data,
    authorizedEvaluations,
  };
}

module.exports = {
  autocompleteClear,
  autocompleteUpdate,
};
