const fs = require('@parcel/fs');
const Resolver = require('./Resolver');
const Parser = require('./Parser');
const WorkerFarm = require('@parcel/workers');
const Path = require('path');
const Bundle = require('./Bundle');
const Watcher = require('@parcel/watcher');
const FSCache = require('./FSCache');
const HMRServer = require('./HMRServer');
const Server = require('./Server');
const {EventEmitter} = require('events');
const logger = require('@parcel/logger');
const PackagerRegistry = require('./packagers');
const localRequire = require('./utils/localRequire');
const config = require('./utils/config');
const loadEnv = require('./utils/env');
const PromiseQueue = require('./utils/PromiseQueue');
const installPackage = require('./utils/installPackage');
const bundleReport = require('./utils/bundleReport');
const prettifyTime = require('./utils/prettifyTime');
const getRootDir = require('./utils/getRootDir');
const {glob, isGlob} = require('./utils/glob');

/**
 * The Bundler is the main entry point. It resolves and loads assets,
 * creates the bundle tree, and manages the worker farm, cache, and file watcher.
 */
class Bundler extends EventEmitter {
  constructor(entryFiles, options = {}) {
    super();

    entryFiles = this.normalizeEntries(entryFiles);
    this.watchedGlobs = entryFiles.filter(entry => isGlob(entry));
    this.entryFiles = this.findEntryFiles(entryFiles);
    this.options = this.normalizeOptions(options);

    this.resolver = new Resolver(this.options);
    this.parser = new Parser(this.options);
    this.packagers = new PackagerRegistry(this.options);
    this.cache = this.options.cache ? new FSCache(this.options) : null;
    this.delegate = options.delegate || {};
    this.bundleLoaders = {};

    this.addBundleLoader('wasm', {
      browser: require.resolve('./builtins/loaders/browser/wasm-loader'),
      node: require.resolve('./builtins/loaders/node/wasm-loader')
    });
    this.addBundleLoader('css', {
      browser: require.resolve('./builtins/loaders/browser/css-loader'),
      node: require.resolve('./builtins/loaders/node/css-loader')
    });
    this.addBundleLoader('js', {
      browser: require.resolve('./builtins/loaders/browser/js-loader'),
      node: require.resolve('./builtins/loaders/node/js-loader')
    });
    this.addBundleLoader('html', {
      browser: require.resolve('./builtins/loaders/browser/html-loader'),
      node: require.resolve('./builtins/loaders/node/html-loader')
    });

    this.pending = false;
    this.loadedAssets = new Map();
    this.watchedAssets = new Map();

    this.farm = null;
    this.watcher = null;
    this.hmr = null;
    this.bundleHashes = null;
    this.error = null;
    this.buildQueue = new PromiseQueue(this.processAsset.bind(this));
    this.rebuildTimeout = null;

    logger.setOptions(this.options);
  }

  normalizeEntries(entryFiles) {
    // Support passing a single file
    if (entryFiles && !Array.isArray(entryFiles)) {
      entryFiles = [entryFiles];
    }

    // If no entry files provided, resolve the entry point from the current directory.
    if (!entryFiles || entryFiles.length === 0) {
      entryFiles = [process.cwd()];
    }

    return entryFiles;
  }

  findEntryFiles(entryFiles) {
    // Match files as globs
    return entryFiles
      .reduce((p, m) => p.concat(glob.sync(m)), [])
      .map(f => Path.resolve(f));
  }

  normalizeOptions(options) {
    const isProduction =
      options.production || process.env.NODE_ENV === 'production';
    const publicURL = options.publicUrl || options.publicURL || '/';
    const watch =
      typeof options.watch === 'boolean' ? options.watch : !isProduction;
    const target = options.target || 'browser';
    const hmr =
      target === 'node'
        ? false
        : typeof options.hmr === 'boolean'
        ? options.hmr
        : watch;
    const scopeHoist =
      options.scopeHoist !== undefined ? options.scopeHoist : false;
    return {
      production: isProduction,
      outDir: Path.resolve(options.outDir || 'dist'),
      outFile: options.outFile || '',
      publicURL: publicURL,
      watch: watch,
      cache: typeof options.cache === 'boolean' ? options.cache : true,
      cacheDir: Path.resolve(options.cacheDir || '.cache'),
      killWorkers:
        typeof options.killWorkers === 'boolean' ? options.killWorkers : true,
      minify:
        typeof options.minify === 'boolean' ? options.minify : isProduction,
      target: target,
      bundleNodeModules:
        typeof options.bundleNodeModules === 'boolean'
          ? options.bundleNodeModules
          : target === 'browser',
      hmr: hmr,
      https: options.https || false,
      logLevel: isNaN(options.logLevel) ? 3 : options.logLevel,
      entryFiles: this.entryFiles,
      hmrPort: options.hmrPort || 0,
      rootDir: getRootDir(this.entryFiles),
      sourceMaps:
        (typeof options.sourceMaps === 'boolean' ? options.sourceMaps : true) &&
        !scopeHoist,
      hmrHostname:
        options.hmrHostname ||
        options.host ||
        (options.target === 'electron' ? 'localhost' : ''),
      detailedReport: options.detailedReport || false,
      global: options.global,
      autoinstall:
        typeof options.autoInstall === 'boolean'
          ? options.autoInstall
          : process.env.PARCEL_AUTOINSTALL === 'false'
          ? false
          : !isProduction,
      scopeHoist: scopeHoist,
      contentHash:
        typeof options.contentHash === 'boolean'
          ? options.contentHash
          : isProduction,
      throwErrors:
        typeof options.throwErrors === 'boolean' ? options.throwErrors : true
    };
  }

  addAssetType(extension, path) {
    if (typeof path !== 'string') {
      throw new Error('Asset type should be a module path.');
    }

    if (this.farm) {
      throw new Error('Asset types must be added before bundling.');
    }

    this.parser.registerExtension(extension, path);
  }

  addPackager(type, packager) {
    if (this.farm) {
      throw new Error('Packagers must be added before bundling.');
    }

    this.packagers.add(type, packager);
  }

  addBundleLoader(type, paths) {
    if (typeof paths === 'string') {
      paths = {node: paths, browser: paths};
    } else if (typeof paths !== 'object') {
      throw new Error('Bundle loaders should be an object.');
    }

    for (const target in paths) {
      if (target !== 'node' && target !== 'browser') {
        throw new Error(`Unknown bundle loader target "${target}".`);
      }

      if (typeof paths[target] !== 'string') {
        throw new Error('Bundle loader should be a string.');
      }
    }

    if (this.farm) {
      throw new Error('Bundle loaders must be added before bundling.');
    }

    this.bundleLoaders[type] = paths;
  }

  async loadPlugins() {
    let relative = Path.join(this.options.rootDir, 'index');
    let pkg = await config.load(relative, ['package.json']);
    if (!pkg) {
      return;
    }

    let lastDep;
    try {
      let deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
      for (let dep in deps) {
        lastDep = dep;
        const pattern = /^(@.*\/)?parcel-plugin-.+/;
        if (pattern.test(dep)) {
          let plugin = await localRequire(dep, relative);
          await plugin(this);
        }
      }
    } catch (err) {
      logger.warn(
        `Plugin ${lastDep} failed to initialize: ${err.stack ||
          err.message ||
          err}`
      );
    }
  }

  async bundle() {
    // If another bundle is already pending, wait for that one to finish and retry.
    if (this.pending) {
      return new Promise((resolve, reject) => {
        this.once('buildEnd', () => {
          this.bundle().then(resolve, reject);
        });
      });
    }

    let isInitialBundle = !this.entryAssets;
    let startTime = Date.now();
    let initialised = !isInitialBundle;
    this.pending = true;
    this.error = null;

    logger.clear();
    logger.progress('Building...');

    try {
      // Start worker farm, watcher, etc. if needed
      await this.start();

      // Emit start event, after bundler is initialised
      this.emit('buildStart', this.entryFiles);

      // If this is the initial bundle, ensure the output directory exists, and resolve the main asset.
      if (isInitialBundle) {
        await fs.mkdirp(this.options.outDir);

        this.entryAssets = new Set();
        for (let entry of this.entryFiles) {
          try {
            let asset = await this.resolveAsset(entry);
            this.buildQueue.add(asset);
            this.entryAssets.add(asset);
          } catch (err) {
            throw new Error(
              `Cannot resolve entry "${entry}" from "${this.options.rootDir}"`
            );
          }
        }

        if (this.entryAssets.size === 0) {
          throw new Error('No entries found.');
        }

        initialised = true;
      }

      // Build the queued assets.
      let loadedAssets = await this.buildQueue.run();

      // The changed assets are any that don't have a parent bundle yet
      // plus the ones that were in the build queue.
      let changedAssets = [...this.findOrphanAssets(), ...loadedAssets];

      // Invalidate bundles
      for (let asset of this.loadedAssets.values()) {
        asset.invalidateBundle();
      }

      logger.progress(`Producing bundles...`);

      // Create a root bundle to hold all of the entry assets, and add them to the tree.
      this.mainBundle = new Bundle();
      for (let asset of this.entryAssets) {
        this.createBundleTree(asset, this.mainBundle);
      }

      // If there is only one child bundle, replace the root with that bundle.
      if (this.mainBundle.childBundles.size === 1) {
        this.mainBundle = Array.from(this.mainBundle.childBundles)[0];
      }

      // Generate the final bundle names, and replace references in the built assets.
      let numBundles = this.bundleNameMap ? this.bundleNameMap.size : 0;
      this.bundleNameMap = this.mainBundle.getBundleNameMap(
        this.options.contentHash
      );

      for (let asset of changedAssets) {
        asset.replaceBundleNames(this.bundleNameMap);
      }

      // Emit an HMR update if this is not the initial bundle.
      let bundlesChanged = numBundles !== this.bundleNameMap.size;
      if (this.hmr && !isInitialBundle) {
        this.hmr.emitUpdate(changedAssets, bundlesChanged);
      }

      logger.progress(`Packaging...`);

      // Package everything up
      this.bundleHashes = await this.mainBundle.package(
        this,
        bundlesChanged ? null : this.bundleHashes
      );

      // Unload any orphaned assets
      this.unloadOrphanedAssets();

      let buildTime = Date.now() - startTime;
      let time = prettifyTime(buildTime);
      logger.success(`Built in ${time}.`);
      if (!this.watcher) {
        bundleReport(this.mainBundle, this.options.detailedReport);
      }

      this.emit('bundled', this.mainBundle);
      return this.mainBundle;
    } catch (err) {
      this.error = err;

      logger.error(err);

      this.emit('buildError', err);

      if (this.hmr) {
        this.hmr.emitError(err);
      }

      if (this.options.throwErrors && !this.hmr) {
        throw err;
      } else if (!this.options.watch || !initialised) {
        await this.stop();
        process.exit(1);
      }
    } finally {
      this.pending = false;
      this.emit('buildEnd');

      // If not in watch mode, stop the worker farm so we don't keep the process running.
      if (!this.watcher && this.options.killWorkers) {
        await this.stop();
      }
    }
  }

  async start() {
    if (this.farm) {
      return;
    }

    await this.loadPlugins();

    if (!this.options.env) {
      await loadEnv(Path.join(this.options.rootDir, 'index'));
      this.options.env = process.env;
    }

    this.options.extensions = Object.assign({}, this.parser.extensions);
    this.options.bundleLoaders = this.bundleLoaders;

    if (this.options.watch) {
      this.watcher = new Watcher();
      // Wait for ready event for reliable testing on watcher
      if (process.env.NODE_ENV === 'test' && !this.watcher.ready) {
        await new Promise(resolve => this.watcher.once('ready', resolve));
      }
      this.watchedGlobs.forEach(glob => {
        this.watcher.add(glob);
      });
      this.watcher.on('add', this.onAdd.bind(this));
      this.watcher.on('change', this.onChange.bind(this));
      this.watcher.on('unlink', this.onUnlink.bind(this));
    }

    if (this.options.hmr) {
      this.hmr = new HMRServer();
      this.options.hmrPort = await this.hmr.start(this.options);
    }

    this.farm = await WorkerFarm.getShared(this.options, {
      workerPath: require.resolve('./worker.js')
    });
  }

  async stop() {
    if (this.watcher) {
      await this.watcher.stop();
    }

    if (this.hmr) {
      this.hmr.stop();
    }

    // Watcher and hmr can cause workerfarm calls
    // keep this as last to prevent unwanted errors
    if (this.farm) {
      await this.farm.end();
    }
  }

  async getAsset(name, parent) {
    let asset = await this.resolveAsset(name, parent);
    this.buildQueue.add(asset);
    await this.buildQueue.run();
    return asset;
  }

  async resolveAsset(name, parent) {
    let {path} = await this.resolver.resolve(name, parent);
    return this.getLoadedAsset(path);
  }

  getLoadedAsset(path) {
    if (this.loadedAssets.has(path)) {
      return this.loadedAssets.get(path);
    }

    let asset = this.parser.getAsset(path, this.options);
    this.loadedAssets.set(path, asset);

    this.watch(path, asset);
    return asset;
  }

  async watch(path, asset) {
    if (!this.watcher) {
      return;
    }

    path = await fs.realpath(path);

    if (!this.watchedAssets.has(path)) {
      this.watcher.watch(path);
      this.watchedAssets.set(path, new Set());
    }
    this.watchedAssets.get(path).add(asset);
  }

  async unwatch(path, asset) {
    path = await fs.realpath(path);
    if (!this.watchedAssets.has(path)) {
      return;
    }

    let watched = this.watchedAssets.get(path);
    watched.delete(asset);

    if (watched.size === 0) {
      this.watchedAssets.delete(path);
      this.watcher.unwatch(path);
    }
  }

  async resolveDep(asset, dep, install = true) {
    try {
      if (dep.resolved) {
        return this.getLoadedAsset(dep.resolved);
      }

      return await this.resolveAsset(dep.name, asset.name);
    } catch (err) {
      // If the dep is optional, return before we throw
      if (dep.optional) {
        return;
      }

      if (err.code === 'MODULE_NOT_FOUND') {
        let isLocalFile = /^[/~.]/.test(dep.name);
        let fromNodeModules = asset.name.includes(
          `${Path.sep}node_modules${Path.sep}`
        );

        if (
          !isLocalFile &&
          !fromNodeModules &&
          this.options.autoinstall &&
          install
        ) {
          return this.installDep(asset, dep);
        }

        err.message = `Cannot resolve dependency '${dep.name}'`;
        if (isLocalFile) {
          const absPath = Path.resolve(Path.dirname(asset.name), dep.name);
          err.message += ` at '${absPath}'`;
        }

        await this.throwDepError(asset, dep, err);
      }

      throw err;
    }
  }

  async installDep(asset, dep) {
    // Check if module exists, prevents useless installs
    let resolved = await this.resolver.resolveModule(dep.name, asset.name);

    // If the module resolved (i.e. wasn't a local file), but the module directory wasn't found, install it.
    if (resolved.moduleName && !resolved.moduleDir) {
      try {
        await installPackage(resolved.moduleName, asset.name, {
          saveDev: false
        });
      } catch (err) {
        await this.throwDepError(asset, dep, err);
      }
    }

    return this.resolveDep(asset, dep, false);
  }

  async throwDepError(asset, dep, err) {
    // Generate a code frame where the dependency was used
    if (dep.loc) {
      await asset.loadIfNeeded();
      err.loc = dep.loc;
      err = asset.generateErrorMessage(err);
    }

    err.fileName = asset.name;
    throw err;
  }

  async processAsset(asset, isRebuild) {
    if (isRebuild) {
      asset.invalidate();
      if (this.cache) {
        this.cache.invalidate(asset.name);
      }
    }

    await this.loadAsset(asset);
  }

  async loadAsset(asset) {
    if (asset.processed) {
      return;
    }

    if (!this.error) {
      logger.progress(`Building ${asset.basename}...`);
    }

    // Mark the asset processed so we don't load it twice
    asset.processed = true;

    // First try the cache, otherwise load and compile in the background
    asset.startTime = Date.now();
    let processed = this.cache && (await this.cache.read(asset.name));
    let cacheMiss = false;
    if (!processed || asset.shouldInvalidate(processed.cacheData)) {
      processed = await this.farm.run(asset.name);
      cacheMiss = true;
    }

    asset.endTime = Date.now();
    asset.buildTime = asset.endTime - asset.startTime;
    asset.id = processed.id;
    asset.generated = processed.generated;
    asset.sourceMaps = processed.sourceMaps;
    asset.hash = processed.hash;
    asset.cacheData = processed.cacheData;

    // Call the delegate to get implicit dependencies
    let dependencies = processed.dependencies;
    if (this.delegate.getImplicitDependencies) {
      let implicitDeps = await this.delegate.getImplicitDependencies(asset);
      if (implicitDeps) {
        dependencies = dependencies.concat(implicitDeps);
      }
    }

    // Resolve and load asset dependencies
    let assetDeps = await Promise.all(
      dependencies.map(async dep => {
        if (dep.includedInParent) {
          // This dependency is already included in the parent's generated output,
          // so no need to load it. We map the name back to the parent asset so
          // that changing it triggers a recompile of the parent.
          this.watch(dep.name, asset);
        } else {
          dep.parent = asset.name;
          let assetDep = await this.resolveDep(asset, dep);
          if (assetDep) {
            await this.loadAsset(assetDep);
          }

          return assetDep;
        }
      })
    );

    // If there was a processing error, re-throw now that we've set up
    // depdenency watchers. This keeps reloading working if there is an
    // error in a dependency not directly handled by Parcel.
    if (processed.error !== null) {
      throw processed.error;
    }

    // Store resolved assets in their original order
    dependencies.forEach((dep, i) => {
      asset.dependencies.set(dep.name, dep);
      let assetDep = assetDeps[i];
      if (assetDep) {
        asset.depAssets.set(dep, assetDep);
        dep.resolved = assetDep.name;
      }
    });

    logger.verbose(`Built ${asset.relativeName}...`);

    if (this.cache && cacheMiss) {
      this.cache.write(asset.name, processed);
    }
  }

  createBundleTree(asset, bundle, dep, parentBundles = new Set()) {
    if (dep) {
      asset.parentDeps.add(dep);
    }

    if (asset.parentBundle && !bundle.isolated) {
      // If the asset is already in a bundle, it is shared. Move it to the lowest common ancestor.
      if (asset.parentBundle !== bundle) {
        let commonBundle = bundle.findCommonAncestor(asset.parentBundle);

        // If the common bundle's type matches the asset's, move the asset to the common bundle.
        // Otherwise, proceed with adding the asset to the new bundle below.
        if (asset.parentBundle.type === commonBundle.type) {
          this.moveAssetToBundle(asset, commonBundle);
          return;
        }
      } else {
        return;
      }

      // Detect circular bundles
      if (parentBundles.has(asset.parentBundle)) {
        return;
      }
    }

    // Skip this asset if it's already in the bundle.
    // Happens when circular dependencies are placed in an isolated bundle (e.g. a worker).
    if (bundle.isolated && bundle.assets.has(asset)) {
      return;
    }

    let isEntryAsset =
      asset.parentBundle && asset.parentBundle.entryAsset === asset;

    // If the asset generated a representation for the parent bundle type, and this
    // is not an async import, add it to the current bundle
    if (bundle.type && asset.generated[bundle.type] != null && !dep.dynamic) {
      bundle.addAsset(asset);
    }

    if ((dep && dep.dynamic) || !bundle.type) {
      // If the asset is already the entry asset of a bundle, don't create a duplicate.
      if (isEntryAsset) {
        return;
      }

      // Create a new bundle for dynamic imports
      bundle = bundle.createChildBundle(asset, dep);
    } else if (
      asset.type &&
      !this.packagers.get(asset.type).shouldAddAsset(bundle, asset)
    ) {
      // If the asset is already the entry asset of a bundle, don't create a duplicate.
      if (isEntryAsset) {
        return;
      }

      // No packager is available for this asset type, or the packager doesn't support
      // combining this asset into the bundle. Create a new bundle with only this asset.
      bundle = bundle.createSiblingBundle(asset, dep);
    } else {
      // Add the asset to the common bundle of the asset's type
      bundle.getSiblingBundle(asset.type).addAsset(asset);
    }

    // Add the asset to sibling bundles for each generated type
    if (asset.type && asset.generated[asset.type]) {
      for (let t in asset.generated) {
        if (asset.generated[t]) {
          bundle.getSiblingBundle(t).addAsset(asset);
        }
      }
    }

    asset.parentBundle = bundle;
    parentBundles.add(bundle);

    for (let [dep, assetDep] of asset.depAssets) {
      this.createBundleTree(assetDep, bundle, dep, parentBundles);
    }

    parentBundles.delete(bundle);
    return bundle;
  }

  moveAssetToBundle(asset, commonBundle) {
    // Never move the entry asset of a bundle, as it was explicitly requested to be placed in a separate bundle.
    if (
      asset.parentBundle.entryAsset === asset ||
      asset.parentBundle === commonBundle
    ) {
      return;
    }

    for (let bundle of Array.from(asset.bundles)) {
      if (!bundle.isolated) {
        bundle.removeAsset(asset);
      }
      commonBundle.getSiblingBundle(bundle.type).addAsset(asset);
    }

    let oldBundle = asset.parentBundle;
    asset.parentBundle = commonBundle;

    // Move all dependencies as well
    for (let child of asset.depAssets.values()) {
      if (child.parentBundle === oldBundle) {
        this.moveAssetToBundle(child, commonBundle);
      }
    }
  }

  *findOrphanAssets() {
    for (let asset of this.loadedAssets.values()) {
      if (!asset.parentBundle) {
        yield asset;
      }
    }
  }

  unloadOrphanedAssets() {
    for (let asset of this.findOrphanAssets()) {
      this.unloadAsset(asset);
    }
  }

  unloadAsset(asset) {
    this.loadedAssets.delete(asset.name);
    if (this.watcher) {
      this.unwatch(asset.name, asset);

      // Unwatch all included dependencies that map to this asset
      for (let dep of asset.dependencies.values()) {
        if (dep.includedInParent) {
          this.unwatch(dep.name, asset);
        }
      }
    }
  }

  async onAdd(path) {
    path = Path.join(process.cwd(), path);

    let asset = this.parser.getAsset(path, this.options);
    this.loadedAssets.set(path, asset);

    this.entryAssets.add(asset);

    await this.watch(path, asset);
    this.onChange(path);
  }

  async onChange(path) {
    // The path to the newly-added items are not absolute.
    if (!Path.isAbsolute(path)) {
      path = Path.resolve(process.cwd(), path);
    }

    let assets = this.watchedAssets.get(path);
    if (!assets || !assets.size) {
      return;
    }

    logger.clear();
    logger.progress(`Building ${Path.basename(path)}...`);

    // Add the asset to the rebuild queue, and reset the timeout.
    for (let asset of assets) {
      this.buildQueue.add(asset, true);
    }

    clearTimeout(this.rebuildTimeout);

    this.rebuildTimeout = setTimeout(async () => {
      await this.bundle();
    }, 100);
  }

  async onUnlink(path) {
    // The path to the newly-added items are not absolute.
    if (!Path.isAbsolute(path)) {
      path = Path.resolve(process.cwd(), path);
    }

    let asset = this.getLoadedAsset(path);
    this.entryAssets.delete(asset);
    this.unloadAsset(asset);

    this.bundle();
  }

  middleware() {
    this.bundle();
    return Server.middleware(this);
  }

  async serve(port = 1234, https = false, host) {
    this.server = await Server.serve(this, port, host, https);
    try {
      await this.bundle();
    } catch (e) {
      // ignore: server can still work with errored bundler
    }
    return this.server;
  }
}

module.exports = Bundler;
Bundler.Asset = require('./Asset');
Bundler.Packager = require('./packagers/Packager');
