/*
 *  This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
 *  License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
 */

/*
Terminal server
*/

const { spawn } = require("node-pty");
import { readFile, writeFile } from "fs";
import { promises as fsPromises } from "fs";
const { readlink } = fsPromises;
import { console_init_filename, len, merge, path_split } from "@cocalc/util/misc";
import { exists } from "../jupyter/async-utils-node";
import { isEqual, throttle } from "lodash";
import { callback, delay } from "awaiting";

interface Terminal {
  channel: any;
  history: string;
  client_sizes?: any;
  last_truncate_time: number;
  truncating: number;
  last_exit: number;
  options: {
    path?: string; // this is the "original" path to the terminal, not the derived "term_path"
    command?: string;
    args?: string[];
    env?: { [key: string]: string };
  };
  size?: any;
  term?: any; // node-pty
}

const PREFIX = "terminal:";
const terminals: { [name: string]: Terminal } = {};

const MAX_HISTORY_LENGTH: number = 10000000;
const truncate_thresh_ms: number = 10000;
const check_interval_ms: number = 5000;

// this is used to know which process belongs to which terminal
export function pid2path(pid: number): string | undefined {
  for (const term of Object.values(terminals)) {
    if (term.term?.pid == pid) {
      return term.options.path;
    }
  }
}

export async function terminal(
  primus: any,
  logger: any,
  path: string,
  options: any
): Promise<string> {
  const name = `${PREFIX}${path}`;
  if (terminals[name] !== undefined) {
    if (options.command != terminals[name].options.command) {
      terminals[name].options.command = options.command;
      terminals[name].options.args = options.args;
      process.kill(terminals[name].term.pid, "SIGKILL");
    }
    return name;
  }
  const channel = primus.channel(name);
  terminals[name] = {
    channel,
    history: "",
    client_sizes: {},
    last_truncate_time: new Date().valueOf(),
    truncating: 0,
    last_exit: 0,
    options: options ?? {},
  };

  async function init_term() {
    const args: string[] = [];

    const options = terminals[name].options;
    if (options.args != null) {
      for (const arg of options.args) {
        if (typeof arg === "string") {
          args.push(arg);
        }
      }
    } else {
      const init_filename: string = console_init_filename(path);
      if (await exists(init_filename)) {
        args.push("--init-file");
        args.push(path_split(init_filename).tail);
      }
    }

    const s = path_split(path);
    const env = merge({ COCALC_TERMINAL_FILENAME: s.tail }, process.env);
    if (options.env != null) {
      merge(env, options.env);
    }
    if (env.TMUX) {
      // If TMUX was set for some reason in the environment that setup
      // a cocalc project (e.g., start hub in dev mode from tmux), then
      // TMUX is set even though terminal hasn't started tmux yet, which
      // confuses our open command.  So we explicitly unset it here.
      // https://unix.stackexchange.com/questions/10689/how-can-i-tell-if-im-in-a-tmux-session-from-a-bash-script
      delete env["TMUX"];
    }

    const command = options.command ? options.command : "/bin/bash";
    const cwd = s.head;

    try {
      terminals[name].history = (await callback(readFile, path)).toString();
    } catch (err) {
      console.log(`failed to load ${path} from disk`);
    }
    const term = spawn(command, args, { cwd, env });
    logger.debug(
      "terminal",
      "init_term",
      name,
      "pid=",
      term.pid,
      "command=",
      command,
      "args",
      args
    );
    terminals[name].term = term;

    const save_history_to_disk = throttle(async () => {
      try {
        await callback(writeFile, path, terminals[name].history);
      } catch (err) {
        console.log(`failed to save ${path} to disk`);
      }
    }, 15000);

    term.on("data", function (data): void {
      //logger.debug("terminal: term --> browsers", name, data);
      handle_backend_messages(data);
      terminals[name].history += data;
      save_history_to_disk();
      const n = terminals[name].history.length;
      if (n >= MAX_HISTORY_LENGTH) {
        logger.debug("terminal data -- truncating");
        terminals[name].history = terminals[name].history.slice(
          n - MAX_HISTORY_LENGTH / 2
        );
        const last = terminals[name].last_truncate_time;
        const now = new Date().valueOf();
        terminals[name].last_truncate_time = now;
        logger.debug("terminal", now, last, now - last, truncate_thresh_ms);
        if (now - last <= truncate_thresh_ms) {
          // getting a huge amount of data quickly.
          if (!terminals[name].truncating) {
            channel.write({ cmd: "burst" });
          }
          terminals[name].truncating += data.length;
          setTimeout(check_if_still_truncating, check_interval_ms);
          if (terminals[name].truncating >= 5 * MAX_HISTORY_LENGTH) {
            // only start sending control+c if output has been completely stuck
            // being truncated several times in a row -- it has to be a serious non-stop burst...
            term.write("\u0003");
          }
          return;
        } else {
          terminals[name].truncating = 0;
        }
      }
      if (!terminals[name].truncating) {
        channel.write(data);
      }
    });

    let backend_messages_state: "NONE" | "READING" = "NONE";
    let backend_messages_buffer: string = "";
    function reset_backend_messages_buffer(): void {
      backend_messages_buffer = "";
      backend_messages_state = "NONE";
    }
    function handle_backend_messages(data: string): void {
      /* parse out messages like this:
            \x1b]49;"valid JSON string here"\x07
         and format and send them via our json channel.
         NOTE: such messages also get sent via the
         normal channel, but ignored by the client.
      */
      if (backend_messages_state === "NONE") {
        const i = data.indexOf("\x1b");
        if (i === -1) {
          return; // nothing to worry about
        }
        // stringify it so it is easy to see what is there:
        backend_messages_state = "READING";
        backend_messages_buffer = data.slice(i);
      } else {
        backend_messages_buffer += data;
      }
      if (
        backend_messages_buffer.length >= 5 &&
        backend_messages_buffer.slice(1, 5) != "]49;"
      ) {
        reset_backend_messages_buffer();
        return;
      }
      if (backend_messages_buffer.length >= 6) {
        const i = backend_messages_buffer.indexOf("\x07");
        if (i === -1) {
          // continue to wait... unless too long
          if (backend_messages_buffer.length > 10000) {
            reset_backend_messages_buffer();
          }
          return;
        }
        const s = backend_messages_buffer.slice(5, i);
        reset_backend_messages_buffer();
        logger.debug(
          `handle_backend_message: parsing JSON payload ${JSON.stringify(s)}`
        );
        try {
          const payload = JSON.parse(s);
          channel.write({ cmd: "message", payload });
        } catch (err) {
          logger.warn(
            `handle_backend_message: error sending JSON payload ${JSON.stringify(
              s
            )}, ${err}`
          );
          // Otherwise, ignore...
        }
      }
    }

    function check_if_still_truncating(): void {
      if (!terminals[name].truncating) return;
      if (
        new Date().valueOf() - terminals[name].last_truncate_time >=
        check_interval_ms
      ) {
        // turn off truncating, and send recent data.
        const { truncating, history } = terminals[name];
        channel.write(history.slice(Math.max(0, history.length - truncating)));
        terminals[name].truncating = 0;
        channel.write({ cmd: "no-burst" });
      } else {
        setTimeout(check_if_still_truncating, check_interval_ms);
      }
    }

    // Whenever term ends, we just respawn it.
    term.on("exit", async function () {
      logger.debug("terminal", name, "EXIT -- spawning again");
      const now = new Date().getTime();
      if (now - terminals[name].last_exit <= 15000) {
        // frequent exit; we wait a few seconds, since otherwise
        // restarting could burn all cpu and break everything.
        logger.debug(
          "terminal",
          name,
          "EXIT -- waiting a few seconds before trying again..."
        );
        await delay(3000);
      }
      terminals[name].last_exit = now;
      init_term();
    });

    // set the size
    resize();
  }
  await init_term();

  function resize() {
    //logger.debug("resize");
    if (
      terminals[name] === undefined ||
      terminals[name].client_sizes === undefined ||
      terminals[name].term === undefined
    ) {
      return;
    }
    const sizes = terminals[name].client_sizes;
    if (len(sizes) === 0) return;
    const INFINITY = 999999;
    let rows: number = INFINITY,
      cols: number = INFINITY;
    for (const id in sizes) {
      if (sizes[id].rows) {
        // if, since 0 rows or 0 columns means *ignore*.
        rows = Math.min(rows, sizes[id].rows);
      }
      if (sizes[id].cols) {
        cols = Math.min(cols, sizes[id].cols);
      }
    }
    if (rows === INFINITY || cols === INFINITY) {
      // no clients currently visible
      delete terminals[name].size;
      return;
    }
    //logger.debug("resize", "new size", rows, cols);
    if (rows && cols) {
      try {
        terminals[name].term.resize(cols, rows);
      } catch (err) {
        logger.debug(
          "terminal channel",
          `WARNING: unable to resize term ${err}`
        );
      }
      channel.write({ cmd: "size", rows, cols });
    }
  }

  channel.on("connection", function (spark: any): void {
    // Now handle the connection
    logger.debug(
      "terminal channel",
      `new connection from ${spark.address.ip} -- ${spark.id}`
    );
    // send current size info
    if (terminals[name].size !== undefined) {
      const { rows, cols } = terminals[name].size;
      spark.write({ cmd: "size", rows, cols });
    }
    // send burst info
    if (terminals[name].truncating) {
      spark.write({ cmd: "burst" });
    }
    // send history
    spark.write(terminals[name].history);
    // have history, so do not ignore commands now.
    spark.write({ cmd: "no-ignore" });
    spark.on("close", function () {
      delete terminals[name].client_sizes[spark.id];
      resize();
    });
    spark.on("end", function () {
      delete terminals[name].client_sizes[spark.id];
      resize();
    });
    spark.on("data", async function (data) {
      //logger.debug("terminal: browser --> term", name, JSON.stringify(data));
      if (typeof data === "string") {
        try {
          terminals[name].term.write(data);
        } catch (err) {
          spark.write(err.toString());
        }
      } else if (typeof data === "object") {
        // control message
        //logger.debug("terminal channel control message", JSON.stringify(data));
        switch (data.cmd) {
          case "size":
            terminals[name].client_sizes[spark.id] = {
              rows: data.rows,
              cols: data.cols,
            };
            try {
              resize();
            } catch (err) {
              // no-op -- can happen if terminal is restarting.
              logger.debug("terminal size", name, terminals[name].options, err);
            }
            break;

          case "set_command":
            if (
              isEqual(
                [data.command, data.args],
                [terminals[name].options.command, terminals[name].options.args]
              )
            ) {
              // no actual change.
              break;
            }
            terminals[name].options.command = data.command;
            terminals[name].options.args = data.args;
            // Also kill it so will respawn with new command/args:
            process.kill(terminals[name].term.pid, "SIGKILL");
            break;

          case "kill":
            // send kill signal
            process.kill(terminals[name].term.pid, "SIGKILL");
            break;

          case "cwd":
            // we reply with the current working directory of the underlying terminal process
            const pid = terminals[name].term.pid;
            const home = process.env.HOME ?? "/home/user";
            try {
              const cwd = await readlink(`/proc/${pid}/cwd`);
              logger.debug(`terminal cwd sent back: ${cwd}`);
              // we send back a relative path, because the webapp does not understand absolute paths
              const path = cwd.startsWith(home)
                ? cwd.slice(home.length + 1)
                : cwd;
              spark.write({ cmd: "cwd", payload: path });
            } catch {
              // ignoring errors
            }
            break;

          case "boot":
            // delete all sizes except this one, so at least kick resets
            // the sizes no matter what.
            for (const id in terminals[name].client_sizes) {
              if (id !== spark.id) {
                delete terminals[name].client_sizes[id];
              }
            }
            // next tell this client to go fullsize.
            if (terminals[name].size !== undefined) {
              const { rows, cols } = terminals[name].size;
              if (rows && cols) {
                spark.write({ cmd: "size", rows, cols });
              }
            }
            // broadcast message to all other clients telling them to close.
            channel.forEach(function (spark0, id, _) {
              if (id !== spark.id) {
                spark0.write({ cmd: "close" });
              }
            });
            break;
        }
      }
    });
  });

  return name;
}
