#!/usr/bin/env node
/**
 * @license
 * Copyright 2018 The Closure Library Authors. All Rights Reserved.
 *
 * 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.
 */

/**
 * @fileoverview Utility to generate Closure Dependency files that are used
 * when using Closure Library's debug code loader in a web browser.
 */

const depFile = require('../lib/depfile');
const depGraph = require('../lib/depgraph');
const fs = require('fs').promises;
const minimatch = require('minimatch');
const parser = require('../lib/parser');
const path = require('path');
const process = require('process');
const yargs = require('yargs');

/**
 * @typedef {{
 *   file: !Array<string>,
 *   root: !Array<string>,
 *   exclude: !Array<string>,
 *   closurePath: (string|undefined),
 *   validate: boolean,
 *   mergeDeps: boolean,
 * }}
 */
let Arguments;

/**
 * @param {string|undefined} args
 * @return {!Arguments}
 */
function parseArgs(args) {
  return yargs
      .command('*', 'Utility for Closure Library dependency calculation.')
      .option('file', {
        alias: 'f',
        default: [],
        array: true,
        description:
            'One or more input files to calculate dependencies for. The ' +
            'namespaces in these files will be combined with those given ' +
            'with the --root flag to form the set of namespaces to find ' +
            'dependencies for. If this is a file that contains repeated ' +
            'calls to goog.addDependency, then the calls will be used as ' +
            'forward declarations. e.g. if you include Closure Library\'s ' +
            'deps.js file, then there is no need to include ' +
            'closure/goog/array/array.js as an input file as well. ' +
            'If you wish to merge these deps files in the output, see the ' +
            '--merge-deps option.',
      })
      .option('root', {
        alias: 'r',
        default: [],
        array: true,

        description:
            'Directories to scan for JavaScript (.js) files to include as ' +
            'inputs. These directories are scanned recursively.',
      })
      .option('exclude', {
        alias: 'e',
        default: [],
        array: true,
        description:
            'One or more path globs to ignore. Useful when combined with the ' +
            '--root flag to ignore specific subfiles or subdirectories.'
      })
      .option('closure-path', {
        default: undefined,
        string: true,
        description:
            'Path to the Closure Library (folder containing the base.js ' +
            'file). Required unless the path can be determined ' +
            'automatically by base.js ' +
            'being included among the input files.',
      })
      .option('validate', {
        default: true,
        boolean: true,
        description:
            'Enables validation the dependency graph before generating the ' +
            'dependency file.'
      })
      .option('merge-deps', {
        default: false,
        boolean: true,
        description: 'If true, then any deps files (files with calls to ' +
            'goog.addDependency) included in the input wll be also included ' +
            'in the output.'
      })
      .parse(args);
}

/**
 * Resolves the given path against the working directory.
 * @param {string} p
 * @return {string}
 */
function resolve(p) {
  return path.resolve(process.cwd(), p);
}

/**
 * Returns whether the given path matches at least one of the given globs.
 * @param {!Array<string>} globs The globs against which to match.
 * @param {string} path The path to match.
 * @return {boolean} Whether the path matches at least one of the globs.
 */
function globMatch(globs, path) {
  return globs.some(glob => minimatch(path, glob));
}

/**
 * @param {string} pathToScan
 * @param {!Array<string>} excludedGlobs
 * @return {!Promise<!Array<string>>}
 */
async function findAllJsFiles(pathToScan, excludedGlobs) {
  if (globMatch(excludedGlobs, pathToScan)) {
    return [];
  }

  const stats = await fs.stat(pathToScan);

  if (stats.isDirectory()) {
    let allfiles = [];
    const files = await fs.readdir(pathToScan);
    const allFilePromises = files.map(
        file => findAllJsFiles(path.join(pathToScan, file), excludedGlobs));
    for (const p of allFilePromises) {
      const subFiles = await p;
      allfiles = allfiles.concat(subFiles);
    }
    return allfiles;
  } else if (stats.isFile()) {
    if (pathToScan.endsWith('.js')) {
      return [pathToScan];
    }
  }

  return [];
}

/**
 * Finds or creates a dependency for Closure's base.js. This also determines the
 * path to Closure Library.
 *
 * @param {!Array<!depGraph.Dependency>} deps
 * @param {!Arguments} args
 * @return {!depGraph.Dependency}
 */
async function getClosureDep(deps, args) {
  // First, try to identify Closure's base.js among the input files. This will
  // help us get the path to Closure.
  // We only consider base.js dependencies that were specified in the command
  // line as input files; in other words, we exclude dependencies that come from
  // dependency (deps.js) files. The reason for this is two-fold:
  // (1) If we find base.js via a dependency file, it may not help us determine
  //     the path to Closure Library, For example, Closure Library's own deps.js
  //     file only lists the relative path from Closure Library to base.js,
  //     which is always "base.js".
  // (2) We aren't guaranteed to be able to read a file that is only referenced
  //     in a dependency file.
  let baseJsInputDependency = null;
  const depsCalledBaseJs = deps.filter(
      d => !d.isParsedFromDepsFile() && d.path.endsWith(`${path.sep}base.js`));
  for (const dep of depsCalledBaseJs) {
    const contents = await fs.readFile(dep.path, 'utf8');
    // We assume that the string '@provideGoog' exists in the file iff the file
    // is Closure's base.js.
    if (contents.indexOf('@provideGoog') !== -1) {
      baseJsInputDependency = dep;
      break;
    }
  }

  // It's OK if we didn't find base.js among the input files, as long as
  // --closure-path is specified.
  // Here, ensure that the path to Closure Library is specified as *exactly* one
  // of (1) the --closure-path argument, or (2) the path to the input base.js.
  if (!args.closurePath && !baseJsInputDependency) {
    throw new Error(
        'Could not find path to Closure. Closure\'s base.js either needs to ' +
        'be included or --closure-path provided.');
  } else if (args.closurePath && baseJsInputDependency) {
    throw new Error(
        'Both --closure-path and Closure\'s base.js file should not be ' +
        'inputs.');
  }

  // Now that we know the path to Closure Library, we can return the dependency
  // for base.js.
  let closureDep = null;
  if (args.closurePath) {
    // Having --closure-path argument set implies that base.js is not among the
    // input files, as we validated earlier in this function.
    // Therefore, either base.js is included via a dependency file (in which
    // case, simply set the path to closure in the corresponding Dependency
    // object), or it's not included as a dependency at all (in which case, add
    // it).
    const baseJsFromDepsFile = deps.find(
        d => d.isParsedFromDepsFile() && d.closureRelativePath === 'base.js');
    if (baseJsFromDepsFile) {
      baseJsFromDepsFile.setClosurePath(args.closurePath);
      closureDep = baseJsFromDepsFile;
    } else {
      closureDep = new depGraph.Dependency(
          depGraph.DependencyType.SCRIPT,
          path.join(args.closurePath, 'base.js'), [], []);
      deps.push(closureDep);
    }
  } else {
    // We found base.js among the input files earlier, so simply return that.
    closureDep = baseJsInputDependency;
  }

  return closureDep;
}

/**
 * @param {!Array<string>=} opt_args
 * @return {!Promise<{errors:!Array<!ParseError>,text:(string|undefined)}>}
 */
async function main(opt_args) {
  const args = parseArgs(opt_args);

  if (!args.file && !args.root) {
    console.error('Must supply inputs and/or roots.');
    yargs.showHelp();
    process.exit(1);
  }

  const sources = new Set((args.file || []).map(resolve));
  const roots = (args.root || []).map(resolve);
  const excluded = [...new Set(args.exclude || [])].map(resolve);

  const allFiles =
      await Promise.all(roots.map(r => findAllJsFiles(r, excluded)));
  for (const files of allFiles) {
    for (const file of files) {
      sources.add(file);
    }
  }

  const results = await Promise.all([...sources].map(parser.parseFileAsync));
  let fatal = false;
  const errors = [].concat.apply([], results.map(r => r.errors));

  for (const error of errors) {
    fatal = fatal || error.fatal;
  }

  if (fatal) {
    return {errors};
  }

  const deps = [];
  const depsFromDepFiles = new Set();

  for (const result of results) {
    for (const dep of result.dependencies) {
      if (result.isFromDepsFile) {
        depsFromDepFiles.add(dep);
      }
      deps.push(dep);
    }
  }

  const closureDep = await getClosureDep(deps, args);
  const closurePath = path.dirname(closureDep.path);

  // Update the path to closure for any files that we don't know the full path
  // of (parsed from a goog.addDependency call).
  for (const dep of deps) {
    dep.setClosurePath(closurePath);
  }

  if (args.validate) {
    new depGraph.Graph(deps).validate();
  }

  const depsToWrite = args.mergeDeps
      ? deps
      // `deps` - `depsFromFiles`.
      : deps.filter(d => !depsFromDepFiles.has(d));

  const text = depFile.getDepFileText(closurePath, depsToWrite);
  return {errors, text};
}

if (require.main == module) {
  (async () => {
    // Log any uncaught exceptions as these are frowned upon in Node.
    let fatal = false;
    try {
      const result = await main();
      for (const error of result.errors) {
        console.error(error.toString());
        fatal = fatal || error.fatal;
      }
      if (result.text) {
        console.log(result.text);
      }
    } catch (e) {
      console.error(e);
      fatal = true;
    }
    if (fatal) process.exit(1);
  })();
}

module.exports.execute = main;
