import { Output, Link, RawOutput, TTimer } from "./OutputTypes";
import { Router } from "../ui/GameRoot";
import { Page } from "../ui/Router";
import { Player } from "@player";
import { HacknetServer } from "../Hacknet/HacknetServer";
import { BaseServer } from "../Server/BaseServer";
import { Server } from "../Server/Server";
import { CompletedProgramName } from "@enums";
import { CodingContractResult } from "../CodingContract/Contract";
import { TerminalEvents, TerminalClearEvents } from "./TerminalEvents";

import { TextFile } from "../TextFile";
import { Script } from "../Script/Script";
import { hasScriptExtension } from "../Paths/ScriptFilePath";
import { CONSTANTS } from "../Constants";
import { GetServer } from "../Server/AllServers";

import { checkIfConnectedToDarkweb } from "../DarkWeb/DarkWeb";
import { iTutorialNextStep, iTutorialSteps, ITutorial } from "../InteractiveTutorial";
import { processSingleServerGrowth, getWeakenEffect } from "../Server/ServerHelpers";
import { parseCommand, parseCommands } from "./Parser";
import { SpecialServers } from "../Server/data/SpecialServers";
import { Settings } from "../Settings/Settings";
import { createProgressBarText } from "../utils/helpers/createProgressBarText";
import {
  calculateHackingChance,
  calculateHackingExpGain,
  calculatePercentMoneyHacked,
  calculateHackingTime,
  calculateGrowTime,
  calculateWeakenTime,
} from "../Hacking";
import { formatExp, formatMoney, formatPercent, formatRam, formatSecurity } from "../ui/formatNumber";
import { convertTimeMsToTimeElapsedString } from "../utils/StringHelperFunctions";

// TODO: Does every terminal function really need its own file...?
import { alias } from "./commands/alias";
import { analyze } from "./commands/analyze";
import { backdoor } from "./commands/backdoor";
import { buy } from "./commands/buy";
import { cat } from "./commands/cat";
import { cd } from "./commands/cd";
import { check } from "./commands/check";
import { connect } from "./commands/connect";
import { cp } from "./commands/cp";
import { download } from "./commands/download";
import { upload } from "./commands/upload";
import { expr } from "./commands/expr";
import { free } from "./commands/free";
import { grep } from "./commands/grep";
import { grow } from "./commands/grow";
import { hack } from "./commands/hack";
import { help } from "./commands/help";
import { history } from "./commands/history";
import { home } from "./commands/home";
import { hostname } from "./commands/hostname";
import { ipaddr } from "./commands/ipaddr";
import { kill } from "./commands/kill";
import { killall } from "./commands/killall";
import { ls } from "./commands/ls";
import { lscpu } from "./commands/lscpu";
import { mem } from "./commands/mem";
import { mv } from "./commands/mv";
import { nano } from "./commands/nano";
import { ps } from "./commands/ps";
import { rm } from "./commands/rm";
import { run } from "./commands/run";
import { scan } from "./commands/scan";
import { scananalyze } from "./commands/scananalyze";
import { scp } from "./commands/scp";
import { sudov } from "./commands/sudov";
import { tail } from "./commands/tail";
import { top } from "./commands/top";
import { unalias } from "./commands/unalias";
import { vim } from "./commands/vim";
import { weaken } from "./commands/weaken";
import { wget } from "./commands/wget";
import { commitHash } from "../utils/helpers/commitHash";
import { apr1 } from "./commands/apr1";
import { changelog } from "./commands/changelog";
import { clear } from "./commands/clear";
import { mkdir } from "./commands/mkdir";
import { currentNodeMults } from "../BitNode/BitNodeMultipliers";
import { Engine } from "../engine";
import { Directory, resolveDirectory, root } from "../Paths/Directory";
import { FilePath, isBasicFilePath, resolveFilePath } from "../Paths/FilePath";
import { hasTextExtension } from "../Paths/TextFilePath";
import { ContractFilePath } from "../Paths/ContractFilePath";
import { ServerConstants } from "../Server/data/Constants";
import { isIPAddress } from "../Types/strings";
import { getRewardFromCache } from "../DarkNet/effects/cacheFiles";
import { DarknetServer } from "../Server/DarknetServer";

export const TerminalCommands: Record<string, (args: (string | number | boolean)[], server: BaseServer) => void> = {
  "scan-analyze": scananalyze,
  alias: alias,
  analyze: analyze,
  backdoor: backdoor,
  buy: buy,
  cat: cat,
  cd: cd,
  changelog: changelog,
  check: check,
  clear: clear,
  cls: clear,
  connect: connect,
  cp: cp,
  download: download,
  upload: upload,
  expr: expr,
  free: free,
  grep: grep,
  grow: grow,
  hack: hack,
  help: help,
  history: history,
  home: home,
  hostname: hostname,
  ipaddr: ipaddr,
  kill: kill,
  killall: killall,
  ls: ls,
  lscpu: lscpu,
  mem: mem,
  mv: mv,
  nano: nano,
  ps: ps,
  rm: rm,
  run: run,
  scan: scan,
  scp: scp,
  sudov: sudov,
  tail: tail,
  apr1: apr1,
  top: top,
  unalias: unalias,
  vim: vim,
  weaken: weaken,
  wget: wget,
  mkdir: mkdir,
};

// "mkdir" is a "hidden" command; i.e., it is not shown in help text or autocomplete.
export const supportedCommands = Object.keys(TerminalCommands).filter((command) => command !== "mkdir");

export class Terminal {
  // Flags to determine whether the player is currently running a hack or an analyze
  action: TTimer | null = null;

  commandHistory: string[] = [];
  commandHistoryIndex = 0;

  outputHistory: (Output | Link | RawOutput)[] = [
    new Output(`Bitburner v${CONSTANTS.VersionString} (${commitHash()})`, "primary"),
  ];

  // True if a Coding Contract prompt is opened
  contractOpen = false;
  // True if a prompt is opened via the ns.prompt() API
  nsPromptApiOpen = false;

  // Path of current directory
  currDir = "" as Directory;

  process(cycles: number): void {
    if (this.action === null) return;
    this.action.timeLeft -= (CONSTANTS.MilliPerCycle * cycles) / 1000;
    if (this.action.timeLeft < 0.01) this.finishAction(false);
  }

  append(item: Output | Link | RawOutput): void {
    this.outputHistory.push(item);
    if (this.outputHistory.length > Settings.MaxTerminalCapacity) {
      this.outputHistory.splice(0, this.outputHistory.length - Settings.MaxTerminalCapacity);
    }
    TerminalEvents.emit();
  }

  print(s: string): void {
    this.append(new Output(s, "primary"));
  }

  printRaw(node: React.ReactNode): void {
    this.append(new RawOutput(node));
  }

  error(s: string): void {
    this.append(new Output(s, "error"));
  }

  success(s: string): void {
    this.append(new Output(s, "success"));
  }

  info(s: string): void {
    this.append(new Output(s, "info"));
  }

  warn(s: string): void {
    this.append(new Output(s, "warn"));
  }

  startHack(): void {
    // Hacking through Terminal should be faster than hacking through a script
    const server = Player.getCurrentServer();
    if (server instanceof HacknetServer) {
      this.error("Cannot hack this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");
    this.startAction(calculateHackingTime(server, Player) / 4, "h", server);
  }

  startGrow(): void {
    const server = Player.getCurrentServer();
    if (server instanceof HacknetServer) {
      this.error("Cannot grow this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");
    this.startAction(calculateGrowTime(server, Player) / 16, "g", server);
  }
  startWeaken(): void {
    const server = Player.getCurrentServer();
    if (server instanceof HacknetServer) {
      this.error("Cannot weaken this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");
    this.startAction(calculateWeakenTime(server, Player) / 16, "w", server);
  }

  startBackdoor(): void {
    // Backdoor should take the same amount of time as hack
    const server = Player.getCurrentServer();
    if (server instanceof HacknetServer) {
      this.error("Cannot backdoor this kind of server");
      return;
    }
    if (!(server instanceof Server || server instanceof DarknetServer))
      throw new Error("server should be normal server");
    this.startAction(calculateHackingTime(server, Player) / 4, "b", server);
  }

  startAnalyze(): void {
    this.print("Analyzing system...");
    const server = Player.getCurrentServer();
    this.startAction(1, "a", server);
  }

  startAction(n: number, action: "h" | "b" | "a" | "g" | "w" | "c", server?: BaseServer): void {
    this.action = new TTimer(n, action, server);
  }

  // Complete the hack/analyze command
  finishHack(server: BaseServer, cancelled = false): void {
    if (cancelled) return;

    if (server instanceof HacknetServer) {
      this.error("Cannot hack this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");

    // Calculate whether hack was successful
    const hackChance = calculateHackingChance(server, Player);
    const rand = Math.random();
    let expGainedOnSuccess = calculateHackingExpGain(server, Player);
    const expGainedOnFailure = expGainedOnSuccess / 4;
    if (rand < hackChance) {
      // Success!
      server.backdoorInstalled = true;
      if (SpecialServers.WorldDaemon === server.hostname) {
        Router.toPage(Page.BitVerse, { flume: false, quick: false });
        return;
      }
      // Manually check for faction invitations
      Engine.Counters.checkFactionInvitations = 0;
      Engine.checkCounters();

      let moneyDrained = server.moneyAvailable * calculatePercentMoneyHacked(server, Player);

      // Over-the-top safety checks
      if (moneyDrained < 0) {
        moneyDrained = 0;
      }
      if (moneyDrained > server.moneyAvailable) {
        moneyDrained = server.moneyAvailable;
      }

      if (moneyDrained === 0) {
        expGainedOnSuccess = expGainedOnFailure;
      }

      server.moneyAvailable -= moneyDrained;
      if (server.moneyAvailable < 0) {
        server.moneyAvailable = 0;
      }

      const moneyGained = moneyDrained * currentNodeMults.ManualHackMoney;
      Player.gainMoney(moneyGained, "hacking");
      Player.gainHackingExp(expGainedOnSuccess);
      if (expGainedOnSuccess > 1) {
        Player.gainIntelligenceExp(4 * Math.log10(expGainedOnSuccess));
      }

      const oldSec = server.hackDifficulty;
      server.fortify(ServerConstants.ServerFortifyAmount);
      const newSec = server.hackDifficulty;

      this.print(
        `Hack successful on '${server.hostname}'! Gained ${formatMoney(moneyGained, true)} and ${formatExp(
          expGainedOnSuccess,
        )} hacking exp`,
      );
      this.print(
        `Security increased on '${server.hostname}' from ${formatSecurity(oldSec)} to ${formatSecurity(newSec)}`,
      );
    } else {
      // Failure
      Player.gainHackingExp(expGainedOnFailure);
      this.print(`Failed to hack '${server.hostname}'. Gained ${formatExp(expGainedOnFailure)} hacking exp`);
    }
  }

  finishGrow(server: BaseServer, cancelled = false): void {
    if (cancelled) return;

    if (server instanceof HacknetServer) {
      this.error("Cannot grow this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");
    const expGain = calculateHackingExpGain(server, Player);
    const oldSec = server.hackDifficulty;
    const growth = processSingleServerGrowth(server, 25, server.cpuCores);
    const newSec = server.hackDifficulty;

    Player.gainHackingExp(expGain);
    this.print(
      `Available money on '${server.hostname}' grown by ${formatPercent(growth - 1, 6)}. Gained ${formatExp(
        expGain,
      )} hacking exp.`,
    );
    this.print(
      `Security increased on '${server.hostname}' from ${formatSecurity(oldSec)} to ${formatSecurity(newSec)}`,
    );
  }

  finishWeaken(server: BaseServer, cancelled = false): void {
    if (cancelled) return;

    if (server instanceof HacknetServer) {
      this.error("Cannot weaken this kind of server");
      return;
    }
    if (!(server instanceof Server)) throw new Error("server should be normal server");
    const expGain = calculateHackingExpGain(server, Player);
    const oldSec = server.hackDifficulty;
    const weakenAmt = getWeakenEffect(1, server.cpuCores);
    server.weaken(weakenAmt);
    const newSec = server.hackDifficulty;

    Player.gainHackingExp(expGain);
    this.print(
      `Security decreased on '${server.hostname}' by ${formatSecurity(weakenAmt)} from ${formatSecurity(
        oldSec,
      )} to ${formatSecurity(newSec)} (min: ${formatSecurity(server.minDifficulty)})` +
        ` and Gained ${formatExp(expGain)} hacking exp.`,
    );
  }

  finishBackdoor(server: BaseServer, cancelled = false): void {
    if (!cancelled) {
      if (server instanceof HacknetServer) {
        this.error("Cannot hack this kind of server");
        return;
      }
      if (!(server instanceof Server || server instanceof DarknetServer))
        throw new Error("server should be normal server");
      server.backdoorInstalled = true;
      if (SpecialServers.WorldDaemon === server.hostname) {
        if (Player.bitNodeN == null) {
          Player.bitNodeN = 1;
        }
        Router.toPage(Page.BitVerse, { flume: false, quick: false });
        return;
      }
      // Manunally check for faction invites
      Engine.Counters.checkFactionInvitations = 0;
      Engine.checkCounters();

      this.print(`Backdoor on '${server.hostname}' successful!`);
    }
  }

  finishAnalyze(currServ: BaseServer, cancelled = false): void {
    if (!cancelled) {
      const isHacknet = currServ instanceof HacknetServer;
      this.print(currServ.hostname + ": ");
      const org = currServ.organizationName;
      this.print("Organization name: " + (!isHacknet ? org : "player"));
      const hasAdminRights = (!isHacknet && currServ.hasAdminRights) || isHacknet;
      this.print("Root Access: " + (hasAdminRights ? "YES" : "NO"));
      const canRunScripts = hasAdminRights && currServ.maxRam > 0;
      this.print("Can run scripts on this host: " + (canRunScripts ? "YES" : "NO"));
      this.print("RAM: " + formatRam(currServ.maxRam));
      if (currServ instanceof DarknetServer && currServ.blockedRam) {
        this.print("RAM blocked by owner: " + formatRam(currServ.blockedRam));
        this.print("Stasis link: " + (currServ.hasStasisLink ? "YES" : "NO"));
        this.print("Backdoor: " + (currServ.backdoorInstalled ? "YES" : "NO"));
      }
      if (currServ instanceof Server) {
        this.print("Backdoor: " + (currServ.backdoorInstalled ? "YES" : "NO"));
        const hackingSkill = currServ.requiredHackingSkill;
        this.print("Required hacking skill for hack() and backdoor: " + (!isHacknet ? hackingSkill : "N/A"));
        const security = currServ.hackDifficulty;
        this.print("Server security level: " + (!isHacknet ? formatSecurity(security) : "N/A"));
        const hackingChance = calculateHackingChance(currServ, Player);
        this.print("Chance to hack: " + (!isHacknet ? formatPercent(hackingChance) : "N/A"));
        const hackingTime = calculateHackingTime(currServ, Player) * 1000;
        this.print("Time to hack: " + (!isHacknet ? convertTimeMsToTimeElapsedString(hackingTime, true) : "N/A"));
      }
      this.print(
        `Total money available on server: ${
          currServ instanceof Server ? formatMoney(currServ.moneyAvailable, true) : "N/A"
        }`,
      );
      if (currServ instanceof Server) {
        const numPort = currServ.numOpenPortsRequired;
        this.print("Required number of open ports for NUKE: " + (!isHacknet ? numPort : "N/A"));
        this.print("SSH port: " + (currServ.sshPortOpen ? "Open" : "Closed"));
        this.print("FTP port: " + (currServ.ftpPortOpen ? "Open" : "Closed"));
        this.print("SMTP port: " + (currServ.smtpPortOpen ? "Open" : "Closed"));
        this.print("HTTP port: " + (currServ.httpPortOpen ? "Open" : "Closed"));
        this.print("SQL port: " + (currServ.sqlPortOpen ? "Open" : "Closed"));
      }
    }
  }

  finishAction(cancelled = false): void {
    if (this.action === null) {
      if (!cancelled) throw new Error("Finish action called when there was no action");
      return;
    }

    if (!this.action.server) throw new Error("Missing action target server");

    this.print(this.getProgressText());
    if (this.action.action === "h") {
      this.finishHack(this.action.server, cancelled);
    } else if (this.action.action === "g") {
      this.finishGrow(this.action.server, cancelled);
    } else if (this.action.action === "w") {
      this.finishWeaken(this.action.server, cancelled);
    } else if (this.action.action === "b") {
      this.finishBackdoor(this.action.server, cancelled);
    } else if (this.action.action === "a") {
      this.finishAnalyze(this.action.server, cancelled);
    } else if (this.action.action === "c" && this.action.server instanceof DarknetServer) {
      const cache = this.action.server.caches.pop();
      if (!cache) {
        this.print("No cache files found.");
      } else {
        const result = getRewardFromCache(this.action.server, cache, true);
        this.print(result.message);
      }
    }

    if (cancelled) {
      this.print("Cancelled");
    }
    this.action = null;
    TerminalEvents.emit();
  }

  getFile(filename: string): Script | TextFile | string | null {
    if (hasScriptExtension(filename)) return this.getScript(filename);
    if (hasTextExtension(filename)) return this.getTextFile(filename);
    if (filename.endsWith(".lit")) return this.getLitFile(filename);
    return null;
  }

  getFilepath(path: string, useAbsolute?: boolean): FilePath | null {
    // If path starts with a slash, consider it to be an absolute path
    if (useAbsolute || path.startsWith("/")) return resolveFilePath(path);
    // Otherwise, force path to be seen as relative to the current directory.
    path = "./" + path;
    return resolveFilePath(path, this.currDir);
  }

  getDirectory(path: string, useAbsolute?: boolean): Directory | null {
    // If path starts with a slash, consider it to be an absolute path
    if (useAbsolute || path.startsWith("/")) return resolveDirectory(path);
    // Otherwise, force path to be seen as relative to the current directory.
    path = "./" + path;
    return resolveDirectory(path, this.currDir);
  }

  getScript(filename: string): Script | null {
    const server = Player.getCurrentServer();
    const filepath = this.getFilepath(filename);
    if (!filepath || !hasScriptExtension(filepath)) return null;
    return server.scripts.get(filepath) ?? null;
  }

  getTextFile(filename: string): TextFile | null {
    const server = Player.getCurrentServer();
    const filepath = this.getFilepath(filename);
    if (!filepath || !hasTextExtension(filepath)) return null;
    return server.textFiles.get(filepath) ?? null;
  }

  getLitFile(filename: string): string | null {
    const s = Player.getCurrentServer();
    const filepath = this.getFilepath(filename);
    if (!filepath) return null;
    for (const lit of s.messages) {
      if (typeof lit === "string" && filepath === lit) {
        return lit;
      }
    }

    return null;
  }

  cwd(): Directory {
    return this.currDir;
  }

  setcwd(dir: Directory): void {
    this.currDir = dir;
    TerminalEvents.emit();
  }

  async runContract(contractPath: ContractFilePath): Promise<void> {
    // There's already an opened contract
    if (this.contractOpen) {
      return this.error("There's already a Coding Contract in Progress");
    }

    const server = Player.getCurrentServer();
    const contract = server.getContract(contractPath);
    if (!contract) {
      return this.error("No such contract");
    }

    this.contractOpen = true;
    const promptResult = await contract.prompt();

    // Get a new copy of the server, in case it changed while the prompt was open
    const postPromptServer = GetServer(server.hostname);

    // Check if the contract still exists by the time the promise is fulfilled
    if (postPromptServer?.getContract(contractPath) == null) {
      this.contractOpen = false;
      return this.error("Contract no longer exists (Was it solved by a script?)");
    }

    switch (promptResult.result) {
      case CodingContractResult.Success:
        if (contract.reward !== null) {
          const reward = Player.gainCodingContractReward(
            contract.reward,
            contract.getDifficulty(),
            contract.rewardScaling,
          );
          this.print(`Contract SUCCESS - ${reward}`);
        }
        server.removeContract(contract);
        break;
      case CodingContractResult.InvalidFormat:
        this.error(
          `Contract FAILED - ${
            promptResult.message ?? `The answer is not in the right format for contract '${contract.type}'`
          }`,
        );
        break;
      case CodingContractResult.Failure:
        ++contract.tries;
        if (contract.tries >= contract.getMaxNumTries()) {
          this.error("Contract FAILED - Contract is now self-destructing");
          const solution = contract.getAnswer();
          if (solution !== null) {
            this.error(`Coding Contract solution was: ${solution}`);
          }
          server.removeContract(contract);
        } else {
          this.error(`Contract FAILED - ${contract.getMaxNumTries() - contract.tries} tries remaining`);
        }
        break;
      case CodingContractResult.Cancelled:
        this.print("Contract cancelled");
        break;
      default: {
        const __: never = promptResult.result;
      }
    }
    this.contractOpen = false;
  }

  executeScanAnalyzeCommand(depth = 1, all = false): void {
    interface Node {
      hostname: string;
      children: Node[];
    }

    const ignoreServer = (s: BaseServer, d: number): boolean =>
      (!all && s.purchasedByPlayer && s.hostname != "home") ||
      d > depth ||
      (!all && s instanceof HacknetServer) ||
      (!all && s instanceof DarknetServer && s.hostname !== SpecialServers.DarkWeb);

    const makeNode = (root: BaseServer = Player.getCurrentServer()) => {
      // Keep track of previously seen servers to prevent backtracking (since darknet can be cyclical)
      const seenServers = [root.hostname];
      const populateNode = (s: BaseServer, d = 1): Node => {
        seenServers.push(s.hostname);
        return {
          hostname: s.hostname,
          children: s.serversOnNetwork
            .filter((h) => !seenServers.includes(h))
            .map((s) => GetServer(s))
            .filter((v): v is BaseServer => !!v)
            .filter((v) => !ignoreServer(v, d))
            .map((h) => populateNode(h, d + 1)),
        };
      };
      return populateNode(root);
    };

    const root = makeNode();

    const printOutput = (node: Node, prefix = ["  "], last = true) => {
      const titlePrefix = prefix.slice(0, prefix.length - 1).join("") + (last ? "┗ " : "┣ ");
      const infoPrefix = prefix.join("") + (node.children.length > 0 ? "┃   " : "    ");
      if (Player.hasProgram(CompletedProgramName.autoLink)) {
        this.append(new Link(titlePrefix, node.hostname));
      } else {
        this.print(titlePrefix + node.hostname + "\n");
      }

      const server = GetServer(node.hostname);
      if (!server) return;
      const hasRoot = server.hasAdminRights ? "YES" : "NO";
      if (server instanceof Server) {
        this.print(
          `${infoPrefix}Root Access: ${hasRoot}, Required hacking skill: ${server.requiredHackingSkill}` + "\n",
        );
        this.print(`${infoPrefix}Number of open ports required to NUKE: ${server.numOpenPortsRequired}` + "\n");
      } else {
        this.print(`${infoPrefix}Root Access: ${hasRoot}` + "\n");
      }
      this.print(`${infoPrefix}RAM: ${formatRam(server.maxRam)}` + "\n");
      node.children.forEach((n, i) =>
        printOutput(n, [...prefix, i === node.children.length - 1 ? "  " : "┃ "], i === node.children.length - 1),
      );
    };

    printOutput(root);
  }

  connectToServer(hostname: string, singularity = false): void {
    const server = GetServer(hostname);
    if (server === null) {
      this.error("Invalid server. Connection failed.");
      return;
    }
    Player.getCurrentServer().isConnectedTo = false;
    Player.currentServer = server.hostname;
    server.isConnectedTo = true;
    this.setcwd(root);
    if (!singularity) {
      this.print("Connected to " + `${isIPAddress(hostname) ? server.ip : server.hostname}`);
      if (Player.getCurrentServer().hostname === "darkweb") {
        checkIfConnectedToDarkweb(); // Posts a 'help' message if connecting to dark web
      }
    }
  }

  executeCommands(commands: string): void {
    // Handle Terminal History - multiple commands should be saved as one
    if (this.commandHistory[this.commandHistory.length - 1] != commands) {
      this.commandHistory.push(commands);
      if (this.commandHistory.length > 50) {
        this.commandHistory.splice(0, 1);
      }
      Player.terminalCommandHistory = this.commandHistory;
    }
    this.commandHistoryIndex = this.commandHistory.length;
    const allCommands = parseCommands(commands);
    for (const command of allCommands) this.executeCommand(command);
  }

  clear(): void {
    this.outputHistory = [new Output(`Bitburner v${CONSTANTS.VersionString} (${commitHash()})`, "primary")];
    TerminalEvents.emit();
    TerminalClearEvents.emit();
  }

  prestige(): void {
    this.action = null;
    this.clear();
  }

  executeCommand(command: string): void {
    if (this.action !== null) return this.error(`Cannot execute command (${command}) while an action is in progress`);

    const commandArray = parseCommand(command);
    if (!commandArray.length) return;

    const currentServer = Player.getCurrentServer();
    /****************** Interactive Tutorial Terminal Commands ******************/
    if (ITutorial.isRunning) {
      const n00dlesServ = GetServer("n00dles");
      if (n00dlesServ == null) {
        throw new Error("Could not get n00dles server");
      }
      const errorMessageForBadCommand =
        "Bad command. Please follow the tutorial or click 'Exit Tutorial' if you'd like to skip it.";
      switch (ITutorial.currStep) {
        case iTutorialSteps.TerminalHelp:
          if (commandArray.length === 1 && commandArray[0] === "help") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalLs:
          if (commandArray.length === 1 && commandArray[0] === "ls") {
            iTutorialNextStep();
          } else if (commandArray[0] === "1s") {
            this.error("Command '1s' not found. Did you mean 'ls' with a lowercase L?");
            return;
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalScan:
          if (commandArray.length === 1 && commandArray[0] === "scan") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalScanAnalyze1:
          if (commandArray.length === 1 && commandArray[0] === "scan-analyze") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalScanAnalyze2:
          if (commandArray.length === 2 && commandArray[0] === "scan-analyze" && commandArray[1] === 2) {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalConnect:
          if (commandArray.length === 2) {
            if (
              commandArray[0] === "connect" &&
              (commandArray[1] === "n00dles" || commandArray[1] === n00dlesServ.hostname)
            ) {
              iTutorialNextStep();
            } else {
              this.error("Wrong command! Try again!");
              return;
            }
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalAnalyze:
          if (commandArray.length === 1 && commandArray[0] === "analyze") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalNuke:
          if (commandArray.length === 2 && commandArray[0] === "run" && commandArray[1] === "NUKE.exe") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalManualHack:
          if (commandArray.length === 1 && commandArray[0] === "hack") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalHackingMechanics:
          if (commandArray.length !== 1 || !["grow", "weaken", "hack"].includes(commandArray[0] + "")) {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalGoHome:
          if (commandArray.length === 1 && commandArray[0] === "home") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalCreateScript:
          if (commandArray.length === 2 && commandArray[0] === "nano" && commandArray[1] === "n00dles.js") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalFree:
          if (commandArray.length === 1 && commandArray[0] === "free") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.TerminalRunScript:
          if (commandArray.length === 2 && commandArray[0] === "run" && commandArray[1] === "n00dles.js") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        case iTutorialSteps.ActiveScriptsToTerminal:
          if (commandArray.length === 2 && commandArray[0] === "tail" && commandArray[1] === "n00dles.js") {
            iTutorialNextStep();
          } else {
            this.error(errorMessageForBadCommand);
            return;
          }
          break;
        default:
          this.error("Please follow the tutorial or click 'Exit Tutorial' if you'd like to skip it");
          return;
      }
    }
    /****************** END INTERACTIVE TUTORIAL ******************/
    /* Command parser */

    const commandName = commandArray[0];
    if (typeof commandName !== "string") return this.error(`${commandName} is not a valid command.`);
    // run by path command
    if (isBasicFilePath(commandName)) return run(commandArray, currentServer);

    // Aside from the run-by-path command, we don't need the first entry once we've stored it in commandName.
    commandArray.shift();

    const f = TerminalCommands[commandName.toLowerCase()];
    if (!f) {
      const similarCommands = findSimilarCommands(commandName);
      const didYouMeanString = similarCommands.length ? ` Did you mean: ${similarCommands.join(" or ")}?` : "";
      return this.error(`Command ${commandName} not found.${didYouMeanString}`);
    }

    f(commandArray, currentServer);
  }

  getProgressText(): string {
    if (this.action === null) throw new Error("trying to get the progress text when there's no action");
    return createProgressBarText({
      progress: (this.action.time - this.action.timeLeft) / this.action.time,
      totalTicks: 50,
    });
  }
}

function findSimilarCommands(command: string): string[] {
  const offByOneLetter = supportedCommands.filter((c) => {
    if (c.length !== command.length) return false;
    let diff = 0;
    for (let i = 0; i < c.length; i++) {
      if (c[i] !== command[i]) diff++;
    }
    return diff === 1;
  });
  const subset = supportedCommands.filter((c) => c.includes(command)).sort((a, b) => a.length - b.length);
  return Array.from(new Set([...offByOneLetter, ...subset])).slice(0, 3);
}
