/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * @flow
 */

'use strict';

const AssetServer = require('../AssetServer');
const getPlatformExtension = require('../node-haste').getPlatformExtension;
const Bundler = require('../Bundler');
const MultipartResponse = require('./MultipartResponse');
const ProgressBar = require('progress');
const SourceMapConsumer = require('source-map').SourceMapConsumer;

const declareOpts = require('../lib/declareOpts');
const defaults = require('../../../defaults');
const mime = require('mime-types');
const path = require('path');
const url = require('url');

const debug = require('debug')('ReactNativePackager:Server');

import type Module from '../node-haste/Module';
import type {Stats} from 'fs';
import type {IncomingMessage, ServerResponse} from 'http';
import type ResolutionResponse from '../node-haste/DependencyGraph/ResolutionResponse';
import type Bundle from '../Bundler/Bundle';

const {
  createActionStartEntry,
  createActionEndEntry,
  log,
  print,
} = require('../Logger');

function debounceAndBatch(fn, delay) {
  let timeout, args = [];
  return (value) => {
    args.push(value);
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      const a = args;
      args = [];
      fn(a);
    }, delay);
  };
}

const validateOpts = declareOpts({
  projectRoots: {
    type: 'array',
    required: true,
  },
  blacklistRE: {
    type: 'object', // typeof regex is object
  },
  moduleFormat: {
    type: 'string',
    default: 'haste',
  },
  polyfillModuleNames: {
    type: 'array',
    default: [],
  },
  cacheVersion: {
    type: 'string',
    default: '1.0',
  },
  resetCache: {
    type: 'boolean',
    default: false,
  },
  transformModulePath: {
    type: 'string',
    required: false,
  },
  extraNodeModules: {
    type: 'object',
    required: false,
  },
  watch: {
    type: 'boolean',
    default: false,
  },
  assetExts: {
    type: 'array',
    default: defaults.assetExts,
  },
  platforms: {
    type: 'array',
    default: defaults.platforms,
  },
  transformTimeoutInterval: {
    type: 'number',
    required: false,
  },
  getTransformOptions: {
    type: 'function',
    required: false,
  },
  silent: {
    type: 'boolean',
    default: false,
  },
});

const bundleOpts = declareOpts({
  sourceMapUrl: {
    type: 'string',
    required: false,
  },
  entryFile: {
    type: 'string',
    required: true,
  },
  dev: {
    type: 'boolean',
    default: true,
  },
  minify: {
    type: 'boolean',
    default: false,
  },
  runModule: {
    type: 'boolean',
    default: true,
  },
  inlineSourceMap: {
    type: 'boolean',
    default: false,
  },
  platform: {
    type: 'string',
    required: true,
  },
  runBeforeMainModule: {
    type: 'array',
    default: defaults.runBeforeMainModule,
  },
  unbundle: {
    type: 'boolean',
    default: false,
  },
  hot: {
    type: 'boolean',
    default: false,
  },
  entryModuleOnly: {
    type: 'boolean',
    default: false,
  },
  isolateModuleIDs: {
    type: 'boolean',
    default: false,
  },
  resolutionResponse: {
    type: 'object',
  },
  generateSourceMaps: {
    type: 'boolean',
    required: false,
  },
  assetPlugins: {
    type: 'array',
    default: [],
  },
  onProgress: {
    type: 'function',
  },
});

const dependencyOpts = declareOpts({
  platform: {
    type: 'string',
    required: true,
  },
  dev: {
    type: 'boolean',
    default: true,
  },
  entryFile: {
    type: 'string',
    required: true,
  },
  recursive: {
    type: 'boolean',
    default: true,
  },
  hot: {
    type: 'boolean',
    default: false,
  },
  minify: {
    type: 'boolean',
    default: undefined,
  },
});

const bundleDeps = new WeakMap();
const NODE_MODULES = `${path.sep}node_modules${path.sep}`;

class Server {

  _opts: {
    projectRoots: Array<string>,
    watch: boolean,
  };
  _projectRoots: Array<string>;
  _bundles: {};
  _changeWatchers: Array<{
    req: IncomingMessage,
    res: ServerResponse,
  }>;
  _fileChangeListeners: Array<(filePath: string) => mixed>;
  _assetServer: AssetServer;
  _bundler: Bundler;
  _debouncedFileChangeHandler: (filePath: string) => mixed;
  _hmrFileChangeListener: (type: string, filePath: string) => mixed;

  constructor(options: {watch?: boolean}) {
    const opts = this._opts = validateOpts(options);
    const processFileChange =
      ({type, filePath, stat}) => this.onFileChange(type, filePath, stat);

    this._projectRoots = opts.projectRoots;
    this._bundles = Object.create(null);
    this._changeWatchers = [];
    this._fileChangeListeners = [];

    this._assetServer = new AssetServer({
      assetExts: opts.assetExts,
      projectRoots: opts.projectRoots,
    });

    const bundlerOpts = Object.create(opts);
    bundlerOpts.assetServer = this._assetServer;
    bundlerOpts.allowBundleUpdates = options.watch;
    bundlerOpts.watch = options.watch;
    this._bundler = new Bundler(bundlerOpts);

    // changes to the haste map can affect resolution of files in the bundle
    const dependencyGraph = this._bundler.getResolver().getDependencyGraph();
    dependencyGraph.load().then(() => {
      dependencyGraph.getWatcher().on(
        'change',
        ({eventsQueue}) => eventsQueue.forEach(processFileChange),
      );
      dependencyGraph.getHasteMap().on('change', () => {
        debug('Clearing bundle cache due to haste map change');
        this._clearBundles();
      });
    });

    this._debouncedFileChangeHandler = debounceAndBatch(filePaths => {
      // only clear bundles for non-JS changes
      if (filePaths.every(RegExp.prototype.test, /\.js(?:on)?$/i)) {
        for (const key in this._bundles) {
          this._bundles[key].then(bundle => {
            const deps = bundleDeps.get(bundle);
            filePaths.forEach(filePath => {
              if (deps.files.has(filePath)) {
                deps.outdated.add(filePath);
              }
            });
          }).catch(e => {
            debug(`Could not update bundle: ${e}, evicting from cache`);
            delete this._bundles[key];
          });
        }
      } else {
        debug('Clearing bundles due to non-JS change');
        this._clearBundles();
      }
      this._informChangeWatchers();
    }, 50);
  }

  end(): mixed {
    return this._bundler.end();
  }

  setHMRFileChangeListener(
    listener: (type: string, filePath: string) => mixed,
  ) {
    this._hmrFileChangeListener = listener;
  }

  addFileChangeListener(listener: (filePath: string) => mixed) {
    if (this._fileChangeListeners.indexOf(listener) === -1) {
      this._fileChangeListeners.push(listener);
    }
  }

  buildBundle(options: {
    entryFile: string,
    platform?: string,
  }): Promise<Bundle> {
    return this._bundler.getResolver().getDependencyGraph().load().then(() => {
      if (!options.platform) {
        options.platform = getPlatformExtension(options.entryFile);
      }

      const opts = bundleOpts(options);
      const building = this._bundler.bundle(opts);
      building.then(bundle => {
        const modules = bundle.getModules();
        const nonVirtual = modules.filter(m => !m.virtual);
        bundleDeps.set(bundle, {
          files: new Map(
            nonVirtual
              .map(({sourcePath, meta = {dependencies: []}}) =>
                [sourcePath, meta.dependencies])
          ),
          idToIndex: new Map(modules.map(({id}, i) => [id, i])),
          dependencyPairs: new Map(
            nonVirtual
              .filter(({meta}) => meta && meta.dependencyPairs)
              .map(m => [m.sourcePath, m.meta.dependencyPairs])
          ),
          outdated: new Set(),
        });
      });
      return building;
    });
  }

  buildBundleFromUrl(reqUrl: string): Promise<mixed> {
    const options = this._getOptionsFromUrl(reqUrl);
    return this.buildBundle(options);
  }

  buildBundleForHMR(
    options: {platform: ?string},
    host: string,
    port: number,
  ): Promise<string> {
    return this._bundler.hmrBundle(options, host, port);
  }

  getShallowDependencies(options: {
    entryFile: string,
    platform?: string,
  }): Promise<mixed> {
    return Promise.resolve().then(() => {
      if (!options.platform) {
        options.platform = getPlatformExtension(options.entryFile);
      }

      const opts = dependencyOpts(options);
      return this._bundler.getShallowDependencies(opts);
    });
  }

  getModuleForPath(entryFile: string): Module {
    return this._bundler.getModuleForPath(entryFile);
  }

  getDependencies(options: {
    entryFile: string,
    platform?: string,
  }): Promise<ResolutionResponse> {
    return Promise.resolve().then(() => {
      if (!options.platform) {
        options.platform = getPlatformExtension(options.entryFile);
      }

      const opts = dependencyOpts(options);
      return this._bundler.getDependencies(opts);
    });
  }

  getOrderedDependencyPaths(options: {}): Promise<mixed> {
    return Promise.resolve().then(() => {
      const opts = dependencyOpts(options);
      return this._bundler.getOrderedDependencyPaths(opts);
    });
  }

  onFileChange(type: string, filePath: string, stat: Stats) {
    this._assetServer.onFileChange(type, filePath, stat);
    this._bundler.invalidateFile(filePath);

    // If Hot Loading is enabled avoid rebuilding bundles and sending live
    // updates. Instead, send the HMR updates right away and clear the bundles
    // cache so that if the user reloads we send them a fresh bundle
    if (this._hmrFileChangeListener) {
      // Clear cached bundles in case user reloads
      this._clearBundles();
      this._hmrFileChangeListener(type, filePath);
      return;
    } else if (type !== 'change' && filePath.indexOf(NODE_MODULES) !== -1) {
      // node module resolution can be affected by added or removed files
      debug('Clearing bundles due to potential node_modules resolution change');
      this._clearBundles();
    }

    Promise.all(
      this._fileChangeListeners.map(listener => listener(filePath))
    ).then(
      () => this._onFileChangeComplete(filePath),
      () => this._onFileChangeComplete(filePath)
    );
  }

  _onFileChangeComplete(filePath: string) {
    // Make sure the file watcher event runs through the system before
    // we rebuild the bundles.
    this._debouncedFileChangeHandler(filePath);
  }

  _clearBundles() {
    this._bundles = Object.create(null);
  }

  _informChangeWatchers() {
    const watchers = this._changeWatchers;
    const headers = {
      'Content-Type': 'application/json; charset=UTF-8',
    };

    watchers.forEach(function(w) {
      w.res.writeHead(205, headers);
      w.res.end(JSON.stringify({ changed: true }));
    });

    this._changeWatchers = [];
  }

  _processDebugRequest(reqUrl: string, res: ServerResponse) {
    let ret = '<!doctype html>';
    const pathname = url.parse(reqUrl).pathname;
    /* $FlowFixMe: pathname would be null for an invalid URL */
    const parts = pathname.split('/').filter(Boolean);
    if (parts.length === 1) {
      ret += '<div><a href="/debug/bundles">Cached Bundles</a></div>';
      res.end(ret);
    } else if (parts[1] === 'bundles') {
      ret += '<h1> Cached Bundles </h1>';
      Promise.all(Object.keys(this._bundles).map(optionsJson =>
        this._bundles[optionsJson].then(p => {
          ret += '<div><h2>' + optionsJson + '</h2>';
          ret += p.getDebugInfo();
        })
      )).then(
        () => res.end(ret),
        e => {
          res.writeHead(500);
          res.end('Internal Error');
          console.log(e.stack); // eslint-disable-line no-console-disallow
        }
      );
    } else {
      res.writeHead(404);
      res.end('Invalid debug request');
      return;
    }
  }

  _processOnChangeRequest(req: IncomingMessage, res: ServerResponse) {
    const watchers = this._changeWatchers;

    watchers.push({
      req: req,
      res: res,
    });

    req.on('close', () => {
      for (let i = 0; i < watchers.length; i++) {
        if (watchers[i] && watchers[i].req === req) {
          watchers.splice(i, 1);
          break;
        }
      }
    });
  }

  _rangeRequestMiddleware(
    req: IncomingMessage,
    res: ServerResponse,
    data: string,
    assetPath: string,
  ) {
    if (req.headers && req.headers.range) {
      const [rangeStart, rangeEnd] = req.headers.range.replace(/bytes=/, '').split('-');
      const dataStart = parseInt(rangeStart, 10);
      const dataEnd = rangeEnd ? parseInt(rangeEnd, 10) : data.length - 1;
      const chunksize = (dataEnd - dataStart) + 1;

      res.writeHead(206, {
        'Accept-Ranges': 'bytes',
        'Content-Length': chunksize.toString(),
        'Content-Range': `bytes ${dataStart}-${dataEnd}/${data.length}`,
        'Content-Type': mime.lookup(path.basename(assetPath[1])),
      });

      return data.slice(dataStart, dataEnd + 1);
    }

    return data;
  }

  _processAssetsRequest(req: IncomingMessage, res: ServerResponse) {
    const urlObj = url.parse(decodeURI(req.url), true);
    /* $FlowFixMe: could be empty if the url is invalid */
    const assetPath: string = urlObj.pathname.match(/^\/assets\/(.+)$/);

    const processingAssetRequestLogEntry =
      print(log(createActionStartEntry({
        action_name: 'Processing asset request',
        asset: assetPath[1],
      })), ['asset']);

    /* $FlowFixMe: query may be empty for invalid URLs */
    this._assetServer.get(assetPath[1], urlObj.query.platform)
      .then(
        data => {
          // Tell clients to cache this for 1 year.
          // This is safe as the asset url contains a hash of the asset.
          if (process.env.REACT_NATIVE_ENABLE_ASSET_CACHING === true) {
            res.setHeader('Cache-Control', 'max-age=31536000');
          }
          res.end(this._rangeRequestMiddleware(req, res, data, assetPath));
          process.nextTick(() => {
            print(log(createActionEndEntry(processingAssetRequestLogEntry)), ['asset']);
          });
        },
        error => {
          console.error(error.stack);
          res.writeHead(404);
          res.end('Asset not found');
        }
      );
  }

  optionsHash(options: {}) {
    // onProgress is a function, can't be serialized
    return JSON.stringify(Object.assign({}, options, { onProgress: null }));
  }

  _useCachedOrUpdateOrCreateBundle(options: {
    entryFile: string,
    platform?: string,
  }): Promise<Bundle> {
    const optionsJson = this.optionsHash(options);
    const bundleFromScratch = () => {
      const building = this.buildBundle(options);
      this._bundles[optionsJson] = building;
      return building;
    };

    if (optionsJson in this._bundles) {
      return this._bundles[optionsJson].then(bundle => {
        const deps = bundleDeps.get(bundle);
        const {dependencyPairs, files, idToIndex, outdated} = deps;
        if (outdated.size) {

          const updatingExistingBundleLogEntry =
            print(log(createActionStartEntry({
              action_name: 'Updating existing bundle',
              outdated_modules: outdated.size,
            })), ['outdated_modules']);

          debug('Attempt to update existing bundle');

          const changedModules =
            Array.from(outdated, this.getModuleForPath, this);
          deps.outdated = new Set();

          const opts = bundleOpts(options);
          const {platform, dev, minify, hot} = opts;

          // Need to create a resolution response to pass to the bundler
          // to process requires after transform. By providing a
          // specific response we can compute a non recursive one which
          // is the least we need and improve performance.
          const bundlePromise = this._bundles[optionsJson] =
            this.getDependencies({
              platform, dev, hot, minify,
              entryFile: options.entryFile,
              recursive: false,
            }).then(response => {
              debug('Update bundle: rebuild shallow bundle');

              changedModules.forEach(m => {
                response.setResolvedDependencyPairs(
                  m,
                  dependencyPairs.get(m.path),
                  {ignoreFinalized: true},
                );
              });

              return this.buildBundle({
                ...options,
                resolutionResponse: response.copy({
                  dependencies: changedModules,
                }),
              }).then(updateBundle => {
                const oldModules = bundle.getModules();
                const newModules = updateBundle.getModules();
                for (let i = 0, n = newModules.length; i < n; i++) {
                  const moduleTransport = newModules[i];
                  const {meta, sourcePath} = moduleTransport;
                  if (outdated.has(sourcePath)) {
                    /* $FlowFixMe: `meta` could be empty */
                    if (!contentsEqual(meta.dependencies, new Set(files.get(sourcePath)))) {
                      // bail out if any dependencies changed
                      return Promise.reject(Error(
                        `Dependencies of ${sourcePath} changed from [${
                          /* $FlowFixMe: `get` can return empty */
                          files.get(sourcePath).join(', ')
                        }] to [${
                          /* $FlowFixMe: `meta` could be empty */
                          meta.dependencies.join(', ')
                        }]`
                      ));
                    }

                    oldModules[idToIndex.get(moduleTransport.id)] = moduleTransport;
                  }
                }

                bundle.invalidateSource();

                print(
                  log(createActionEndEntry(updatingExistingBundleLogEntry)),
                  ['outdated_modules'],
                );

                debug('Successfully updated existing bundle');
                return bundle;
            });
          }).catch(e => {
            debug('Failed to update existing bundle, rebuilding...', e.stack || e.message);
            return bundleFromScratch();
          });
          return bundlePromise;
        } else {
          debug('Using cached bundle');
          return bundle;
        }
      });
    }

    return bundleFromScratch();
  }

  processRequest(
    req: IncomingMessage,
    res: ServerResponse,
    next: () => mixed,
  ) {
    const urlObj = url.parse(req.url, true);
    /* $FlowFixMe: Could be empty if the URL is invalid. */
    const pathname: string = urlObj.pathname;

    let requestType;
    if (pathname.match(/\.bundle$/)) {
      requestType = 'bundle';
    } else if (pathname.match(/\.map$/)) {
      requestType = 'map';
    } else if (pathname.match(/\.assets$/)) {
      requestType = 'assets';
    } else if (pathname.match(/^\/debug/)) {
      this._processDebugRequest(req.url, res);
      return;
    } else if (pathname.match(/^\/onchange\/?$/)) {
      this._processOnChangeRequest(req, res);
      return;
    } else if (pathname.match(/^\/assets\//)) {
      this._processAssetsRequest(req, res);
      return;
    } else if (pathname === '/symbolicate') {
      this._symbolicate(req, res);
      return;
    } else {
      next();
      return;
    }

    const options = this._getOptionsFromUrl(req.url);
    const requestingBundleLogEntry =
      print(log(createActionStartEntry({
        action_name: 'Requesting bundle',
        bundle_url: req.url,
        entry_point: options.entryFile,
      })), ['bundle_url']);

    let consoleProgress = () => {};
    if (process.stdout.isTTY && !this._opts.silent) {
      const bar = new ProgressBar('transformed :current/:total (:percent)', {
        complete: '=',
        incomplete: ' ',
        width: 40,
        total: 1,
      });
      consoleProgress = debouncedTick(bar);
    }

    const mres = MultipartResponse.wrap(req, res);
    options.onProgress = (done, total) => {
      consoleProgress(done, total);
      mres.writeChunk({'Content-Type': 'application/json'}, JSON.stringify({done, total}));
    };

    debug('Getting bundle for request');
    const building = this._useCachedOrUpdateOrCreateBundle(options);
    building.then(
      p => {
        if (requestType === 'bundle') {
          debug('Generating source code');
          const bundleSource = p.getSource({
            inlineSourceMap: options.inlineSourceMap,
            minify: options.minify,
            dev: options.dev,
          });
          debug('Writing response headers');
          const etag = p.getEtag();
          mres.setHeader('Content-Type', 'application/javascript');
          mres.setHeader('ETag', etag);

          if (req.headers['if-none-match'] === etag) {
            debug('Responding with 304');
            mres.writeHead(304);
            mres.end();
          } else {
            mres.end(bundleSource);
          }
          debug('Finished response');
          print(log(createActionEndEntry(requestingBundleLogEntry)), ['bundle_url']);
        } else if (requestType === 'map') {
          let sourceMap = p.getSourceMap({
            minify: options.minify,
            dev: options.dev,
          });

          if (typeof sourceMap !== 'string') {
            sourceMap = JSON.stringify(sourceMap);
          }

          mres.setHeader('Content-Type', 'application/json');
          mres.end(sourceMap);
          print(log(createActionEndEntry(requestingBundleLogEntry)), ['bundle_url']);
        } else if (requestType === 'assets') {
          const assetsList = JSON.stringify(p.getAssets());
          mres.setHeader('Content-Type', 'application/json');
          mres.end(assetsList);
          print(log(createActionEndEntry(requestingBundleLogEntry)), ['bundle_url']);
        }
      },
      error => this._handleError(mres, this.optionsHash(options), error)
    ).catch(error => {
      process.nextTick(() => {
        throw error;
      });
    });
  }

  _symbolicate(req: IncomingMessage, res: ServerResponse) {
    const symbolicatingLogEntry =
      print(log(createActionStartEntry('Symbolicating')));

    /* $FlowFixMe: where is `rowBody` defined? Is it added by
     * the `connect` framework? */
    Promise.resolve(req.rawBody).then(body => {
      const stack = JSON.parse(body).stack;

      // In case of multiple bundles / HMR, some stack frames can have
      // different URLs from others
      const urlIndexes = {};
      const uniqueUrls = [];
      stack.forEach(frame => {
        const sourceUrl = frame.file;
        // Skip `/debuggerWorker.js` which drives remote debugging because it
        // does not need to symbolication.
        // Skip anything except http(s), because there is no support for that yet
        if (!urlIndexes.hasOwnProperty(sourceUrl) &&
            !sourceUrl.endsWith('/debuggerWorker.js') &&
            sourceUrl.startsWith('http')) {
          urlIndexes[sourceUrl] = uniqueUrls.length;
          uniqueUrls.push(sourceUrl);
        }
      });

      const sourceMaps = uniqueUrls.map(
        sourceUrl => this._sourceMapForURL(sourceUrl)
      );
      return Promise.all(sourceMaps).then(consumers => {
        return stack.map(frame => {
          const sourceUrl = frame.file;
          if (!urlIndexes.hasOwnProperty(sourceUrl)) {
            return frame;
          }
          const idx = urlIndexes[sourceUrl];
          const consumer = consumers[idx];
          const original = consumer.originalPositionFor({
            line: frame.lineNumber,
            column: frame.column,
          });
          if (!original) {
            return frame;
          }
          return Object.assign({}, frame, {
            file: original.source,
            lineNumber: original.line,
            column: original.column,
          });
        });
      });
    }).then(
      stack => {
        res.end(JSON.stringify({stack: stack}));
        process.nextTick(() => {
          print(log(createActionEndEntry(symbolicatingLogEntry)));
        });
      },
      error => {
        console.error(error.stack || error);
        res.statusCode = 500;
        res.end(JSON.stringify({error: error.message}));
      }
    );
  }

  _sourceMapForURL(reqUrl: string): Promise<SourceMapConsumer> {
    const options = this._getOptionsFromUrl(reqUrl);
    const building = this._useCachedOrUpdateOrCreateBundle(options);
    return building.then(p => {
      const sourceMap = p.getSourceMap({
        minify: options.minify,
        dev: options.dev,
      });
      return new SourceMapConsumer(sourceMap);
    });
  }

  _handleError(res: ServerResponse, bundleID: string, error: {
    status: number,
    type: string,
    description: string,
    filename: string,
    lineNumber: number,
    errors: Array<{description: string, filename: string, lineNumber: number}>,
  }) {
    res.writeHead(error.status || 500, {
      'Content-Type': 'application/json; charset=UTF-8',
    });

    if (error.type === 'TransformError' ||
        error.type === 'NotFoundError' ||
        error.type === 'UnableToResolveError') {
      error.errors = [{
        description: error.description,
        filename: error.filename,
        lineNumber: error.lineNumber,
      }];
      res.end(JSON.stringify(error));

      if (error.type === 'NotFoundError') {
        delete this._bundles[bundleID];
      }
    } else {
      console.error(error.stack || error);
      res.end(JSON.stringify({
        type: 'InternalError',
        message: 'react-packager has encountered an internal error, ' +
          'please check your terminal error output for more details',
      }));
    }
  }

  _getOptionsFromUrl(reqUrl: string): {
    sourceMapUrl: string,
    entryFile: string,
    dev: boolean,
    minify: boolean,
    hot: boolean,
    runModule: boolean,
    inlineSourceMap: boolean,
    platform?: string,
    entryModuleOnly: boolean,
    generateSourceMaps: boolean,
    assetPlugins: Array<string>,
    onProgress?: () => mixed,
  } {
    // `true` to parse the query param as an object.
    const urlObj = url.parse(reqUrl, true);
    /* $FlowFixMe: `pathname` could be empty for an invalid URL */
    const pathname = decodeURIComponent(urlObj.pathname);

    // Backwards compatibility. Options used to be as added as '.' to the
    // entry module name. We can safely remove these options.
    const entryFile = pathname.replace(/^\//, '').split('.').filter(part => {
      if (part === 'includeRequire' || part === 'runModule' ||
          part === 'bundle' || part === 'map' || part === 'assets') {
        return false;
      }
      return true;
    }).join('.') + '.js';

    const sourceMapUrlObj = Object.assign({}, urlObj);
    sourceMapUrlObj.pathname = pathname.replace(/\.bundle$/, '.map');

    // try to get the platform from the url
    /* $FlowFixMe: `query` could be empty for an invalid URL */
    const platform = urlObj.query.platform ||
      getPlatformExtension(pathname);

    /* $FlowFixMe: `query` could be empty for an invalid URL */
    const assetPlugin = urlObj.query.assetPlugin;
    const assetPlugins = Array.isArray(assetPlugin) ?
      assetPlugin :
      (typeof assetPlugin === 'string') ? [assetPlugin] : [];

    return {
      sourceMapUrl: url.format(sourceMapUrlObj),
      entryFile: entryFile,
      dev: this._getBoolOptionFromQuery(urlObj.query, 'dev', true),
      minify: this._getBoolOptionFromQuery(urlObj.query, 'minify', false),
      hot: this._getBoolOptionFromQuery(urlObj.query, 'hot', false),
      runModule: this._getBoolOptionFromQuery(urlObj.query, 'runModule', true),
      inlineSourceMap: this._getBoolOptionFromQuery(
        urlObj.query,
        'inlineSourceMap',
        false
      ),
      platform: platform,
      entryModuleOnly: this._getBoolOptionFromQuery(
        urlObj.query,
        'entryModuleOnly',
        false,
      ),
      /* $FlowFixMe: missing defaultVal */
      generateSourceMaps: this._getBoolOptionFromQuery(urlObj.query, 'babelSourcemap'),
      assetPlugins,
    };
  }

  _getBoolOptionFromQuery(query: ?{}, opt: string, defaultVal: boolean): boolean {
    /* $FlowFixMe: `query` could be empty when it comes from an invalid URL */
    if (query[opt] == null) {
      return defaultVal;
    }

    return query[opt] === 'true' || query[opt] === '1';
  }
}

function contentsEqual(array: Array<mixed>, set: Set<mixed>): boolean {
  return array.length === set.size && array.every(set.has, set);
}

function debouncedTick(progressBar) {
  let n = 0;
  let start, total;

  return (_, t) => {
    total = t;
    n += 1;
    if (start) {
      if (progressBar.curr + n >= total || Date.now() - start > 200) {
        progressBar.total = total;
        progressBar.tick(n);
        start = n = 0;
      }
    } else {
      start = Date.now();
    }
  };
}

module.exports = Server;
