import { exec, spawn } from 'child_process';
import * as cluster from 'cluster';
import * as cors from 'connect-cors';
import * as crypto from 'crypto';
import * as Debug from 'debug';
import * as detectPort from 'detect-port';
import * as fs from 'fs';
import * as http from 'http';
import * as humps from 'humps';
import * as ip from 'ip';
import * as isDocker from 'is-docker';
import * as _ from 'lodash';
import * as minilog from 'minilog';
import * as mkdirp from 'mkdirp';
import * as path from 'path';
import * as serveStatic from 'serve-static';
import { fromStringWithSourceMap, SourceListMap } from 'source-list-map';
import * as url from 'url';
import { ConcatSource, RawSource } from 'webpack-sources';

import { Builder, Builders } from './Builder';
import liveReloadMiddleware from './plugins/react-native/liveReloadMiddleware';
import symbolicateMiddleware from './plugins/react-native/symbolicateMiddleware';
import Spin from './Spin';
import { hookAsync, hookSync } from './webpackHooks';

const SPIN_DLL_VERSION = 2;
const BACKEND_CHANGE_MSG = 'backend_change';

const debug = Debug('spinjs');
const expoPorts = {};

const clientStats = { all: false, assets: true, warnings: true, errors: true, errorDetails: false };

const spinLogger = minilog('spin');

process.on('uncaughtException', ex => {
  spinLogger.error(ex);
});

process.on('unhandledRejection', reason => {
  spinLogger.error(reason);
});

const __WINDOWS__ = /^win/.test(process.platform);

let server;
let startBackend = false;
let nodeDebugOpt;

process.on('exit', () => {
  if (server) {
    server.kill('SIGTERM');
  }
});

const spawnServer = (cwd, args: any[], options: { nodeDebugger: boolean; serverPath: string }, logger) => {
  server = spawn('node', [...args], { stdio: [0, 1, 2], cwd });
  logger.debug(`Spawning ${['node', ...args].join(' ')}`);
  server.on('exit', code => {
    if (code === 250) {
      // App requested full reload
      startBackend = true;
    }
    logger.info('Backend has been stopped');
    server = undefined;
    runServer(cwd, options.serverPath, options.nodeDebugger, logger);
  });
};

const runServer = (cwd, serverPath, nodeDebugger, logger) => {
  if (!fs.existsSync(serverPath)) {
    throw new Error(`Backend doesn't exist at ${serverPath}, exiting`);
  }
  if (startBackend) {
    startBackend = false;
    logger.debug('Starting backend');

    if (!nodeDebugOpt) {
      if (!nodeDebugger) {
        // disables node debugger when the option was set to false
        spawnServer(cwd, [serverPath], { serverPath, nodeDebugger }, logger);
      } else {
        exec('node -v', (error, stdout, stderr) => {
          if (error) {
            spinLogger.error(error);
            process.exit(1);
          }
          const nodeVersion = stdout.match(/^v([0-9]+)\.([0-9]+)\.([0-9]+)/);
          const nodeMajor = parseInt(nodeVersion[1], 10);
          const nodeMinor = parseInt(nodeVersion[2], 10);
          nodeDebugOpt = nodeMajor >= 6 || (nodeMajor === 6 && nodeMinor >= 9) ? '--inspect' : '--debug';
          detectPort(9229).then(debugPort => {
            spawnServer(cwd, [nodeDebugOpt + '=' + debugPort, serverPath], { serverPath, nodeDebugger }, logger);
          });
        });
      }
    } else {
      spawnServer(cwd, [nodeDebugOpt, serverPath], { serverPath, nodeDebugger }, logger);
    }
  }
};

const webpackReporter = (spin: Spin, builder: Builder, outputPath: string, log, err?, stats?) => {
  if (err) {
    log.error(err.stack);
    throw new Error('Build error');
  }
  if (stats) {
    const str = stats.toString(builder.config.stats);
    if (str.length > 0) {
      log.info(str);
    }

    if (builder.writeStats) {
      mkdirp.sync(outputPath);
      fs.writeFileSync(path.join(outputPath, 'stats.json'), JSON.stringify(stats.toJson(clientStats)));
    }
  }
  if (!spin.watch && cluster.isWorker) {
    log.info('Build process finished, exitting...');
    process.exit(0);
  }
};

const frontendVirtualModules = [];

class MobileAssetsPlugin {
  public vendorAssets: any;

  constructor(vendorAssets?) {
    this.vendorAssets = vendorAssets || [];
  }

  public apply(compiler) {
    hookAsync(compiler, 'after-compile', (compilation, callback) => {
      compilation.chunks.forEach(chunk => {
        chunk.files.forEach(file => {
          if (file.endsWith('.bundle')) {
            const assets = this.vendorAssets;
            compilation.modules.forEach(module => {
              if (module._asset) {
                assets.push(module._asset);
              }
            });
            compilation.assets[file.replace('.bundle', '') + '.assets'] = new RawSource(JSON.stringify(assets));
          }
        });
      });
      callback();
    });
  }
}

const startClientWebpack = (hasBackend, spin, builder) => {
  const webpack = builder.require('webpack');

  const config = builder.config;
  const configOutputPath = config.output.path;

  const VirtualModules = builder.require('webpack-virtual-modules');
  const clientVirtualModules = new VirtualModules({ 'node_modules/backend_reload.js': '' });
  config.plugins.push(clientVirtualModules);
  frontendVirtualModules.push(clientVirtualModules);

  const logger = minilog(`${config.name}-webpack`);
  if (builder.silent) {
    logger.suggest.deny(/.*/, 'debug');
  }
  try {
    const reporter = (...args) => webpackReporter(spin, builder, configOutputPath, logger, ...args);

    if (spin.watch) {
      startWebpackDevServer(hasBackend, spin, builder, reporter, logger);
    } else {
      if (builder.stack.platform !== 'web') {
        config.plugins.push(new MobileAssetsPlugin());
      }

      const compiler = webpack(config);

      compiler.run(reporter);
    }
  } catch (err) {
    logger.error(err.message, err.stack);
  }
};

let backendReloadCount = 0;
const increaseBackendReloadCount = () => {
  backendReloadCount++;
  for (const virtualModules of frontendVirtualModules) {
    virtualModules.writeModule('node_modules/backend_reload.js', `var count = ${backendReloadCount};\n`);
  }
};

const startServerWebpack = (spin, builder) => {
  const config = builder.config;
  const logger = minilog(`${config.name}-webpack`);
  if (builder.silent) {
    logger.suggest.deny(/.*/, 'debug');
  }

  try {
    const webpack = builder.require('webpack');
    const reporter = (...args) => webpackReporter(spin, builder, config.output.path, logger, ...args);

    const compiler = webpack(config);

    if (spin.watch) {
      hookSync(compiler, 'done', stats => {
        if (stats.compilation.errors && stats.compilation.errors.length) {
          stats.compilation.errors.forEach(error => logger.error(error.message));
        }
      });

      hookSync(compiler, 'compilation', compilation => {
        hookSync(compilation, 'after-optimize-assets', assets => {
          // Patch webpack-generated original source files path, by stripping hash after filename
          const mapKey = _.findKey(assets, (v, k) => k.endsWith('.map'));
          if (mapKey) {
            const srcMap = JSON.parse(assets[mapKey]._value);
            for (const idx of Object.keys(srcMap.sources)) {
              srcMap.sources[idx] = srcMap.sources[idx].split(';')[0];
            }
            assets[mapKey]._value = JSON.stringify(srcMap);
          }
        });
      });

      compiler.watch({}, reporter);

      hookSync(compiler, 'done', stats => {
        if (!stats.compilation.errors.length) {
          const { output } = config;
          startBackend = true;
          if (server) {
            if (!__WINDOWS__) {
              server.kill('SIGUSR2');
            }

            if (builder.frontendRefreshOnBackendChange) {
              for (const module of stats.compilation.modules) {
                if (module.built && module.resource && module.resource.split(/[\\\/]/).indexOf('server') >= 0) {
                  // Force front-end refresh on back-end change
                  logger.debug('Force front-end current page refresh, due to change in backend at:', module.resource);
                  process.send({ cmd: BACKEND_CHANGE_MSG });
                  break;
                }
              }
            }
          } else {
            runServer(builder.require.cwd, path.join(output.path, 'index.js'), builder.nodeDebugger, logger);
          }
        }
      });
    } else {
      compiler.run(reporter);
    }
  } catch (err) {
    logger.error(err.message, err.stack);
  }
};

const openFrontend = (spin, builder, logger) => {
  const opn = builder.require('opn');
  try {
    if (builder.stack.hasAny('web')) {
      const lanUrl = `http://${ip.address()}:${builder.config.devServer.port}`;
      const localUrl = `http://localhost:${builder.config.devServer.port}`;
      if (isDocker() || builder.openBrowser === false) {
        logger.info(`App is running at, Local: ${localUrl} LAN: ${lanUrl}`);
      } else {
        opn(localUrl);
      }
    } else if (builder.stack.hasAny('react-native')) {
      startExpoProject(spin, builder, logger);
    }
  } catch (e) {
    logger.error(e.stack);
  }
};

const debugMiddleware = (req, res, next) => {
  if (['/debug', '/debug/bundles'].indexOf(req.path) >= 0) {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<!doctype html><div><a href="/debug/bundles">Cached Bundles</a></div>');
  } else {
    next();
  }
};

const startWebpackDevServer = (hasBackend: boolean, spin: Spin, builder: Builder, reporter, logger) => {
  const webpack = builder.require('webpack');

  const config = builder.config;
  const platform = builder.stack.platform;

  const configOutputPath = config.output.path;
  config.output.path = '/';

  let vendorHashesJson;
  let vendorSourceListMap;
  let vendorSource;
  let vendorMap;

  if (builder.webpackDll && builder.child) {
    const name = `vendor_${humps.camelize(builder.name)}`;
    const jsonPath = path.join(builder.dllBuildDir, `${name}_dll.json`);
    const json = JSON.parse(fs.readFileSync(path.resolve('./' + jsonPath)).toString());

    config.plugins.push(
      new webpack.DllReferencePlugin({
        context: process.cwd(),
        manifest: json
      })
    );
    vendorHashesJson = JSON.parse(
      fs.readFileSync(path.join(builder.dllBuildDir, `${name}_dll_hashes.json`)).toString()
    );
    vendorSource = new RawSource(
      fs.readFileSync(path.join(builder.dllBuildDir, vendorHashesJson.name)).toString() + '\n'
    );
    if (platform !== 'web') {
      const vendorAssets = JSON.parse(
        fs.readFileSync(path.join(builder.dllBuildDir, vendorHashesJson.name + '.assets')).toString()
      );
      config.plugins.push(new MobileAssetsPlugin(vendorAssets));
    }
    if (builder.sourceMap) {
      vendorMap = new RawSource(
        fs.readFileSync(path.join(builder.dllBuildDir, vendorHashesJson.name + '.map')).toString()
      );
      vendorSourceListMap = fromStringWithSourceMap(vendorSource.source(), JSON.parse(vendorMap.source()));
    }
  }

  const compiler = webpack(config);
  let awaitedAlready = false;

  hookAsync(compiler, 'after-emit', (compilation, callback) => {
    if (!awaitedAlready) {
      if (hasBackend || builder.waitOn) {
        let waitOnUrls;
        const backendOption = builder.backendUrl || builder.backendUrl;
        if (backendOption) {
          const { protocol, hostname, port } = url.parse(backendOption.replace('{ip}', ip.address()));
          waitOnUrls = [`tcp:${hostname}:${port || (protocol === 'https:' ? 443 : 80)}`];
        } else {
          waitOnUrls = builder.waitOn ? [].concat(builder.waitOn) : undefined;
        }
        if (waitOnUrls && waitOnUrls.length) {
          logger.debug(`waiting for ${waitOnUrls}`);
          const waitStart = Date.now();
          const waitNotifier = setInterval(() => {
            logger.debug(`still waiting for ${waitOnUrls} after ${Date.now() - waitStart}ms...`);
          }, 10000);
          const waitOn = builder.require('wait-on');
          waitOn({ resources: waitOnUrls }, err => {
            clearInterval(waitNotifier);
            awaitedAlready = true;
            if (err) {
              logger.error(err);
            } else {
              logger.debug('Backend has been started, resuming webpack dev server...');
            }
            callback();
          });
        } else {
          awaitedAlready = true;
          callback();
        }
      } else {
        callback();
      }
    } else {
      callback();
    }
  });
  if (builder.webpackDll && builder.child && platform !== 'web') {
    hookAsync(compiler, 'after-compile', (compilation, callback) => {
      compilation.chunks.forEach(chunk => {
        chunk.files.forEach(file => {
          if (file.endsWith('.bundle')) {
            if (builder.sourceMap) {
              const sourceListMap = new SourceListMap();
              sourceListMap.add(vendorSourceListMap);
              sourceListMap.add(
                fromStringWithSourceMap(
                  compilation.assets[file].source(),
                  JSON.parse(compilation.assets[file + '.map'].source())
                )
              );
              const sourceAndMap = sourceListMap.toStringWithSourceMap({ file });
              compilation.assets[file] = new RawSource(sourceAndMap.source);
              compilation.assets[file + '.map'] = new RawSource(JSON.stringify(sourceAndMap.map));
            } else {
              compilation.assets[file] = new ConcatSource(vendorSource, compilation.assets[file]);
            }
          }
        });
      });
      callback();
    });
  }

  if (builder.webpackDll && builder.child && platform === 'web' && !builder.ssr) {
    hookAsync(compiler, 'after-compile', (compilation, callback) => {
      compilation.assets[vendorHashesJson.name] = vendorSource;
      if (builder.sourceMap) {
        compilation.assets[vendorHashesJson.name + '.map'] = vendorMap;
      }
      callback();
    });
    hookSync(compiler, 'compilation', compilation => {
      hookAsync(compilation, 'html-webpack-plugin-before-html-processing', (htmlPluginData, callback) => {
        htmlPluginData.assets.js.unshift('/' + vendorHashesJson.name);
        callback(null, htmlPluginData);
      });
    });
  }

  let frontendFirstStart = true;

  hookSync(compiler, 'done', stats => {
    // if (stats.compilation.errors && stats.compilation.errors.length) {
    //   stats.compilation.errors.forEach(error => logger.error(error.message));
    // }
    const dir = configOutputPath;
    mkdirp.sync(dir);
    if (stats.compilation.assets['assets.json']) {
      const assetsMap = JSON.parse(stats.compilation.assets['assets.json'].source());
      const prefix = compiler.outputPath;
      _.each(stats.toJson(clientStats).assetsByChunkName, (assets, bundle) => {
        const bundleJs = assets.constructor === Array ? assets[0] : assets;
        assetsMap[`${bundle}.js`] = prefix + bundleJs;
        if (assets.length > 1) {
          assetsMap[`${bundle}.js.map`] = prefix + `${bundleJs}.map`;
        }
      });
      if (builder.webpackDll) {
        assetsMap['vendor.js'] = prefix + vendorHashesJson.name;
      }
      fs.writeFileSync(path.join(dir, 'assets.json'), JSON.stringify(assetsMap));
    }
    if (frontendFirstStart) {
      frontendFirstStart = false;
      openFrontend(spin, builder, logger);
    }
  });

  let serverInstance: any;

  let webSocketProxy;
  let messageSocket;
  let wsProxy;
  let ms;
  let inspectorProxy;

  if (platform === 'web') {
    const WebpackDevServer = builder.require('webpack-dev-server');

    serverInstance = new WebpackDevServer(compiler, {
      ...config.devServer,
      reporter: (opts1, opts2) => {
        const opts = opts2 || opts1;
        const { state, stats } = opts;
        if (state) {
          logger.debug('bundle is now VALID.');
        } else {
          logger.debug('bundle is now INVALID.');
        }
        reporter(null, stats);
      }
    });
  } else {
    const connect = builder.require('connect');
    const compression = builder.require('compression');
    const httpProxyMiddleware = builder.require('http-proxy-middleware');
    const mime = builder.require('mime', builder.require.resolve('webpack-dev-middleware'));
    const webpackDevMiddleware = builder.require('webpack-dev-middleware');
    const webpackHotMiddleware = builder.require('webpack-hot-middleware');

    const app = connect();

    serverInstance = http.createServer(app);
    mime.define({ 'application/javascript': ['bundle'] }, true);
    mime.define({ 'application/json': ['assets'] }, true);

    messageSocket = builder.require('react-native/local-cli/server/util/messageSocket.js');
    webSocketProxy = builder.require('react-native/local-cli/server/util/webSocketProxy.js');

    try {
      const InspectorProxy = builder.require('react-native/local-cli/server/util/inspectorProxy.js');
      inspectorProxy = new InspectorProxy();
    } catch (ignored) {}
    const copyToClipBoardMiddleware = builder.require(
      'react-native/local-cli/server/middleware/copyToClipBoardMiddleware'
    );
    let cpuProfilerMiddleware;
    try {
      cpuProfilerMiddleware = builder.require('react-native/local-cli/server/middleware/cpuProfilerMiddleware');
    } catch (ignored) {}
    const getDevToolsMiddleware = builder.require('react-native/local-cli/server/middleware/getDevToolsMiddleware');
    let heapCaptureMiddleware;
    try {
      heapCaptureMiddleware = builder.require('react-native/local-cli/server/middleware/heapCaptureMiddleware.js');
    } catch (ignored) {}
    const indexPageMiddleware = builder.require('react-native/local-cli/server/middleware/indexPage');
    const loadRawBodyMiddleware = builder.require('react-native/local-cli/server/middleware/loadRawBodyMiddleware');
    const openStackFrameInEditorMiddleware = builder.require(
      'react-native/local-cli/server/middleware/openStackFrameInEditorMiddleware'
    );
    const statusPageMiddleware = builder.require('react-native/local-cli/server/middleware/statusPageMiddleware.js');
    const systraceProfileMiddleware = builder.require(
      'react-native/local-cli/server/middleware/systraceProfileMiddleware.js'
    );
    const unless = builder.require('react-native/local-cli/server/middleware/unless');

    // Workaround for bug in Haul /symbolicate under Windows
    compiler.options.output.path = path.sep;
    const devMiddleware = webpackDevMiddleware(
      compiler,
      _.merge({}, config.devServer, {
        reporter(mwOpts, { state, stats }) {
          if (state) {
            logger.info('bundle is now VALID.');
          } else {
            logger.info('bundle is now INVALID.');
          }
          reporter(null, stats);
        }
      })
    );

    const args = {
      port: config.devServer.port,
      projectRoots: [path.resolve('.')]
    };
    app
      .use(cors())
      .use(loadRawBodyMiddleware)
      .use((req, res, next) => {
        req.path = req.url.split('?')[0];
        if (req.path === '/symbolicate') {
          req.rawBody = req.rawBody.replace(/index\.mobile\.delta/g, 'index.mobile.bundle');
        }
        const origWriteHead = res.writeHead;
        res.writeHead = (...parms) => {
          const code = parms[0];
          if (code === 404) {
            logger.error(`404 at URL ${req.url}`);
          }
          origWriteHead.apply(res, parms);
        };
        if (debug.enabled && req.path !== '/onchange') {
          logger.debug(`Dev mobile packager request: ${debug.enabled ? req.url : req.path}`);
        }
        next();
      })
      .use((req, res, next) => {
        const query = url.parse(req.url, true).query;
        const urlPlatform = query && query.platform;
        if (urlPlatform && urlPlatform !== builder.stack.platform) {
          res.writeHead(404, { 'Content-Type': 'text/plain' });
          res.end(`Serving '${builder.stack.platform}' bundles, but got request from '${urlPlatform}'`);
        } else {
          next();
        }
      })
      .use(compression());
    app.use('/assets', serveStatic(path.join(builder.require.cwd, '.expo', builder.stack.platform)));
    if (builder.child) {
      app.use(serveStatic(builder.child.config.output.path));
    }
    app
      .use((req, res, next) => {
        if (req.path === '/debugger-ui/deltaUrlToBlobUrl.js') {
          debug(`serving monkey patched deltaUrlToBlobUrl`);
          res.writeHead(200, { 'Content-Type': 'application/javascript' });
          res.end(`window.deltaUrlToBlobUrl = function(url) { return url.replace('.delta', '.bundle'); }`);
        } else {
          next();
        }
      })
      .use(
        '/debugger-ui',
        serveStatic(
          path.join(
            path.dirname(builder.require.resolve('react-native/package.json')),
            '/local-cli/server/util/debugger-ui'
          )
        )
      )
      .use(getDevToolsMiddleware(args, () => wsProxy && wsProxy.isChromeConnected()))
      .use(getDevToolsMiddleware(args, () => ms && ms.isChromeConnected()))
      .use(liveReloadMiddleware(compiler))
      .use(symbolicateMiddleware(compiler, logger))
      .use(openStackFrameInEditorMiddleware(args))
      .use(copyToClipBoardMiddleware)
      .use(statusPageMiddleware)
      .use(systraceProfileMiddleware)
      .use(indexPageMiddleware)
      .use(debugMiddleware);
    if (heapCaptureMiddleware) {
      app.use(heapCaptureMiddleware);
    }
    if (cpuProfilerMiddleware) {
      app.use(cpuProfilerMiddleware);
    }
    if (inspectorProxy) {
      app.use(unless('/inspector', inspectorProxy.processRequest.bind(inspectorProxy)));
    }

    app
      .use((req, res, next) => {
        if (platform !== 'web') {
          // Workaround for Expo Client bug in parsing Content-Type header with charset
          const origSetHeader = res.setHeader;
          res.setHeader = (key, value) => {
            let val = value;
            if (key === 'Content-Type' && value.indexOf('application/javascript') >= 0) {
              val = value.split(';')[0];
            }
            origSetHeader.call(res, key, val);
          };
        }
        return devMiddleware(req, res, next);
      })
      .use(webpackHotMiddleware(compiler, { log: false }));

    if (config.devServer.proxy) {
      Object.keys(config.devServer.proxy).forEach(key => {
        app.use(httpProxyMiddleware(key, config.devServer.proxy[key]));
      });
    }
  }

  logger.info(`Webpack dev server listening on http://localhost:${config.devServer.port}`);
  serverInstance.listen(config.devServer.port, () => {
    if (platform !== 'web') {
      wsProxy = webSocketProxy.attachToServer(serverInstance, '/debugger-proxy');
      ms = messageSocket.attachToServer(serverInstance, '/message');
      webSocketProxy.attachToServer(serverInstance, '/devtools');
      if (inspectorProxy) {
        inspectorProxy.attachToServer(serverInstance, '/inspector');
      }
    }
  });
  serverInstance.timeout = 0;
  serverInstance.keepAliveTimeout = 0;
};

const isDllValid = (spin, builder, logger): boolean => {
  const name = `vendor_${humps.camelize(builder.name)}`;
  try {
    const hashesPath = path.join(builder.dllBuildDir, `${name}_dll_hashes.json`);
    if (!fs.existsSync(hashesPath)) {
      return false;
    }
    const relMeta = JSON.parse(fs.readFileSync(hashesPath).toString());
    if (SPIN_DLL_VERSION !== relMeta.version) {
      return false;
    }
    if (!fs.existsSync(path.join(builder.dllBuildDir, relMeta.name))) {
      return false;
    }
    if (builder.sourceMap && !fs.existsSync(path.join(builder.dllBuildDir, relMeta.name + '.map'))) {
      return false;
    }
    if (!_.isEqual(relMeta.modules, builder.child.config.entry.vendor)) {
      return false;
    }

    const json = JSON.parse(fs.readFileSync(path.join(builder.dllBuildDir, `${name}_dll.json`)).toString());

    for (const filename of Object.keys(json.content)) {
      if (filename.indexOf(' ') < 0 && filename.indexOf('@virtual') < 0) {
        if (!fs.existsSync(filename)) {
          logger.warn(`${name} DLL need to be regenerated, file: ${filename} is missing.`);
          return false;
        }
        const hash = crypto
          .createHash('md5')
          .update(fs.readFileSync(filename))
          .digest('hex');
        if (relMeta.hashes[filename] !== hash) {
          logger.warn(`Hash for ${name} DLL file ${filename} has changed, need to rebuild it`);
          return false;
        }
      }
    }

    return true;
  } catch (e) {
    logger.warn(`Error checking vendor bundle ${name}, regenerating it...`, e);

    return false;
  }
};

const buildDll = (spin: Spin, builder: Builder) => {
  const webpack = builder.require('webpack');
  const config = builder.child.config;
  return new Promise(done => {
    const name = `vendor_${humps.camelize(builder.name)}`;
    const logger = minilog(`${config.name}-webpack`);
    if (builder.silent) {
      logger.suggest.deny(/.*/, 'debug');
    }
    const reporter = (...args) => webpackReporter(spin, builder, config.output.path, logger, ...args);

    if (!isDllValid(spin, builder, logger)) {
      logger.debug(`Generating ${name} DLL bundle with modules:\n${JSON.stringify(config.entry.vendor)}`);

      mkdirp.sync(builder.dllBuildDir);
      const compiler = webpack(config);

      hookSync(compiler, 'done', stats => {
        try {
          const json = JSON.parse(fs.readFileSync(path.join(builder.dllBuildDir, `${name}_dll.json`)).toString());
          const vendorKey = _.findKey(
            stats.compilation.assets,
            (v, key) => key.startsWith('vendor') && key.endsWith('_dll.js')
          );
          const assets = [];
          stats.compilation.modules.forEach(module => {
            if (module._asset) {
              assets.push(module._asset);
            }
          });
          fs.writeFileSync(path.join(builder.dllBuildDir, `${vendorKey}.assets`), JSON.stringify(assets));

          const meta = { name: vendorKey, hashes: {}, modules: config.entry.vendor, version: SPIN_DLL_VERSION };
          for (const filename of Object.keys(json.content)) {
            if (filename.indexOf(' ') < 0 && filename.indexOf('@virtual') < 0) {
              meta.hashes[filename] = crypto
                .createHash('md5')
                .update(fs.readFileSync(filename))
                .digest('hex');
            }
          }

          fs.writeFileSync(path.join(builder.dllBuildDir, `${name}_dll_hashes.json`), JSON.stringify(meta));
          fs.writeFileSync(path.join(builder.dllBuildDir, `${name}_dll.json`), JSON.stringify(json));
        } catch (e) {
          logger.error(e.stack);
          process.exit(1);
        }
        done();
      });

      compiler.run(reporter);
    } else {
      done();
    }
  });
};

const copyExpoImage = (cwd: string, expoDir: string, appJson: any, keyPath: string) => {
  const imagePath: string = _.get(appJson, keyPath);
  if (imagePath) {
    const absImagePath = path.join(cwd, imagePath);
    fs.writeFileSync(path.join(expoDir, path.basename(absImagePath)), fs.readFileSync(absImagePath));
    _.set(appJson, keyPath, path.basename(absImagePath));
  }
};

const setupExpoDir = (spin: Spin, builder: Builder, dir, platform) => {
  const reactNativeDir = path.join(dir, 'node_modules', 'react-native');
  mkdirp.sync(path.join(reactNativeDir, 'local-cli'));
  fs.writeFileSync(
    path.join(reactNativeDir, 'package.json'),
    fs.readFileSync(builder.require.resolve('react-native/package.json'))
  );
  fs.writeFileSync(path.join(reactNativeDir, 'local-cli/cli.js'), '');

  const reactDir = path.join(dir, 'node_modules', 'react');
  mkdirp.sync(reactDir);
  fs.writeFileSync(path.join(reactDir, 'package.json'), fs.readFileSync(builder.require.resolve('react/package.json')));

  const pkg = JSON.parse(fs.readFileSync(builder.require.resolve('./package.json')).toString());
  const origDeps = pkg.dependencies;
  delete pkg.devDependencies;
  pkg.dependencies = { react: origDeps.react, 'react-native': origDeps['react-native'] };
  if (platform !== 'all') {
    pkg.name = pkg.name + '-' + platform;
  }
  pkg.main = `index.mobile`;
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2));
  const appJson = JSON.parse(fs.readFileSync(builder.require.resolve('./app.json')).toString());
  [
    'expo.icon',
    'expo.ios.icon',
    'expo.android.icon',
    'expo.splash.image',
    'expo.ios.splash.image',
    'expo.ios.splash.tabletImage',
    'expo.android.splash.ldpi',
    'expo.android.splash.mdpi',
    'expo.android.splash.hdpi',
    'expo.android.splash.xhdpi',
    'expo.android.splash.xxhdpi',
    'expo.android.splash.xxxhdpi'
  ].forEach(keyPath => copyExpoImage(builder.require.cwd, dir, appJson, keyPath));
  fs.writeFileSync(path.join(dir, 'app.json'), JSON.stringify(appJson, null, 2));
  if (platform !== 'all') {
    fs.writeFileSync(path.join(dir, '.exprc'), JSON.stringify({ manifestPort: expoPorts[platform] }, null, 2));
  }
};

const deviceLoggers = {};

const mirrorExpoLogs = (builder: Builder, projectRoot: string) => {
  const { ProjectUtils } = builder.require('xdl');

  deviceLoggers[projectRoot] = minilog('expo-for-' + builder.name);

  if (!ProjectUtils.logWithLevel._patched) {
    const origExpoLogger = ProjectUtils.logWithLevel;
    ProjectUtils.logWithLevel = (projRoot, level, object, msg, id) => {
      let json;
      if (msg[0] === '{') {
        json = JSON.parse(msg);
      }
      if (level === 'error') {
        const info = object.includesStack ? json.message + '\n' + json.stack : json.message;
        deviceLoggers[projRoot].log(info.replace(/\\n/g, '\n'));
      } else if (json) {
        deviceLoggers[projRoot].log(msg.message);
      } else {
        deviceLoggers[projRoot].log(msg);
      }
      return origExpoLogger.call(ProjectUtils, projRoot, level, object, msg, id);
    };
    ProjectUtils.logWithLevel._patched = true;
  }
};

const startExpoServer = async (spin: Spin, builder: Builder, projectRoot: string, packagerPort) => {
  const { Config, Project, ProjectSettings } = builder.require('xdl');

  mirrorExpoLogs(builder, projectRoot);

  Config.validation.reactNativeVersionWarnings = false;
  Config.developerTool = 'crna';
  Config.offline = true;

  await Project.startExpoServerAsync(projectRoot);
  await ProjectSettings.setPackagerInfoAsync(projectRoot, {
    packagerPort
  });
};

const startExpoProject = async (spin: Spin, builder: Builder, logger: any) => {
  const { UrlUtils, Android, Simulator } = builder.require('xdl');
  const qr = builder.require('qrcode-terminal');
  const platform = builder.stack.platform;

  try {
    const projectRoot = path.join(builder.require.cwd, '.expo', platform);
    setupExpoDir(spin, builder, projectRoot, platform);
    await startExpoServer(spin, builder, projectRoot, builder.config.devServer.port);

    const address = await UrlUtils.constructManifestUrlAsync(projectRoot);
    const localAddress = await UrlUtils.constructManifestUrlAsync(projectRoot, {
      hostType: 'localhost'
    });
    logger.info(`Expo address for ${platform}, Local: ${localAddress}, LAN: ${address}`);
    logger.info(
      "To open this app on your phone scan this QR code in Expo Client (if it doesn't get started automatically)"
    );
    qr.generate(address, code => {
      logger.info('\n' + code);
    });
    if (!isDocker()) {
      if (platform === 'android') {
        const { success, error } = await Android.openProjectAsync(projectRoot);

        if (!success) {
          logger.error(error.message);
        }
      } else if (platform === 'ios') {
        const { success, msg } = await Simulator.openUrlInSimulatorSafeAsync(localAddress);

        if (!success) {
          logger.error('Failed to start Simulator: ', msg);
        }
      }
    }
  } catch (e) {
    logger.error(e.stack);
  }
};

const startWebpack = async (spin: Spin, builder: Builder, platforms: any) => {
  if (builder.stack.platform === 'server') {
    startServerWebpack(spin, builder);
  } else {
    startClientWebpack(!!platforms.server, spin, builder);
  }
};

const allocateExpoPorts = async expoPlatforms => {
  const startPorts = { android: 19000, ios: 19500 };
  for (const platform of expoPlatforms) {
    const expoPort = await detectPort(startPorts[platform]);
    expoPorts[platform] = expoPort;
  }
};

const startExpoProdServer = async (spin: Spin, mainBuilder: Builder, builders: Builders, logger) => {
  const connect = mainBuilder.require('connect');
  const mime = mainBuilder.require('mime', mainBuilder.require.resolve('webpack-dev-middleware'));
  const compression = mainBuilder.require('compression');
  const statusPageMiddleware = mainBuilder.require('react-native/local-cli/server/middleware/statusPageMiddleware.js');
  const { UrlUtils } = mainBuilder.require('xdl');

  logger.info(`Starting Expo prod server`);
  const packagerPort = 3030;

  const app = connect();
  app
    .use((req, res, next) => {
      req.path = req.url.split('?')[0];
      debug(`Prod mobile packager request: ${req.url}`);
      next();
    })
    .use(statusPageMiddleware)
    .use(compression())
    .use(debugMiddleware)
    .use((req, res, next) => {
      const platform = url.parse(req.url, true).query.platform;
      if (platform) {
        let platformFound: boolean = false;
        for (const name of Object.keys(builders)) {
          const builder = builders[name];
          if (builder.stack.hasAny(platform)) {
            platformFound = true;
            const filePath = builder.buildDir
              ? path.join(builder.buildDir, req.path)
              : path.join(builder.frontendBuildDir || `build/client`, platform, req.path);
            if (fs.existsSync(filePath)) {
              res.writeHead(200, { 'Content-Type': mime.lookup ? mime.lookup(filePath) : mime.getType(filePath) });
              fs.createReadStream(filePath).pipe(res);
              return;
            }
          }
        }

        if (!platformFound) {
          logger.error(
            `Bundle for '${platform}' platform is missing! You need to build bundles both for Android and iOS.`
          );
        } else {
          res.writeHead(404, { 'Content-Type': 'application/json' });
          res.end(`{"message": "File not found for request: ${req.path}"}`);
        }
      } else {
        next();
      }
    });

  const serverInstance: any = http.createServer(app);

  await new Promise((resolve, reject) => {
    serverInstance.listen(packagerPort, () => {
      logger.info(`Production mobile packager listening on http://localhost:${packagerPort}`);
      resolve();
    });
  });

  serverInstance.timeout = 0;
  serverInstance.keepAliveTimeout = 0;

  const projectRoot = path.join(path.resolve('.'), '.expo', 'all');
  await startExpoServer(spin, mainBuilder, projectRoot, packagerPort);
  const localAddress = await UrlUtils.constructManifestUrlAsync(projectRoot, {
    hostType: 'localhost'
  });
  logger.info(`Expo server running on address: ${localAddress}`);
};

const startExp = async (spin: Spin, builders: Builders, logger) => {
  let mainBuilder: Builder;
  for (const name of Object.keys(builders)) {
    const builder = builders[name];
    if (builder.stack.hasAny(['ios', 'android'])) {
      mainBuilder = builder;
      break;
    }
  }
  if (!mainBuilder) {
    throw new Error('Builders for `ios` or `android` not found');
  }

  const projectRoot = path.join(process.cwd(), '.expo', 'all');
  setupExpoDir(spin, mainBuilder, projectRoot, 'all');
  const expIdx = process.argv.indexOf('exp');
  if (['ba', 'bi', 'build:android', 'build:ios', 'publish', 'p', 'server'].indexOf(process.argv[expIdx + 1]) >= 0) {
    await startExpoProdServer(spin, mainBuilder, builders, logger);
  }
  if (process.argv[expIdx + 1] !== 'server') {
    const exp = spawn(
      path.join(process.cwd(), 'node_modules/.bin/exp' + (__WINDOWS__ ? '.cmd' : '')),
      process.argv.splice(expIdx + 1),
      {
        cwd: projectRoot,
        stdio: [0, 1, 2]
      }
    );
    exp.on('exit', code => {
      process.exit(code);
    });
  }
};

const runBuilder = (cmd: string, builder: Builder, platforms) => {
  process.chdir(builder.require.cwd);
  const spin = new Spin(builder.require.cwd, cmd);
  const prepareDllPromise: PromiseLike<any> =
    spin.watch && builder.webpackDll && builder.child ? buildDll(spin, builder) : Promise.resolve();
  prepareDllPromise.then(() => startWebpack(spin, builder, platforms));
};

const execute = (cmd: string, argv: any, builders: Builders, spin: Spin) => {
  const expoPlatforms = [];
  const platforms = {};
  Object.keys(builders).forEach(name => {
    const builder = builders[name];
    const stack = builder.stack;
    platforms[stack.platform] = true;
    if (stack.hasAny('react-native') && stack.hasAny('ios')) {
      expoPlatforms.push('ios');
    } else if (stack.hasAny('react-native') && stack.hasAny('android')) {
      expoPlatforms.push('android');
    }
  });

  if (cluster.isMaster) {
    if (argv.verbose) {
      Object.keys(builders).forEach(name => {
        const builder = builders[name];
        spinLogger.log(`${name} = `, require('util').inspect(builder.config, false, null));
      });
    }

    if (cmd === 'exp') {
      startExp(spin, builders, spinLogger);
    } else if (cmd === 'test') {
      // TODO: Remove this in 0.5.x
      let builder;
      for (const name of Object.keys(builders)) {
        builder = builders[name];
        if (builder.roles.indexOf('test') >= 0) {
          const testArgs = ['--webpack-config', builder.require.resolve('spinjs/webpack.config.js')];
          if (builder.stack.hasAny('react')) {
            const majorVer = builder.require('react/package.json').version.split('.')[0];
            const reactVer = majorVer >= 16 ? majorVer : 15;
            if (reactVer >= 16) {
              testArgs.push('--include', 'raf/polyfill');
            }
          }

          const testCmd = path.join(process.cwd(), 'node_modules/.bin/mocha-webpack' + (__WINDOWS__ ? '.cmd' : ''));
          testArgs.push.apply(testArgs, process.argv.slice(process.argv.indexOf('test') + 1));
          spinLogger.info(`Running ${testCmd} ${testArgs.join(' ')}`);

          const env: any = Object.create(process.env);
          if (argv.c) {
            env.SPIN_CWD = spin.cwd;
            env.SPIN_CONFIG = path.resolve(argv.c);
          }

          const mochaWebpack = spawn(testCmd, testArgs, {
            stdio: [0, 1, 2],
            env,
            cwd: builder.require.cwd
          });
          mochaWebpack.on('close', code => {
            if (code !== 0) {
              process.exit(code);
            }
          });
        }
      }
    } else {
      const prepareExpoPromise =
        spin.watch && expoPlatforms.length > 0 ? allocateExpoPorts(expoPlatforms) : Promise.resolve();
      prepareExpoPromise.then(() => {
        const workerBuilders = {};

        let potentialWorkerCount = 0;
        for (const id of Object.keys(builders)) {
          const builder = builders[id];
          if (builder.stack.hasAny(['dll', 'test'])) {
            continue;
          }
          if (builder.cluster !== false) {
            potentialWorkerCount++;
          }
        }

        for (const id of Object.keys(builders)) {
          const builder = builders[id];
          if (builder.stack.hasAny(['dll', 'test'])) {
            continue;
          }

          if (potentialWorkerCount > 1 && !builder.cluster) {
            const worker = cluster.fork({ BUILDER_ID: id, EXPO_PORTS: JSON.stringify(expoPorts) });
            workerBuilders[worker.process.pid] = builder;
          } else {
            runBuilder(cmd, builder, platforms);
          }
        }

        for (const id of Object.keys(cluster.workers)) {
          cluster.workers[id].on('message', msg => {
            debug(`Master received message ${JSON.stringify(msg)}`);
            for (const wid of Object.keys(cluster.workers)) {
              cluster.workers[wid].send(msg);
            }
          });
        }

        cluster.on('exit', (worker, code, signal) => {
          if (cmd !== 'build') {
            spinLogger.warn(`Worker ${workerBuilders[worker.process.pid].id} died, code: ${code}, signal: ${signal}`);
          }
        });
      });
    }
  } else {
    const builder = builders[process.env.BUILDER_ID];
    const builderExpoPorts = JSON.parse(process.env.EXPO_PORTS);
    for (const platform of Object.keys(builderExpoPorts)) {
      expoPorts[platform] = builderExpoPorts[platform];
    }
    process.on('message', msg => {
      if (msg.cmd === BACKEND_CHANGE_MSG) {
        debug(`Increase backend reload count in ${builder.id}`);
        increaseBackendReloadCount();
      }
    });

    runBuilder(cmd, builder, platforms);
  }
};

export default execute;
