import assert from "assert";
import { join as pathJoin } from "path";
import { PassThrough } from "stream";
import {
  closeSync,
  openSync,
  readFileSync,
  unlink,
  writeFileSync,
  writeSync,
} from "fs";
import { createServer } from "net";
import { start as replStart } from "repl";

// Enable process.sendMessage for communication with build process.
import "meteor/inter-process-messaging";

const INFO_FILE_MODE = parseInt("600", 8); // Only the owner can read or write.
const EXITING_MESSAGE = "Shell exiting...";

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

  // If the server is still in the very early stages of starting up,
  // Meteor.startup may not available yet.
  if (typeof Meteor === "object") {
    Meteor.startup(callback);
  } else if (typeof __meteor_bootstrap__ === "object") {
    const hooks = __meteor_bootstrap__.startupHooks;
    if (hooks) {
      hooks.push(callback);
    } else {
      // As a fallback, just call the callback asynchronously.
      setImmediate(callback);
    }
  }
}

// Disabling the shell causes all attached clients to disconnect and exit.
export 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.
    writeFileSync(
      getInfoFile(shellDir),
      JSON.stringify({
        status: "disabled",
        reason: "Shell server has shut down."
      }) + "\n",
      { mode: INFO_FILE_MODE }
    );
  } catch (ignored) {}
}

// Shell commands need to be executed in a Fiber in case they call into
// code that yields. Using a Promise is an even better idea, since it runs
// its callbacks in Fibers drawn from a pool, so the Fibers are recycled.
const evalCommandPromise = Promise.resolve();

class Server {
  constructor(shellDir) {
    assert.ok(this instanceof Server);

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

    this.server =
      createServer((socket) => {
        this.onConnection(socket);
      })
      .on("error", (err) => {
        console.error(err.stack);
      });
  }

  listen() {
    const infoFile = getInfoFile(this.shellDir);

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

  onConnection(socket) {
    // 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.
    const 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.
    readJSONFromStream(socket, (error, options, replInputSocket) => {
      clearTimeout(timeout);

      if (error) {
        socket = null;
        console.error(error.stack);
        return;
      }

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

      // Set the columns to what is being requested by the client.
      if (options.columns && socket) {
        socket.columns = options.columns;
      }
      delete options.columns;

      options = Object.assign(
        Object.create(null),

        // Defaults for configurable options.
        {
          prompt: "> ",
          terminal: true,
          useColors: true,
          ignoreUndefined: true,
        },

        // Configurable options
        options,

        // Immutable options.
        {
          input: replInputSocket,
          useGlobal: false,
          output: socket
        }
      );

      // The prompt during an evaluateAndExit must be blank to ensure
      // that the prompt doesn't inadvertently get parsed as part of
      // the JSON communication channel.
      if (options.evaluateAndExit) {
        options.prompt = "";
      }

      // Start the REPL.
      this.startREPL(options);

      if (options.evaluateAndExit) {
        this._wrappedDefaultEval.call(
          Object.create(null),
          options.evaluateAndExit.command,
          global,
          options.evaluateAndExit.filename || "<meteor shell>",
          function (error, result) {
            if (socket) {
              function sendResultToSocket(message) {
                // Sending back a JSON payload allows the client to
                // distinguish between errors and successful results.
                socket.end(JSON.stringify(message) + "\n");
              }

              if (error) {
                sendResultToSocket({
                  error: error.toString(),
                  code: 1
                });
              } else {
                sendResultToSocket({
                  result,
                });
              }
            }
          }
        );
        return;
      }
      delete options.evaluateAndExit;

      this.enableInteractiveMode(options);
    });
  }

  startREPL(options) {
    // 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;
    });

    const repl = this.repl = replStart(options);
    const { shellDir } = this;

    // This is technique of setting `repl.context` is similar to how the
    // `useGlobal` option would work during a normal `repl.start()` and
    // allows shell access (and tab completion!) to Meteor globals (i.e.
    // Underscore _, Meteor, etc.). By using this technique, which changes
    // the context after startup, we avoid stomping on the special `_`
    // variable (in `repl` this equals the value of the last command) from
    // being overridden in the client/server socket-handshaking.  Furthermore,
    // by setting `useGlobal` back to true, we allow the default eval function
    // to use the desired `runInThisContext` method (https://git.io/vbvAB).
    repl.context = global;
    repl.useGlobal = true;

    setRequireAndModule(repl.context);

    // In order to avoid duplicating code here, specifically the complexities
    // of catching so-called "Recoverable Errors" (https://git.io/vbvbl),
    // we will wrap the default eval, run it in a Fiber (via a Promise), and
    // give it the opportunity to decide if the user is mid-code-block.
    const defaultEval = repl.eval;

    function wrappedDefaultEval(code, context, file, callback) {
      if (Package['babel-compiler']) {
        try {
          code = Package['babel-compiler'].Babel.compileForShell(code, {
            cacheDirectory: getCacheDirectory(shellDir)
          });
        } catch (err) {
          // Any Babel error here might be just fine since it's
          // possible the code was incomplete (multi-line code on the REPL).
          // The defaultEval below will use its own functionality to determine
          // if this error is "recoverable".
        }
      }

      evalCommandPromise
        .then(() => defaultEval(code, context, file, (error, result) => {
          if (error) {
            callback(error);
          } else {
            // Check if the result is a Promise
            if (result && typeof result.then === 'function') {
              // Handle the Promise resolution and rejection
              result
                .then(resolvedResult => {
                  callback(null, resolvedResult);
                })
                .catch(rejectedError => {
                  callback(rejectedError);
                });
            } else {
              callback(null, result);
            }
          }
        }))
        .catch(callback);
    }

    // Have the REPL use the newly wrapped function instead and store the
    // _wrappedDefaultEval so that evalulateAndExit calls can use it directly.
    repl.eval = this._wrappedDefaultEval = wrappedDefaultEval;
  }

  enableInteractiveMode(options) {
    // History persists across shell sessions!
    this.initializeHistory();

    const repl = this.repl;

    // Implement an alternate means of fetching the return value,
    // via `__` (double underscore) as originally implemented in:
    // https://github.com/meteor/meteor/commit/2443d832265c7d1c
    Object.defineProperty(repl.context, "__", {
      get: () => repl.last,
      set: (val) => {
        repl.last = val;
      },

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

    // Some improvements to the existing help messages.
    function addHelp(cmd, helpText) {
      const info = repl.commands[cmd] || repl.commands["." + cmd];
      if (info) {
        info.help = helpText;
      }
    }
    addHelp("break", "Terminate current command input and display new prompt");
    addHelp("exit", "Disconnect from server and leave shell");
    addHelp("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() {
        if (process.sendMessage) {
          process.sendMessage("shell-server", { command: "reload" });
        } else {
          process.exit(0);
        }
      }
    });
  }

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

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

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

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

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

function readJSONFromStream(inputStream, callback) {
  const outputStream = new PassThrough();
  let dataSoFar = "";

  function onData(buffer) {
    const lines = buffer.toString("utf8").split("\n");

    while (lines.length > 0) {
      dataSoFar += lines.shift();

      let json;
      try {
        json = JSON.parse(dataSoFar);
      } catch (error) {
        if (error instanceof SyntaxError) {
          continue;
        }

        return finish(error);
      }

      if (lines.length > 0) {
        outputStream.write(lines.join("\n"));
      }

      inputStream.pipe(outputStream);

      return finish(null, json);
    }
  }

  function onClose() {
    finish(new Error("stream unexpectedly closed"));
  }

  let finished = false;
  function finish(error, json) {
    if (! finished) {
      finished = true;
      inputStream.removeListener("data", onData);
      inputStream.removeListener("error", finish);
      inputStream.removeListener("close", onClose);
      callback(error, json, outputStream);
    }
  }

  inputStream.on("data", onData);
  inputStream.on("error", finish);
  inputStream.on("close", onClose);
}

function getInfoFile(shellDir) {
  return pathJoin(shellDir, "info.json");
}

function getHistoryFile(shellDir) {
  return pathJoin(shellDir, "history");
}

function getCacheDirectory(shellDir) {
  return pathJoin(shellDir, "cache");
}

function setRequireAndModule(context) {
  if (Package.modules) {
    // Use the same `require` function and `module` object visible to the
    // application.
    const toBeInstalled = {};
    const shellModuleName = "meteor-shell-" +
      Math.random().toString(36).slice(2) + ".js";

    toBeInstalled[shellModuleName] = function (require, exports, module) {
      context.module = module;
      context.require = require;

      // Tab completion sometimes uses require.extensions, but only for
      // the keys.
      require.extensions = {
        ".js": true,
        ".json": true,
        ".node": true,
      };
    };

    // This populates repl.context.{module,require} by evaluating the
    // module defined above.
    Package.modules.meteorInstall(toBeInstalled)("./" + shellModuleName);
  }
}
