// @ts-check
/** @typedef {import("webpack/lib/Compilation.js")} WebpackCompilation */
/** @typedef {import("webpack/lib/Compiler.js")} WebpackCompiler */
/** @typedef {import("webpack/lib/Chunk.js")} WebpackChunk */
'use strict';
/**
 * @file
 * This file uses webpack to compile a template with a child compiler.
 *
 * [TEMPLATE] -> [JAVASCRIPT]
 *
 */
'use strict';
const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin');
const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin');
const LoaderTargetPlugin = require('webpack/lib/LoaderTargetPlugin');
const LibraryTemplatePlugin = require('webpack/lib/LibraryTemplatePlugin');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');

/**
 * The HtmlWebpackChildCompiler is a helper to allow resusing one childCompiler
 * for multile HtmlWebpackPlugin instances to improve the compilation performance.
 */
class HtmlWebpackChildCompiler {
  constructor () {
    /**
     * @type {string[]} templateIds
     * The template array will allow us to keep track which input generated which output
     */
    this.templates = [];
    /**
     * @type {Promise<{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}>}
     */
    this.compilationPromise; // eslint-disable-line
    /**
     * @type {number}
     */
    this.compilationStartedTimestamp; // eslint-disable-line
    /**
     * @type {number}
     */
    this.compilationEndedTimestamp; // eslint-disable-line
    /**
     * All file dependencies of the child compiler
     * @type {string[]}
     */
    this.fileDependencies = [];
  }

  /**
   * Add a templatePath to the child compiler
   * The given template will be compiled by `compileTemplates`
   * @param {string} template - The webpack path to the template e.g. `'!!html-loader!index.html'`
   * @returns {boolean} true if the template is new
   */
  addTemplate (template) {
    const templateId = this.templates.indexOf(template);
    // Don't add the template to the compiler if a similar template was already added
    if (templateId !== -1) {
      return false;
    }
    // A child compiler can compile only once
    // throw an error if a new template is added after the compilation started
    if (this.isCompiling()) {
      throw new Error('New templates can only be added before `compileTemplates` was called.');
    }
    // Add the template to the childCompiler
    this.templates.push(template);
    // Mark the cache invalid
    return true;
  }

  /**
   * Returns true if the childCompiler is currently compiling
   * @retuns {boolean}
   */
  isCompiling () {
    return !this.didCompile() && this.compilationStartedTimestamp !== undefined;
  }

  /**
   * Returns true if the childCOmpiler is done compiling
   */
  didCompile () {
    return this.compilationEndedTimestamp !== undefined;
  }

  /**
   * This function will start the template compilation
   * once it is started no more templates can be added
   *
   * @param {WebpackCompilation} mainCompilation
   * @returns {Promise<{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}>}
   */
  compileTemplates (mainCompilation) {
    // To prevent multiple compilations for the same template
    // the compilation is cached in a promise.
    // If it already exists return
    if (this.compilationPromise) {
      return this.compilationPromise;
    }

    // The entry file is just an empty helper as the dynamic template
    // require is added in "loader.js"
    const outputOptions = {
      filename: '__child-[name]',
      publicPath: mainCompilation.outputOptions.publicPath
    };
    const compilerName = 'HtmlWebpackCompiler';
    // Create an additional child compiler which takes the template
    // and turns it into an Node.JS html factory.
    // This allows us to use loaders during the compilation
    const childCompiler = mainCompilation.createChildCompiler(compilerName, outputOptions);
    // The file path context which webpack uses to resolve all relative files to
    childCompiler.context = mainCompilation.compiler.context;
    // Compile the template to nodejs javascript
    new NodeTemplatePlugin(outputOptions).apply(childCompiler);
    new NodeTargetPlugin().apply(childCompiler);
    new LibraryTemplatePlugin('HTML_WEBPACK_PLUGIN_RESULT', 'var').apply(childCompiler);
    new LoaderTargetPlugin('node').apply(childCompiler);

    // Add all templates
    this.templates.forEach((template, index) => {
      new SingleEntryPlugin(childCompiler.context, template, `HtmlWebpackPlugin_${index}`).apply(childCompiler);
    });

    this.compilationStartedTimestamp = new Date().getTime();
    this.compilationPromise = new Promise((resolve, reject) => {
      childCompiler.runAsChild((err, entries, childCompilation) => {
        // Extract templates
        const compiledTemplates = entries
          ? extractHelperFilesFromCompilation(mainCompilation, childCompilation, outputOptions.filename, entries)
          : [];
        // Extract file dependencies
        if (entries) {
          this.fileDependencies = Array.from(childCompilation.fileDependencies);
        }
        // Reject the promise if the childCompilation contains error
        if (childCompilation && childCompilation.errors && childCompilation.errors.length) {
          const errorDetails = childCompilation.errors.map(error => error.message + (error.error ? ':\n' + error.error : '')).join('\n');
          reject(new Error('Child compilation failed:\n' + errorDetails));
          return;
        }
        // Reject if the error object contains errors
        if (err) {
          reject(err);
          return;
        }
        /**
         * @type {{[templatePath: string]: { content: string, hash: string, entry: WebpackChunk }}}
         */
        const result = {};
        compiledTemplates.forEach((templateSource, entryIndex) => {
          // The compiledTemplates are generated from the entries added in
          // the addTemplate function.
          // Therefore the array index of this.templates should be the as entryIndex.
          result[this.templates[entryIndex]] = {
            content: templateSource,
            hash: childCompilation.hash,
            entry: entries[entryIndex]
          };
        });
        this.compilationEndedTimestamp = new Date().getTime();
        resolve(result);
      });
    });

    return this.compilationPromise;
  }
}

/**
 * The webpack child compilation will create files as a side effect.
 * This function will extract them and clean them up so they won't be written to disk.
 *
 * Returns the source code of the compiled templates as string
 *
 * @returns Array<string>
 */
function extractHelperFilesFromCompilation (mainCompilation, childCompilation, filename, childEntryChunks) {
  const helperAssetNames = childEntryChunks.map((entryChunk, index) => {
    return mainCompilation.mainTemplate.getAssetPath(filename, {
      hash: childCompilation.hash,
      chunk: entryChunk,
      name: `HtmlWebpackPlugin_${index}`
    });
  });

  helperAssetNames.forEach((helperFileName) => {
    delete mainCompilation.assets[helperFileName];
  });

  const helperContents = helperAssetNames.map((helperFileName) => {
    return childCompilation.assets[helperFileName].source();
  });

  return helperContents;
}

/**
 * @type {WeakMap<WebpackCompiler, HtmlWebpackChildCompiler>}}
 */
const childCompilerCache = new WeakMap();

/**
 * Get child compiler from cache or a new child compiler for the given mainCompilation
 *
 * @param {WebpackCompiler} mainCompiler
 */
function getChildCompiler (mainCompiler) {
  const cachedChildCompiler = childCompilerCache.get(mainCompiler);
  if (cachedChildCompiler) {
    return cachedChildCompiler;
  }
  const newCompiler = new HtmlWebpackChildCompiler();
  childCompilerCache.set(mainCompiler, newCompiler);
  return newCompiler;
}

/**
 * Remove the childCompiler from the cache
 *
 * @param {WebpackCompiler} mainCompiler
 */
function clearCache (mainCompiler) {
  const childCompiler = getChildCompiler(mainCompiler);
  // If this childCompiler was already used
  // remove the entire childCompiler from the cache
  if (childCompiler.isCompiling() || childCompiler.didCompile()) {
    childCompilerCache.delete(mainCompiler);
  }
}

/**
 * Register a template for the current main compiler
 * @param {WebpackCompiler} mainCompiler
 * @param {string} templatePath
 */
function addTemplateToCompiler (mainCompiler, templatePath) {
  const childCompiler = getChildCompiler(mainCompiler);
  const isNew = childCompiler.addTemplate(templatePath);
  if (isNew) {
    clearCache(mainCompiler);
  }
}

/**
 * Starts the compilation for all templates.
 * This has to be called once all templates where added.
 *
 * If this function is called multiple times it will use a cache inside
 * the childCompiler
 *
 * @param {string} templatePath
 * @param {string} outputFilename
 * @param {WebpackCompilation} mainCompilation
 */
function compileTemplate (templatePath, outputFilename, mainCompilation) {
  const childCompiler = getChildCompiler(mainCompilation.compiler);
  return childCompiler.compileTemplates(mainCompilation).then((compiledTemplates) => {
    if (!compiledTemplates[templatePath]) console.log(Object.keys(compiledTemplates), templatePath);
    const compiledTemplate = compiledTemplates[templatePath];
    // Replace [hash] placeholders in filename
    const outputName = mainCompilation.mainTemplate.getAssetPath(outputFilename, {
      hash: compiledTemplate.hash,
      chunk: compiledTemplate.entry
    });
    return {
      // Hash of the template entry point
      hash: compiledTemplate.hash,
      // Output name
      outputName: outputName,
      // Compiled code
      content: compiledTemplate.content
    };
  });
}

/**
 * Return all file dependencies of the last child compilation
 *
 * @param {WebpackCompiler} compiler
 * @returns {Array<string>}
 */
function getFileDependencies (compiler) {
  const childCompiler = getChildCompiler(compiler);
  return childCompiler.fileDependencies;
}

/**
 * @type {WeakMap<WebpackCompilation, WeakMap<HtmlWebpackChildCompiler, boolean>>}}
 */
const hasOutdatedCompilationDependenciesMap = new WeakMap();
/**
 * Returns `true` if the file dependencies of the current childCompiler
 * for the given mainCompilation are outdated.
 *
 * Uses the `hasOutdatedCompilationDependenciesMap` cache if possible.
 *
 * @param {WebpackCompilation} mainCompilation
 * @returns {boolean}
 */
function hasOutDatedTemplateCache (mainCompilation) {
  const childCompiler = getChildCompiler(mainCompilation.compiler);
  /**
   * @type {WeakMap<HtmlWebpackChildCompiler, boolean>|undefined}
   */
  let hasOutdatedChildCompilerDependenciesMap = hasOutdatedCompilationDependenciesMap.get(mainCompilation);
  // Create map for childCompiler if none exist
  if (!hasOutdatedChildCompilerDependenciesMap) {
    hasOutdatedChildCompilerDependenciesMap = new WeakMap();
    hasOutdatedCompilationDependenciesMap.set(mainCompilation, hasOutdatedChildCompilerDependenciesMap);
  }
  // Try to get the `checkChildCompilerCache` result from cache
  let isOutdated = hasOutdatedChildCompilerDependenciesMap.get(childCompiler);
  if (isOutdated !== undefined) {
    return isOutdated;
  }
  // If `checkChildCompilerCache` has never been called for the given
  // `mainCompilation` and `childCompiler` combination call it:
  isOutdated = isChildCompilerCacheOutdated(mainCompilation, childCompiler);
  hasOutdatedChildCompilerDependenciesMap.set(childCompiler, isOutdated);
  return isOutdated;
}

/**
 * Returns `true` if the file dependencies of the given childCompiler are outdated.
 *
 * @param {WebpackCompilation} mainCompilation
 * @param {HtmlWebpackChildCompiler} childCompiler
 * @returns {boolean}
 */
function isChildCompilerCacheOutdated (mainCompilation, childCompiler) {
  // If the compilation was never run there is no invalid cache
  if (!childCompiler.compilationStartedTimestamp) {
    return false;
  }
  // Check if any dependent file was changed after the last compilation
  const fileTimestamps = mainCompilation.fileTimestamps;
  const isCacheOutOfDate = childCompiler.fileDependencies.some((fileDependency) => {
    const timestamp = fileTimestamps.get(fileDependency);
    // If the timestamp is not known the file is new
    // If the timestamp is larger then the file has changed
    // Otherwise the file is still the same
    return !timestamp || timestamp > childCompiler.compilationStartedTimestamp;
  });
  return isCacheOutOfDate;
}

module.exports = {
  addTemplateToCompiler,
  compileTemplate,
  hasOutDatedTemplateCache,
  clearCache,
  getFileDependencies
};