// @flow strict-local
import type {BundleGraph, PluginOptions, NamedBundle} from '@parcel/types';

import {
  PromiseQueue,
  relativeBundlePath,
  countLines,
  normalizeSeparators,
  relativePath,
} from '@parcel/utils';
import SourceMap from '@parcel/source-map';
import invariant from 'assert';
import path from 'path';
import fs from 'fs';
import {
  replaceScriptDependencies,
  getSpecifier,
  makeValidIdentifier,
  isValidIdentifier,
} from './utils';
import {helpers} from './helpers';

const PRELUDE = fs
  .readFileSync(path.join(__dirname, 'dev-prelude.js'), 'utf8')
  .trim()
  .replace(/;$/, '');

export class DevPackager {
  options: PluginOptions;
  bundleGraph: BundleGraph<NamedBundle>;
  bundle: NamedBundle;
  parcelRequireName: string;

  constructor(
    options: PluginOptions,
    bundleGraph: BundleGraph<NamedBundle>,
    bundle: NamedBundle,
    parcelRequireName: string,
  ) {
    this.options = options;
    this.bundleGraph = bundleGraph;
    this.bundle = bundle;
    this.parcelRequireName = parcelRequireName;
  }

  async package(): Promise<{|contents: string, map: ?SourceMap|}> {
    // Load assets
    let queue = new PromiseQueue({maxConcurrent: 32});
    this.bundle.traverseAssets(asset => {
      queue.add(async () => {
        let [code, mapBuffer] = await Promise.all([
          asset.getCode(),
          this.bundle.env.sourceMap && asset.getMapBuffer(),
        ]);
        return {code, mapBuffer};
      });
    });

    let results = await queue.run();

    let assets = '';
    let i = 0;
    let first = true;
    let map = new SourceMap(this.options.projectRoot);

    let prefix = this.getPrefix();
    let lineOffset = countLines(prefix);
    let script: ?{|code: string, mapBuffer: ?Buffer|} = null;
    let externals = new Set();

    let usedHelpers = 0;
    this.bundle.traverse(node => {
      let wrapped = first ? '' : ',';

      if (node.type === 'dependency') {
        let resolved = this.bundleGraph.getResolvedAsset(
          node.value,
          this.bundle,
        );
        if (resolved && resolved.type !== 'js') {
          // if this is a reference to another javascript asset, we should not include
          // its output, as its contents should already be loaded.
          invariant(!this.bundle.hasAsset(resolved));
          wrapped +=
            JSON.stringify(this.bundleGraph.getAssetPublicId(resolved)) +
            ':[function() {},{}]';
        } else {
          return;
        }
      }

      if (node.type === 'asset') {
        let asset = node.value;
        invariant(
          asset.type === 'js',
          'all assets in a js bundle must be js assets',
        );

        if (typeof asset.meta.usedHelpers === 'number') {
          usedHelpers |= asset.meta.usedHelpers;
        }

        // If this is the main entry of a script rather than a module, we need to hoist it
        // outside the bundle wrapper function so that its variables are exposed as globals.
        if (
          this.bundle.env.sourceType === 'script' &&
          asset === this.bundle.getMainEntry()
        ) {
          script = results[i++];
          return;
        }

        let deps = {};
        let dependencies = this.bundleGraph.getDependencies(asset);
        for (let dep of dependencies) {
          let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle);
          let specifier = getSpecifier(dep);
          if (this.bundleGraph.isDependencySkipped(dep)) {
            deps[specifier] = false;
          } else if (resolved) {
            deps[specifier] = this.bundleGraph.getAssetPublicId(resolved);
          } else {
            // An external module - map placeholder to original specifier.
            deps[specifier] = dep.specifier;
            if (
              this.bundle.env.outputFormat === 'esmodule' &&
              !dep.isOptional
            ) {
              externals.add(dep.specifier);
            }
          }
        }

        // Add dependencies for parcelRequire calls added by runtimes
        // so that the HMR runtime can correctly traverse parents.
        let hmrDeps = asset.meta.hmrDeps;
        if (this.options.hmrOptions && Array.isArray(hmrDeps)) {
          for (let id of hmrDeps) {
            invariant(typeof id === 'string');
            deps[id] = id;
          }
        }

        let {code, mapBuffer} = results[i];
        let output = code || '';
        wrapped +=
          JSON.stringify(this.bundleGraph.getAssetPublicId(asset)) +
          ':[function(require,module,exports,__globalThis) {\n' +
          output +
          '\n},';
        wrapped += JSON.stringify(deps);
        wrapped += ']';

        if (
          this.bundle.env.isNode() &&
          asset.meta.has_node_replacements === true
        ) {
          const relPath = normalizeSeparators(
            path.relative(
              this.bundle.target.distDir,
              path.dirname(asset.filePath),
            ),
          );
          wrapped = wrapped.replace('$parcel$dirnameReplace', relPath);
          wrapped = wrapped.replace('$parcel$filenameReplace', relPath);
        }

        if (this.bundle.env.sourceMap) {
          if (mapBuffer) {
            map.addBuffer(mapBuffer, lineOffset);
          } else {
            map.addEmptyMap(
              path
                .relative(this.options.projectRoot, asset.filePath)
                .replace(/\\+/g, '/'),
              output,
              lineOffset,
            );
          }

          lineOffset += countLines(output) + 1;
        }
        i++;
      }

      assets += wrapped;
      first = false;
    });

    let entries = this.bundle.getEntryAssets();
    let mainEntry = this.bundle.getMainEntry();
    if (
      (!this.isEntry() && this.bundle.env.outputFormat !== 'commonjs') ||
      this.bundle.env.sourceType === 'script'
    ) {
      // In async bundles we don't want the main entry to execute until we require it
      // as there might be dependencies in a sibling bundle that hasn't loaded yet.
      entries = entries.filter(a => a.id !== mainEntry?.id);
      mainEntry = null;
    }

    let load = '';
    if (usedHelpers & 4) {
      load += helpers.$parcel$import(this.bundle.env, this.bundle, new Set());
      load += 'newRequire.load = $parcel$import;\n';
    }

    if (usedHelpers & 8) {
      load += helpers.$parcel$resolve(this.bundle.env, this.bundle, new Set());
      load += 'newRequire.resolve = $parcel$resolve;\n';
    }

    if (usedHelpers & 16) {
      load += helpers.$parcel$extendImportMap(this.bundle.env);
      load += `newRequire.extendImportMap = $parcel$extendImportMap;\n`;
    }

    if (load) {
      usedHelpers |= 1 | 2;
      // Remove newlines to avoid messing up source maps
      prefix = prefix.replace('// INSERT_LOAD_HERE', load.replace(/\n/g, ''));
    }

    let externalImports = '';
    let externalMap = '{';
    let e = 0;
    for (let external of externals) {
      let name = `__parcelExternal${e++}`;
      externalImports += `import * as ${name} from ${JSON.stringify(
        external,
      )};\n`;
      externalMap += `${JSON.stringify(external)}: ${name},`;
    }
    externalMap += '}';

    let contents =
      externalImports +
      prefix +
      '({' +
      assets +
      '},' +
      JSON.stringify(
        entries.map(asset => this.bundleGraph.getAssetPublicId(asset)),
      ) +
      ', ' +
      JSON.stringify(
        mainEntry ? this.bundleGraph.getAssetPublicId(mainEntry) : null,
      ) +
      ', ' +
      JSON.stringify(this.parcelRequireName) +
      ', ' +
      externalMap;

    if (usedHelpers & 1) {
      // Generate a relative path from this bundle to the root of the dist dir.
      let distDir = relativePath(path.dirname(this.bundle.name), '');
      if (!distDir.endsWith('/')) {
        distDir += '/';
      }
      contents += ', ' + JSON.stringify(distDir);
    } else if (usedHelpers & (2 | 32)) {
      contents += ', null';
    }

    if (usedHelpers & 2) {
      // Ensure the public url always ends with a slash to code can easily join paths to it.
      let publicUrl = this.bundle.target.publicUrl;
      if (!publicUrl.endsWith('/')) {
        publicUrl += '/';
      }
      contents += ', ' + JSON.stringify(publicUrl);
    } else if (usedHelpers & 32) {
      contents += ', null';
    }

    if (usedHelpers & 32) {
      let code = helpers.$parcel$devServer(
        this.bundle.env,
        this.bundle,
        new Set(),
        this.options,
      );
      contents += ', ' + code.slice('var $parcel$devServer = '.length, -2);
    }

    contents += ')\n';

    // Add ES module exports from the entry asset.
    if (
      this.bundle.env.outputFormat === 'esmodule' &&
      mainEntry &&
      (this.bundle.env.isLibrary || !this.bundle.env.isBrowser())
    ) {
      let hasNamespace = mainEntry.symbols.hasExportSymbol('*');
      let importedSymbols = new Map();
      let exportedSymbols = new Map();
      for (let {
        exportAs,
        symbol,
        exportSymbol,
      } of this.bundleGraph.getExportedSymbols(mainEntry)) {
        if (typeof symbol === 'string') {
          if (hasNamespace && exportAs !== '*') {
            continue;
          }

          if (exportAs === '*') {
            exportAs = 'default';
          }

          let id = makeValidIdentifier(exportSymbol);
          if (id === 'default') {
            id = '_default';
          }
          importedSymbols.set(exportSymbol, id);
          exportedSymbols.set(exportAs, id);
        }
      }

      contents += 'let {';
      for (let [key, value] of importedSymbols) {
        contents += isValidIdentifier(key) ? key : JSON.stringify(key);
        if (value !== key) {
          contents += ': ';
          contents += value;
        }
        contents += ', ';
      }

      contents +=
        '} = ' +
        this.parcelRequireName +
        '(' +
        JSON.stringify(this.bundleGraph.getAssetPublicId(mainEntry)) +
        ');\n';
      contents += 'export {';

      for (let [exportAs, ident] of exportedSymbols) {
        contents += ident;
        if (exportAs !== ident) {
          contents += ' as ';
          contents += isValidIdentifier(exportAs)
            ? exportAs
            : JSON.stringify(exportAs);
        }
        contents += ', ';
      }

      contents += '};\n';
    }

    // The entry asset of a script bundle gets hoisted outside the bundle wrapper function
    // so that its variables become globals. We need to replace any require calls for
    // runtimes with a parcelRequire call.
    if (this.bundle.env.sourceType === 'script' && script) {
      let entryMap;
      let mapBuffer = script.mapBuffer;
      if (mapBuffer) {
        entryMap = new SourceMap(this.options.projectRoot, mapBuffer);
      }
      contents += replaceScriptDependencies(
        this.bundleGraph,
        this.bundle,
        script.code,
        entryMap,
        this.parcelRequireName,
      );
      if (this.bundle.env.sourceMap && entryMap) {
        map.addSourceMap(entryMap, lineOffset);
      }
    }

    return {
      contents,
      map,
    };
  }

  getPrefix(): string {
    let interpreter: ?string;
    let mainEntry = this.bundle.getMainEntry();
    if (mainEntry && this.isEntry() && !this.bundle.target.env.isBrowser()) {
      let _interpreter = mainEntry.meta.interpreter;
      invariant(_interpreter == null || typeof _interpreter === 'string');
      interpreter = _interpreter;
    }

    let importScripts = '';
    if (this.bundle.env.isWorker()) {
      let bundles = this.bundleGraph.getReferencedBundles(this.bundle);
      for (let b of bundles) {
        importScripts += `importScripts("${relativeBundlePath(
          this.bundle,
          b,
        )}");\n`;
      }
    } else if (this.bundle.env.isNode()) {
      let bundles = this.bundleGraph.getReferencedBundles(this.bundle, {
        includeInline: false,
      });
      for (let b of bundles) {
        if (b.type !== 'js') {
          continue;
        }
        if (this.bundle.env.outputFormat === 'esmodule') {
          importScripts += `import "${relativeBundlePath(this.bundle, b)}";\n`;
        } else {
          importScripts += `require("${relativeBundlePath(
            this.bundle,
            b,
          )}");\n`;
        }
      }
    }

    return (
      // If the entry asset included a hashbang, repeat it at the top of the bundle
      (interpreter != null ? `#!${interpreter}\n` : '') +
      importScripts +
      PRELUDE
    );
  }

  isEntry(): boolean {
    return (
      !this.bundleGraph.hasParentBundleOfType(this.bundle, 'js') ||
      this.bundle.env.isIsolated() ||
      this.bundle.bundleBehavior === 'isolated'
    );
  }
}
