/**
 * @fileoverview functions for scanning an AST for globals including
 *               traversing referenced scripts.
 * 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 path = require("path");
const fs = require("fs");
const helpers = require("./helpers");
const htmlparser = require("htmlparser2");

/**
 * Parses a list of "name:boolean_value" or/and "name" options divided by comma
 * or whitespace.
 *
 * This function was copied from eslint.js
 *
 * @param {string} string The string to parse.
 * @param {Comment} comment The comment node which has the string.
 * @returns {Object} Result map object of names and boolean values
 */
function parseBooleanConfig(string, comment) {
  let items = {};

  // Collapse whitespace around : to make parsing easier
  string = string.replace(/\s*:\s*/g, ":");
  // Collapse whitespace around ,
  string = string.replace(/\s*,\s*/g, ",");

  string.split(/\s|,+/).forEach(function(name) {
    if (!name) {
      return;
    }

    let pos = name.indexOf(":");
    let value;
    if (pos !== -1) {
      value = name.substring(pos + 1, name.length);
      name = name.substring(0, pos);
    }

    items[name] = {
      value: value === "true",
      comment,
    };
  });

  return items;
}

/**
 * Global discovery can require parsing many files. This map of
 * {String} => {Object} caches what globals were discovered for a file path.
 */
const globalCache = new Map();

/**
 * Global discovery can occasionally meet circular dependencies due to the way
 * js files are included via html/xhtml files etc. This set is used to avoid
 * getting into loops whilst the discovery is in progress.
 */
var globalDiscoveryInProgressForFiles = new Set();

/**
 * When looking for globals in HTML files, it can be common to have more than
 * one script tag with inline javascript. These will normally be called together,
 * so we store the globals for just the last HTML file processed.
 */
var lastHTMLGlobals = {};

/**
 * An object that returns found globals for given AST node types. Each prototype
 * property should be named for a node type and accepts a node parameter and a
 * parents parameter which is a list of the parent nodes of the current node.
 * Each returns an array of globals found.
 *
 * @param  {String} filePath
 *         The absolute path of the file being parsed.
 */
function GlobalsForNode(filePath, context) {
  this.path = filePath;
  this.context = context;

  if (this.path) {
    this.dirname = path.dirname(this.path);
  } else {
    this.dirname = null;
  }
}

GlobalsForNode.prototype = {
  Program(node) {
    let globals = [];
    for (let comment of node.comments) {
      if (comment.type !== "Block") {
        continue;
      }
      let value = comment.value.trim();
      value = value.replace(/\n/g, "");

      // We have to discover any globals that ESLint would have defined through
      // comment directives.
      let match = /^globals?\s+(.+)/.exec(value);
      if (match) {
        let values = parseBooleanConfig(match[1].trim(), node);
        for (let name of Object.keys(values)) {
          globals.push({
            name,
            writable: values[name].value,
          });
        }
        // We matched globals, so we won't match import-globals-from.
        continue;
      }

      match = /^import-globals-from\s+(.+)$/.exec(value);
      if (!match) {
        continue;
      }

      if (!this.dirname) {
        // If this is testing context without path, ignore import.
        return globals;
      }

      let filePath = match[1].trim();

      if (filePath.endsWith(".mjs")) {
        if (this.context) {
          this.context.report(
            comment,
            "import-globals-from does not support module files - use a direct import instead"
          );
        } else {
          // Fall back to throwing an error, as we do not have a context in all situations,
          // e.g. when loading the environment.
          throw new Error(
            "import-globals-from does not support module files - use a direct import instead"
          );
        }
        continue;
      }

      if (!path.isAbsolute(filePath)) {
        filePath = path.resolve(this.dirname, filePath);
      } else {
        filePath = path.join(helpers.rootDir, filePath);
      }
      globals = globals.concat(module.exports.getGlobalsForFile(filePath));
    }

    return globals;
  },

  ExpressionStatement(node, parents, globalScope) {
    let isGlobal = helpers.getIsGlobalThis(parents);
    let globals = [];

    // Note: We check the expression types here and only call the necessary
    // functions to aid performance.
    if (node.expression.type === "AssignmentExpression") {
      globals = helpers.convertThisAssignmentExpressionToGlobals(
        node,
        isGlobal
      );
    } else if (node.expression.type === "CallExpression") {
      globals = helpers.convertCallExpressionToGlobals(node, isGlobal);
    }

    // Here we assume that if importScripts is set in the global scope, then
    // this is a worker. It would be nice if eslint gave us a way of getting
    // the environment directly.
    //
    // If this is testing context without path, ignore import.
    if (globalScope && globalScope.set.get("importScripts") && this.dirname) {
      let workerDetails = helpers.convertWorkerExpressionToGlobals(
        node,
        isGlobal,
        this.dirname
      );
      globals = globals.concat(workerDetails);
    }

    return globals;
  },
};

module.exports = {
  /**
   * Returns all globals for a given file. Recursively searches through
   * import-globals-from directives and also includes globals defined by
   * standard eslint directives.
   *
   * @param  {String} filePath
   *         The absolute path of the file to be parsed.
   * @param  {Object} astOptions
   *         Extra options to pass to the parser.
   * @return {Array}
   *         An array of objects that contain details about the globals:
   *         - {String} name
   *                    The name of the global.
   *         - {Boolean} writable
   *                     If the global is writeable or not.
   */
  getGlobalsForFile(filePath, astOptions = {}) {
    if (globalCache.has(filePath)) {
      return globalCache.get(filePath);
    }

    if (globalDiscoveryInProgressForFiles.has(filePath)) {
      // We're already processing this file, so return an empty set for now -
      // the initial processing will pick up on the globals for this file.
      return [];
    }
    globalDiscoveryInProgressForFiles.add(filePath);

    let content = fs.readFileSync(filePath, "utf8");

    // Parse the content into an AST
    let { ast, scopeManager, visitorKeys } = helpers.parseCode(
      content,
      astOptions
    );

    // Discover global declarations
    let globalScope = scopeManager.acquire(ast);

    let globals = Object.keys(globalScope.variables).map(v => ({
      name: globalScope.variables[v].name,
      writable: true,
    }));

    // Walk over the AST to find any of our custom globals
    let handler = new GlobalsForNode(filePath);

    helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
      if (type in handler) {
        let newGlobals = handler[type](node, parents, globalScope);
        globals.push.apply(globals, newGlobals);
      }
    });

    globalCache.set(filePath, globals);

    globalDiscoveryInProgressForFiles.delete(filePath);
    return globals;
  },

  /**
   * Returns all globals for a code.
   * This is only for testing.
   *
   * @param  {String} code
   *         The JS code
   * @param  {Object} astOptions
   *         Extra options to pass to the parser.
   * @return {Array}
   *         An array of objects that contain details about the globals:
   *         - {String} name
   *                    The name of the global.
   *         - {Boolean} writable
   *                     If the global is writeable or not.
   */
  getGlobalsForCode(code, astOptions = {}) {
    // Parse the content into an AST
    let { ast, scopeManager, visitorKeys } = helpers.parseCode(
      code,
      astOptions,
      { useBabel: false }
    );

    // Discover global declarations
    let globalScope = scopeManager.acquire(ast);

    let globals = Object.keys(globalScope.variables).map(v => ({
      name: globalScope.variables[v].name,
      writable: true,
    }));

    // Walk over the AST to find any of our custom globals
    let handler = new GlobalsForNode(null);

    helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
      if (type in handler) {
        let newGlobals = handler[type](node, parents, globalScope);
        globals.push.apply(globals, newGlobals);
      }
    });

    return globals;
  },

  /**
   * Returns all the globals for an html file that are defined by imported
   * scripts (i.e. <script src="foo.js">).
   *
   * This function will cache results for one html file only - we expect
   * this to be called sequentially for each chunk of a HTML file, rather
   * than chucks of different files in random order.
   *
   * @param  {String} filePath
   *         The absolute path of the file to be parsed.
   * @return {Array}
   *         An array of objects that contain details about the globals:
   *         - {String} name
   *                    The name of the global.
   *         - {Boolean} writable
   *                     If the global is writeable or not.
   */
  getImportedGlobalsForHTMLFile(filePath) {
    if (lastHTMLGlobals.filename === filePath) {
      return lastHTMLGlobals.globals;
    }

    let dir = path.dirname(filePath);
    let globals = [];

    let content = fs.readFileSync(filePath, "utf8");
    let scriptSrcs = [];

    // We use htmlparser as this ensures we find the script tags correctly.
    let parser = new htmlparser.Parser(
      {
        onopentag(name, attribs) {
          if (name === "script" && "src" in attribs) {
            scriptSrcs.push({
              src: attribs.src,
              type:
                "type" in attribs && attribs.type == "module"
                  ? "module"
                  : "script",
            });
          }
        },
      },
      {
        xmlMode: filePath.endsWith("xhtml"),
      }
    );

    parser.parseComplete(content);

    for (let script of scriptSrcs) {
      // Ensure that the script src isn't just "".
      if (!script.src) {
        continue;
      }
      let scriptName;
      if (script.src.includes("http:")) {
        // We don't handle this currently as the paths are complex to match.
      } else if (script.src.includes("chrome")) {
        // This is one way of referencing test files.
        script.src = script.src.replace("chrome://mochikit/content/", "/");
        scriptName = path.join(
          helpers.rootDir,
          "testing",
          "mochitest",
          script.src
        );
      } else if (script.src.includes("SimpleTest")) {
        // This is another way of referencing test files...
        scriptName = path.join(
          helpers.rootDir,
          "testing",
          "mochitest",
          script.src
        );
      } else if (script.src.startsWith("/tests/")) {
        scriptName = path.join(helpers.rootDir, script.src.substring(7));
      } else {
        // Fallback to hoping this is a relative path.
        scriptName = path.join(dir, script.src);
      }
      if (scriptName && fs.existsSync(scriptName)) {
        globals.push(
          ...module.exports.getGlobalsForFile(scriptName, {
            ecmaVersion: helpers.getECMAVersion(),
            sourceType: script.type,
          })
        );
      }
    }

    lastHTMLGlobals.filePath = filePath;
    return (lastHTMLGlobals.globals = globals);
  },

  /**
   * Intended to be used as-is for an ESLint rule that parses for globals in
   * the current file and recurses through import-globals-from directives.
   *
   * @param  {Object} context
   *         The ESLint parsing context.
   */
  getESLintGlobalParser(context) {
    let globalScope;

    let parser = {
      Program(node) {
        globalScope = context.getScope();
      },
    };
    let filename = context.getFilename();

    let extraHTMLGlobals = [];
    if (filename.endsWith(".html") || filename.endsWith(".xhtml")) {
      extraHTMLGlobals = module.exports.getImportedGlobalsForHTMLFile(filename);
    }

    // Install thin wrappers around GlobalsForNode
    let handler = new GlobalsForNode(helpers.getAbsoluteFilePath(context));

    for (let type of Object.keys(GlobalsForNode.prototype)) {
      parser[type] = function(node) {
        if (type === "Program") {
          globalScope = context.getScope();
          helpers.addGlobals(extraHTMLGlobals, globalScope);
        }
        let globals = handler[type](node, context.getAncestors(), globalScope);
        helpers.addGlobals(
          globals,
          globalScope,
          node.type !== "Program" && node
        );
      };
    }

    return parser;
  },
};