var selftest = require('../tool-testing/selftest.js');
var Sandbox = selftest.Sandbox;
var utils = require('../utils/utils.js');
var net = require('net');
var _ = require('underscore');
var files = require('../fs/files');
var catalog = require('../packaging/catalog/catalog.js');
var os = require('os');
var isReachable = require("is-reachable");
var httpHelpers = require('../utils/http-helpers.js');

var DEFAULT_RELEASE_TRACK = catalog.DEFAULT_TRACK;

var MONGO_LISTENING =
  { stdout: " [initandlisten] waiting for connections on port" };

var SIMPLE_WAREHOUSE = {
  v1: { },
  v2: { recommended: true },
  v3: { }
};

selftest.define("run", async function () {
  var s = new Sandbox({ fakeMongo: true });
  await s.init();

  var run;

  // Starting a run
  await s.createApp("myapp", "standard-app");
  s.cd("myapp");
  s.set("METEOR_TEST_TMP", files.convertToOSPath(files.mkdtemp()));
  run = s.run();
  await run.match("myapp");
  await run.match("proxy");
  await run.tellMongo(MONGO_LISTENING);
  await run.match("MongoDB");
  await run.match("your app");
  run.waitSecs(5);
  await run.match("running at");
  await run.match("localhost");

  // File change
  s.write("empty.js", "");
  run.waitSecs(2);
  await run.match("restarted");
  s.write("empty.js", " ");
  run.waitSecs(2);
  await run.match("restarted");
  // XXX want app to generate output so that we can see restart counter reset

  // Crashes
  s.write("crash.js", "process.exit(42);");
  run.waitSecs(5);
  await run.match("with code: 42");
  run.waitSecs(5);
  await run.match("is crashing");
  s.unlink("crash.js");
  run.waitSecs(5);
  await run.match("Modified");
  run.waitSecs(5);
  await run.match("restarted");
  s.write("empty.js", "");
  run.waitSecs(5);
  // We used to see the restart counter reset but right now restart messages
  // don't coalesce due to intermediate use of the progress bar.
  await run.match("restarted");
  s.write("crash.js", "process.kill(process.pid, 'SIGKILL');");
  run.waitSecs(5);
  await run.match("Exited");
  await run.match("is crashing");

  // Bundle failure
  s.unlink("crash.js");
  s.write("junk.css", "/*");
  run.waitSecs(5);
  await run.match("Modified");
  await run.match("prevented startup");
  await run.match("Unclosed comment");
  await run.match("file change");

  // Back to working
  s.unlink("junk.css");
  run.waitSecs(5);
  await run.match("restarted");
  await run.stop();

  run = s.run('--settings', 's.json');
  run.waitSecs(5);
  await run.match('s.json: file not found (settings file)');
  await run.match('Waiting for file change');
  s.write('s.json', '}');
  await run.match('s.json: parse error reading settings file');
  await run.match('Waiting for file change');
  s.write('s.json', '{}');
  run.waitSecs(15);
  await run.match('App running at');
  await run.stop();

  // Make sure a directory passed to --settings does not cause an infinite
  // re-build loop (issue #3854).
  run = s.run('--settings', os.tmpdir());
  await run.match(`${os.tmpdir()}: file not found (settings file)`);
  await run.match('Waiting for file change');
  run.forbid('Modified -- restarting');
  await run.stop();

  // How about a bundle failure right at startup
  s.write("junk.css", "/*");
  run = s.run();
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  await run.match("prevented startup");
  await run.match("Unclosed comment");
  await run.match("file change");
  s.unlink("junk.css");
  run.waitSecs(5);
  await run.match("restarted");
  await run.stop();

// XXX --port, --production, --raw-logs, --settings, --program
});

selftest.define("run --once", ["yet-unsolved-windows-failure"], async function () {
  var s = new Sandbox({ fakeMongo: true });
  await s.init();
  var run;

  await s.createApp("onceapp", "once");
  s.cd("onceapp");

  // Basic run --once
  s.set("RUN_ONCE_OUTCOME", "exit");
  run = s.run("--once");
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  await run.match("once test\n");
  await run.expectExit(123);

  // run --once, exit on signal
  s.set("RUN_ONCE_OUTCOME", "kill");
  run = s.run("--once");
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  await run.match("once test\n");
  await run.matchErr("Killed (SIGKILL)\n");
  await run.expectExit(255);

  // run --once, bundle failure
  s.set("RUN_ONCE_OUTCOME", "exit");
  s.write("junk.css", "/*");
  run = s.run("--once");
  run.waitSecs(5);
  await run.matchErr("Build failed");
  await run.matchErr("Unclosed comment");
  await run.expectExit(254);
  s.unlink("junk.css");

  // file changes don't make it restart
  s.set("RUN_ONCE_OUTCOME", "hang");
  run = s.run("--once");
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  await run.match("once test\n");
  s.write('empty.js', 'null');
  var originalRelease = s.read('.meteor/release');
  s.write('.meteor/release', 'v1');
  await utils.sleepMs(2000); // sorry, hard to avoid
  await run.stop();
  run.forbidAll("updated");
  s.unlink('empty.js');
  s.write('.meteor/release', originalRelease);
});

selftest.define("run --once with real Mongo", async function () {
  var s = new Sandbox;
  await s.init();

  await s.createApp("onceapp", "once");
  s.cd("onceapp");
  s.set("RUN_ONCE_OUTCOME", "mongo");
  var run = s.run("--once");
  run.waitSecs(30);
  await run.expectExit(86);
});

selftest.define("run errors", async function () {
  var s = new Sandbox;
  await s.init();

  await s.createApp("myapp", "standard-app");
  s.cd("myapp");

  // Prevent mongod from starting up.  (Note that "127.0.0.1" matches the
  // interface that mongo uses.)
  var proxyPort = utils.randomPort();
  var mongoPort = proxyPort + 1;
  let resolver;
  let toWait = new Promise(r => resolver = r);

  var server = net.createServer().listen(mongoPort, "127.0.0.1", resolver);
  await toWait;

  var run = s.run("-p", proxyPort);
  for (let count = 0; count <= 1; count++) {
    run.waitSecs(30);
    await run.match("Unexpected mongo exit code 48. Restarting.");
  }

  run.waitSecs(3);
  await run.match("Can't start Mongo server");
  await run.match("MongoDB exited because its port was closed");
  await run.match("running in the same project.\n");
  await run.expectEnd();
  run.forbid("Started MongoDB");
  await run.expectExit(254);

  toWait = new Promise(r => resolver = r);
  server.close(resolver);
  await toWait;

  // This time, prevent the proxy from starting. (This time, leaving out the
  // interface name matches.)
  toWait = new Promise(r => resolver = r);
  server = net.createServer().listen(proxyPort, resolver);
  await toWait;

  run = s.run("-p", proxyPort);
  run.waitSecs(3);
  await run.match(/Can't listen on port.*another Meteor/);
  await run.expectExit(254);

  toWait = new Promise(r => resolver = r);
  server.close(resolver);
  await toWait;
});

selftest.define("handle requests with large headers", async function() {
  const sandbox = new Sandbox();
  await sandbox.init();

  sandbox.env.NODE_OPTIONS = '--max-http-header-size=8192';

  await sandbox.createApp('myapp', 'standard-app');
  sandbox.cd('myapp');
  sandbox.append('.meteor/packages', 'browser-policy\n');

  const browserPolicyCode = Array(1000).fill(null)
    .map((_, index) => (
      `BrowserPolicy.content.allowConnectOrigin('host${index}.com');`
    ))
    .join('\n');
  sandbox.write('packageless.js', browserPolicyCode);

  const run = sandbox.run();
  run.waitSecs(5);
  await run.match('App running');

  let errorMessage = null;
  try {
    await httpHelpers.getUrl('http://localhost:3000');
  } catch (error) {
    errorMessage = error.message;
  }

  const errorMatchesExpected = /Unexpected error\./.test(errorMessage);
  selftest.expectTrue(errorMatchesExpected);
  await run.match('due to the header size exceeding Node\'s currently');
});

selftest.define("update during run", ["checkout", 'custom-warehouse'], async function () {
  var s = new Sandbox({
    warehouse: SIMPLE_WAREHOUSE,
    fakeMongo: true
  });
  await s.init();

  var run;

  s.set("METEOR_WATCH_PRIORITIZE_CHANGED", "false");

  await s.createApp("myapp", "packageless", { release: DEFAULT_RELEASE_TRACK + '@v1' });
  s.cd("myapp");

  // If the app version changes, we exit with an error message.
  run = s.run();
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(10);
  await run.match('localhost:3000');
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v2');
  await run.matchErr('to Meteor v2 from Meteor v1');
  run.waitSecs(10);
  await run.expectExit(254);

  // But not if the release was forced (case 1)
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v1');
  run = s.run("--release", DEFAULT_RELEASE_TRACK + "@v3");
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  await run.match('localhost:3000');
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v2');
  s.write('empty.js', '');
  run.waitSecs(10);
  await run.match('restarted');
  run.waitSecs(10);
  await run.stop();
  run.forbidAll("updated");

  // But not if the release was forced (case 2)
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v1');
  run = s.run("--release", DEFAULT_RELEASE_TRACK + "@v1");
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(10);
  await run.match('localhost:3000');
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v2');
  s.write('empty.js', '');
  run.waitSecs(10);
  await run.match('restarted');
  run.waitSecs(10);
  await run.stop();
  run.forbidAll("updated");

  // Nor do we do it if you're running from a checkout
  s = new Sandbox({ fakeMongo: true });
  await s.init();

  await s.createApp("myapp", "standard-app");
  s.cd("myapp");

  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v1');
  run = s.run();
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(10);
  await run.match('localhost:3000');
  run.waitSecs(10);
  s.write('.meteor/release', DEFAULT_RELEASE_TRACK + '@v2');
  s.write('empty.js', '');
  run.waitSecs(10);
  await run.match('restarted');
  run.waitSecs(10);
  await run.stop();
  run.forbidAll("updated");
});

selftest.define("run with mongo crash", ["checkout"], async function () {
  var s = new Sandbox({ fakeMongo: true });
  await s.init();

  var run;

  await s.createApp("myapp", "standard-app");
  s.cd("myapp");

  // Kill mongod three times.  See that it gives up and quits.
  run = s.run();
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  await run.match('localhost:3000/\n');

  if (process.platform === "win32") {
    await run.match('Type Control-C twice to stop.\n\n');
  }

  await run.tellMongo({exit: 23});
  await run.read('Unexpected mongo exit code 23. Restarting.\n');
  await run.tellMongo({exit: 46});
  await run.read('Unexpected mongo exit code 46. Restarting.\n');
  await run.tellMongo({exit: 47});
  await run.read('Unexpected mongo exit code 47. Restarting.\n');
  await run.read("Can't start Mongo server.\n");
  await run.read("MongoDB exited due to excess clock skew\n");
  await run.expectEnd();
  await run.expectExit(254);

  // Now create a build failure. Make sure that killing mongod three times
  // *also* successfully quits even if we're waiting on file change.
  s.write('bad.css', '/*');
  run = s.run();
  await run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  await run.match("prevented startup");
  await run.match("file change.\n");
  await run.tellMongo({exit: 23});
  await run.match('Unexpected mongo exit code 23. Restarting.\n');
  await run.tellMongo({exit: 46});
  await run.read('Unexpected mongo exit code 46. Restarting.\n');
  await run.tellMongo({exit: 47});
  await run.read('Unexpected mongo exit code 47. Restarting.\n');
  await run.read("Can't start Mongo server.\n");
  await run.read("MongoDB exited due to excess clock skew\n");
  await run.expectEnd();
  await run.expectExit(254);
});

// Test that when the parent runner process is SIGKILLed, the child
// process exits also.
selftest.define("run and SIGKILL parent process", ["yet-unsolved-windows-failure"], async function () {
  var s = new Sandbox();
  await s.init();

  var run;

  await s.createApp("myapp", "app-prints-pid");
  s.cd("myapp");

  run = s.run("run", "--timestamps");
  run.waitSecs(30);
  var match = await run.match(/My pid is (\d+)/);
  var childPid;
  if (! match || ! match[1]) {
    selftest.fail("No pid printed");
  }
  childPid = match[1];

  if (await !isReachable("localhost:3000")) {
    selftest.fail("Child process " + childPid + " already dead?");
  }

  process.kill(run.proc.pid, "SIGKILL");
  // This sleep should be a little more time than the interval at which
  // the child checks if the parent is still alive, in
  // packages/webapp/webapp_server.js.
  await utils.sleepMs(10000);

  // Send the child process a signal of 0. If there is no error, it
  // means that the process is still running, which is not what we
  // expect.
  if (await isReachable("localhost:3000")) {
    selftest.fail("Child process " + childPid + " is still running");
  }

  await run.stop();

  // Test that passing a bad pid in $METEOR_PARENT_PID logs an error and exits
  // immediately.
  s.set("METEOR_BAD_PARENT_PID_FOR_TEST", "t");
  run = s.run("run", "--timestamps");

  run.waitSecs(120);
  await run.match("must be a valid process ID");
  await run.match("Your application is crashing");
  await run.stop();
});

selftest.define("'meteor run --port' accepts/rejects proper values", async function () {
  var s = new Sandbox();
  await s.init();

  var run;

  await s.createApp("myapp", "standard-app");
  s.cd("myapp");

  run = s.run("run", "--port", "example.com");
  run.waitSecs(30);
  await run.matchErr("--port must include a port");
  await run.expectExit(1);

  run = s.run("run", "--port", "http://example.com");
  run.waitSecs(30);
  await run.matchErr("--port must include a port");
  await run.expectExit(1);

  run = s.run("run", "--port", "3500");
  run.waitSecs(30);
  await run.match('App running at: http://localhost:3500/');
  await run.stop();

  run = s.run("run", "--port", "127.0.0.1:3500");
  run.waitSecs(30);
  await run.match('App running at: http://127.0.0.1:3500/');
  await run.stop();
});

// Regression test for #3582.  Previously, meteor run would ignore changes to
// .meteor/versions that originate outside of the process.
selftest.define("update package during run", async function () {
  var s = new Sandbox();
  await s.init();

  await s.createApp("myapp", "app-with-atmosphere-package");
  await s.cd("myapp", async function () {
    // The app starts with this package at 0.0.1 (based on its
    // .meteor/versions).  0.0.2 exists too.  (These are on the real atmosphere
    // server.)
    var listRun = s.run("list");
    listRun.waitSecs(3);
    await listRun.match(/glasser:package-for-selftest.*0.0.1\*/);
    await listRun.match(/\* New versions/);
    await listRun.expectExit(0);

    var runRun = s.run();
    runRun.waitSecs(3);
    await runRun.match("App running at:");

    var updateRun = s.run("update", "glasser:package-for-selftest");
    await updateRun.match(
        /glasser:package-for-selftest.*upgraded from 0.0.1 to 0.0.2/);
    await updateRun.expectExit(0);

    await runRun.match("restarted");

    listRun = s.run("list");
    // When #3582 existed, the `meteor run` would revert this back to 0.0.1
    // before it restarted.
    await listRun.match(/glasser:package-for-selftest.*0.0.2 /);
    await listRun.expectExit(0);

    await runRun.stop();
  });
});

selftest.define("run logging in order", async function () {
  var s = new Sandbox({ fakeMongo: true });
  await s.init();

  var run;

  // Starting a run
  await s.createApp("myapp", "standard-app");
  s.cd("myapp");
  s.write('packageless.js', `
    Meteor.startup(function() {
      for (var i = 0; i < 100000; i++) {
        console.log('line: ' + i + '.');
      }
    });
  `);
  run = s.run();
  await run.match("myapp");
  await run.match("proxy");
  await run.tellMongo(MONGO_LISTENING);
  await run.match("MongoDB");
  run.waitSecs(5);
  for (var i = 0; i < 100000; i++) {
    await run.match(`line: ${i}.`);
  }
});

selftest.define("run ROOT_URL must be an URL", async function () {
  var s = new Sandbox();
  await s.init();
  var run;

  s.set("ROOT_URL", "192.168.0.1");
  await s.createApp("myapp", "standard-app", { dontPrepareApp: true });
  s.cd("myapp");

  run = s.run();
  await run.matchErr("$ROOT_URL, if specified, must be an URL");
  await run.expectExit(1);
});

selftest.define("app starts when settings file has BOM", async function () {
  var s = new Sandbox({ fakeMongo: true });
  await s.init();

  var run;
  await s.createApp("myapp", "standard-app");
  s.cd("myapp");
  files.writeFile(
    files.pathJoin(s.cwd, "settings.json"),
    "\ufeff" + JSON.stringify({ foo: "bar" }),
  );
  run = s.run("--settings", "settings.json", "--once");
  await run.tellMongo(MONGO_LISTENING);
  run.forbid("Build failed");
});
