var assert = require("assert");
var path = require("path");
var stream = require("stream");
var fs = require("fs");
var net = require("net");
var tty = require("tty");
var vm = require("vm");
var Fiber = require("fibers");
var _ = require("underscore");
var INFO_FILE_MODE = 0600; // Only the owner can read or write.
var EXITING_MESSAGE =
  // Exported so that ./client.js can know what to expect.
  exports.EXITING_MESSAGE = "Shell exiting...";

// Invoked by the server process to listen for incoming connections from
// shell clients. Each connection gets its own REPL instance.
exports.listen = function listen(shellDir) {
  new Server(shellDir).listen();
};

// Disabling the shell causes all attached clients to disconnect and exit.
exports.disable = function disable(shellDir) {
  try {
    // Replace info.json with a file that says the shell server is
    // disabled, so that any connected shell clients will fail to
    // reconnect after the server process closes their sockets.
    fs.writeFileSync(
      getInfoFile(shellDir),
      JSON.stringify({
        status: "disabled",
        reason: "Shell server has shut down."
      }) + "\n",
      { mode: INFO_FILE_MODE }
    );
  } catch (ignored) {}
};

function Server(shellDir) {
  var self = this;
  assert.ok(self instanceof Server);

  self.shellDir = shellDir;
  self.key = Math.random().toString(36).slice(2);

  self.server = net.createServer(function(socket) {
    self.onConnection(socket);
  }).on("error", function(err) {
    console.error(err.stack);
  });
}

var Sp = Server.prototype;

Sp.listen = function listen() {
  var self = this;
  var infoFile = getInfoFile(self.shellDir);

  fs.unlink(infoFile, function() {
    self.server.listen(0, "127.0.0.1", function() {
      fs.writeFileSync(infoFile, JSON.stringify({
        status: "enabled",
        port: self.server.address().port,
        key: self.key
      }) + "\n", {
        mode: INFO_FILE_MODE
      });
    });
  });
};

Sp.onConnection = function onConnection(socket) {
  var self = this;
  var dataSoFar = "";

  // Make sure this function doesn't try to write anything to the socket
  // after it has been closed.
  socket.on("close", function() {
    socket = null;
  });

  // If communication is not established within 1000ms of the first
  // connection, forcibly close the socket.
  var timeout = setTimeout(function() {
    if (socket) {
      socket.removeAllListeners("data");
      socket.end(EXITING_MESSAGE + "\n");
    }
  }, 1000);

  // Let connecting clients configure certain REPL options by sending a
  // JSON object over the socket. For example, only the client knows
  // whether it's running a TTY or an Emacs subshell or some other kind of
  // terminal, so the client must decide the value of options.terminal.
  socket.on("data", function onData(buffer) {
    // Just in case the options JSON comes in fragments.
    dataSoFar += buffer.toString("utf8");

    try {
      var options = JSON.parse(dataSoFar);
    } finally {
      if (! _.isObject(options)) {
        return; // Silence any parsing exceptions.
      }
    }

    if (socket) {
      socket.removeListener("data", onData);
    }

    if (options.key !== self.key) {
      if (socket) {
        socket.end(EXITING_MESSAGE + "\n");
      }
      return;
    }
    delete options.key;

    clearTimeout(timeout);

    // Immutable options.
    _.extend(options, {
      input: socket,
      output: socket,
      eval: evalCommand
    });

    // Overridable options.
    _.defaults(options, {
      prompt: "> ",
      terminal: true,
      useColors: true,
      useGlobal: true,
      ignoreUndefined: true,
    });

    self.startREPL(options);
  });
};

Sp.startREPL = function startREPL(options) {
  var self = this;

  if (! options.output.columns) {
    // The REPL's tab completion logic assumes process.stdout is a TTY,
    // and while that isn't technically true here, we can get tab
    // completion to behave correctly if we fake the .columns property.
    options.output.columns = getTerminalWidth();
  }

  // Make sure this function doesn't try to write anything to the output
  // stream after it has been closed.
  options.output.on("close", function() {
    options.output = null;
  });

  var repl = self.repl = require("repl").start(options);

  // History persists across shell sessions!
  self.initializeHistory();

  Object.defineProperty(repl.context, "_", {
    // Force the global _ variable to remain bound to underscore.
    get: function () { return _; },

    // Expose the last REPL result as __ instead of _.
    set: function(lastResult) {
      repl.context.__ = lastResult;
    },

    enumerable: true,

    // Allow this property to be (re)defined more than once (e.g. each
    // time the server restarts).
    configurable: true
  });

  // Use the same `require` function and `module` object visible to the
  // shell.js module.
  repl.context.require = require;
  repl.context.module = module;
  repl.context.repl = repl;

  // Some improvements to the existing help messages.
  repl.commands[".break"].help =
    "Terminate current command input and display new prompt";
  repl.commands[".exit"].help = "Disconnect from server and leave shell";
  repl.commands[".help"].help = "Show this help information";

  // When the REPL exits, signal the attached client to exit by sending it
  // the special EXITING_MESSAGE.
  repl.on("exit", function() {
    if (options.output) {
      options.output.write(EXITING_MESSAGE + "\n");
      options.output.end();
    }
  });

  // When the server process exits, end the output stream but do not
  // signal the attached client to exit.
  process.on("exit", function() {
    if (options.output) {
      options.output.end();
    }
  });

  // This Meteor-specific shell command rebuilds the application as if a
  // change was made to server code.
  repl.defineCommand("reload", {
    help: "Restart the server and the shell",
    action: function() {
      process.exit(0);
    }
  });
};

function getInfoFile(shellDir) {
  return path.join(shellDir, "info.json");
}
exports.getInfoFile = getInfoFile;

function getHistoryFile(shellDir) {
  return path.join(shellDir, "history");
}

function getTerminalWidth() {
  try {
    // Inspired by https://github.com/TooTallNate/ttys/blob/master/index.js
    var fd = fs.openSync("/dev/tty", "r");
    assert.ok(tty.isatty(fd));
    var ws = new tty.WriteStream(fd);
    ws.end();
    return ws.columns;
  } catch (fancyApproachWasTooFancy) {
    return 80;
  }
}

// Shell commands need to be executed in fibers in case they call into
// code that yields.
function evalCommand(command, context, filename, callback) {
  Fiber(function() {
    try {
      var result = vm.runInThisContext(command, filename);
    } catch (error) {
      if (process.domain) {
        process.domain.emit("error", error);
        process.domain.exit();
      } else {
        callback(error);
      }
      return;
    }
    callback(null, result);
  }).run();
}

// This function allows a persistent history of shell commands to be saved
// to and loaded from .meteor/local/shell-history.
Sp.initializeHistory = function initializeHistory() {
  var self = this;
  var rli = self.repl.rli;
  var historyFile = getHistoryFile(self.shellDir);
  var historyFd = fs.openSync(historyFile, "a+");
  var historyLines = fs.readFileSync(historyFile, "utf8").split("\n");
  var seenLines = Object.create(null);

  if (! rli.history) {
    rli.history = [];
    rli.historyIndex = -1;
  }

  while (rli.history && historyLines.length > 0) {
    var line = historyLines.pop();
    if (line && /\S/.test(line) && ! seenLines[line]) {
      rli.history.push(line);
      seenLines[line] = true;
    }
  }

  rli.addListener("line", function(line) {
    if (historyFd >= 0 && /\S/.test(line)) {
      fs.writeSync(historyFd, line + "\n");
    }
  });

  self.repl.on("exit", function() {
    fs.closeSync(historyFd);
    historyFd = -1;
  });
};