import { Aliases, GlobalAliases } from "../Alias";
import { DarkWebItems } from "../DarkWeb/DarkWebItems";
import { Player } from "@player";
import { GetAllServers } from "../Server/AllServers";
import { parseCommand, parseCommands } from "./Parser";
import { HelpTexts } from "./HelpText";
import { compile } from "../NetscriptJSEvaluator";
import { Flags } from "../NetscriptFunctions/Flags";
import { AutocompleteData } from "@nsdefs";
import libarg from "arg";
import { getAllDirectories, resolveDirectory, root } from "../Paths/Directory";
import { isLegacyScript, resolveScriptFilePath } from "../Paths/ScriptFilePath";
import { enums } from "../NetscriptFunctions";
import { supportedCommands } from "./Terminal";
import { Terminal } from "../Terminal";
import { parseUnknownError } from "../utils/ErrorHelper";
import { DarknetServer } from "../Server/DarknetServer";
import { CompletedProgramName } from "@enums";

/** Extract the text being autocompleted, handling unclosed double quotes as a single token */
export function extractCurrentText(terminalText: string): string {
  const quoteCount = (terminalText.match(/"/g) || []).length;
  if (quoteCount % 2 === 1) return terminalText.substring(terminalText.lastIndexOf('"'));
  return /[^ ]*$/.exec(terminalText)?.[0] ?? "";
}

/** Suggest all completion possibilities for the last argument in the last command being typed
 * @param terminalText The current full text entered in the terminal
 * @param baseDir The current working directory.
 * @returns Array of possible string replacements for the current text being autocompleted.
 */
export async function getTabCompletionPossibilities(terminalText: string, baseDir = root): Promise<string[]> {
  // Get the current command text, treating unclosed quotes as a single token
  const currentText = extractCurrentText(terminalText);
  // Remove the current text from the commands string
  const valueWithoutCurrent = terminalText.substring(0, terminalText.length - currentText.length);
  // Parse the commands string, this handles alias replacement as well.
  const commands = parseCommands(valueWithoutCurrent);
  if (!commands.length) commands.push("");
  // parse the last command into a commandArgs array, but convert to string
  const commandArray = parseCommand(commands[commands.length - 1]).map(String);
  commandArray.push(currentText);

  /** How many separate strings make up the command, e.g. "run a" would result in 2 strings. */
  const commandLength = commandArray.length;

  // To prevent needing to convert currentArg to lowercase for every comparison
  const requiredMatch = currentText.toLowerCase();

  // If a relative directory is included in the path, this will store what the absolute path needs to start with to be valid
  let pathingRequiredMatch = currentText.toLowerCase();

  /** The directory portion of the current input */
  let relativeDir = "";
  const slashIndex = currentText.lastIndexOf("/");

  if (slashIndex !== -1) {
    relativeDir = currentText.substring(0, slashIndex + 1);
    const path = resolveDirectory(relativeDir, baseDir);
    // No valid terminal inputs contain a / that does not indicate a path
    if (path === null) return [];
    baseDir = path;
    pathingRequiredMatch = currentText.replace(/^.*\//, path).toLowerCase();
  } else if (baseDir !== root) {
    pathingRequiredMatch = (baseDir + currentText).toLowerCase();
  }

  const possibilities: string[] = [];
  const currServ = Player.getCurrentServer();
  const homeComputer = Player.getHomeComputer();

  // --- Functions for adding different types of data ---

  interface AddAllGenericOptions {
    // The iterable to iterate through the data
    iterable: Iterable<string>;
    // Whether the item can be pathed to. Typically this is true for files (programs are an exception)
    usePathing?: boolean;
    // Whether to exclude the current text as one of the autocomplete options
    ignoreCurrent?: boolean;
  }
  function addGeneric({ iterable, usePathing, ignoreCurrent }: AddAllGenericOptions) {
    const requiredStart = usePathing ? pathingRequiredMatch : requiredMatch;
    for (const member of iterable) {
      if (ignoreCurrent && member.length <= requiredStart.length) continue;
      if (member.toLowerCase().startsWith(requiredStart)) {
        possibilities.push(usePathing ? relativeDir + member.substring(baseDir.length) : member);
      }
    }
  }

  const addAliases = () => addGeneric({ iterable: Aliases.keys() });
  const addGlobalAliases = () => addGeneric({ iterable: GlobalAliases.keys() });
  const addCommands = () => addGeneric({ iterable: supportedCommands });
  const addDarkwebItems = () => addGeneric({ iterable: Object.values(DarkWebItems).map((item) => item.program) });
  const addServerNames = () =>
    addGeneric({
      iterable: GetAllServers()
        .filter((server) => server.serversOnNetwork.length !== 0)
        .map((server) => server.hostname),
    });
  const addScripts = () => addGeneric({ iterable: currServ.scripts.keys(), usePathing: true });
  const addTextFiles = () => addGeneric({ iterable: currServ.textFiles.keys(), usePathing: true });
  const addCodingContracts = () => {
    addGeneric({ iterable: currServ.contracts.map((contract) => contract.fn), usePathing: true });
  };

  const addLiterature = () => {
    addGeneric({ iterable: currServ.messages.filter((message) => message.endsWith(".lit")), usePathing: true });
  };

  const addMessages = () => {
    addGeneric({ iterable: currServ.messages.filter((message) => message.endsWith(".msg")), usePathing: true });
  };

  const addReachableServerNames = () => {
    addGeneric({
      iterable: GetAllServers(true)
        .filter(
          (server) =>
            server !== currServ &&
            (server.backdoorInstalled ||
              server.purchasedByPlayer ||
              currServ.serversOnNetwork.includes(server.hostname)),
        )
        .map((server) => server.hostname),
    });
  };

  const addPrograms = () => {
    // Only allow completed programs to autocomplete
    const programs = homeComputer.programs.filter((name) => name.endsWith(".exe"));
    // At all times, programs can be accessed without pathing
    addGeneric({ iterable: programs });

    const currentServer = Player.getCurrentServer();
    if (currentServer !== homeComputer) {
      const localPrograms = currentServer.programs.filter((name) => name.endsWith(".exe"));
      addGeneric({ iterable: localPrograms, usePathing: true });
    }

    // If we're on home and a path is being used, also include pathing results
    if (homeComputer.isConnectedTo && relativeDir) addGeneric({ iterable: programs, usePathing: true });
  };

  const addDirectories = () => {
    addGeneric({ iterable: getAllDirectories(currServ), usePathing: true, ignoreCurrent: true });
  };

  // Just some booleans so the mismatch between command length and arg number are not as confusing.
  const onCommand = commandLength === 1;
  const onFirstCommandArg = commandLength === 2;
  // const onSecondCommandArg = commandLength === 3; // unused

  // These are always added.
  addGlobalAliases();

  // If we're using a relative path, always add directories
  if (relativeDir) addDirectories();

  // -- Handling different commands -- //
  // Command is what is being autocompleted
  if (onCommand) {
    addAliases();
    addCommands();
    // Allow any relative pathing as a command arg to act as previous ./ command
    if (relativeDir) {
      addScripts();
      addPrograms();
      addCodingContracts();
    }
  }

  switch (commandArray[0]) {
    case "buy":
      addDarkwebItems();
      return possibilities;

    case "cat":
      addScripts();
      addTextFiles();
      addMessages();
      addLiterature();
      return possibilities;

    case "cd":
    case "ls":
    case "upload":
      if (onFirstCommandArg && !relativeDir) addDirectories();
      return possibilities;

    case "mem":
      if (onFirstCommandArg) addScripts();
      return possibilities;

    case "connect":
      if (onFirstCommandArg) addReachableServerNames();
      return possibilities;

    case "cp":
      if (onFirstCommandArg) {
        // We're autocompleting a source content file
        addScripts();
        addTextFiles();
      }
      return possibilities;

    case "download":
    case "mv":
      // download only takes one arg, and for mv we only want to autocomplete the first one
      if (onFirstCommandArg) {
        addScripts();
        addTextFiles();
      }
      return possibilities;

    case "help":
      if (onFirstCommandArg) {
        addGeneric({ iterable: Object.keys(HelpTexts), usePathing: false });
      }
      return possibilities;

    case "nano":
    case "vim":
      addScripts();
      addTextFiles();
      return possibilities;

    case "scp":
      if (!onFirstCommandArg) {
        addServerNames();
      }
      addScripts();
      addTextFiles();
      addLiterature();
      return possibilities;

    case "rm":
      addScripts();
      addPrograms();
      addLiterature();
      addTextFiles();
      addCodingContracts();
      return possibilities;

    case "run":
      if (onFirstCommandArg) {
        addPrograms();
        addCodingContracts();
        if (currServ instanceof DarknetServer) {
          addGeneric({ iterable: currServ.caches, usePathing: true });
        }
        addScripts();
      } else if (commandArray[1] === CompletedProgramName.serverProfiler) {
        addServerNames();
      } else {
        const options = await scriptAutocomplete();
        if (options) addGeneric({ iterable: options, usePathing: false });
      }
      return possibilities;

    case "check":
    case "tail":
    case "kill":
      if (onFirstCommandArg) addScripts();
      else {
        const options = await scriptAutocomplete();
        if (options) addGeneric({ iterable: options, usePathing: false });
      }
      return possibilities;

    default:
      if (!onCommand) {
        const options = await scriptAutocomplete();
        if (options) {
          addGeneric({ iterable: options, usePathing: false });
        }
      }
      return possibilities;
  }

  async function scriptAutocomplete(): Promise<string[] | undefined> {
    let inputCopy = commandArray.join(" ");
    if (commandLength >= 1 && commandArray[0] !== "run") inputCopy = "run " + inputCopy;
    const commands = parseCommands(inputCopy);
    if (commands.length === 0) return;
    const command = parseCommand(commands[commands.length - 1]);
    let filename = String(command[1]);
    if (!filename.startsWith("/")) {
      filename = "./" + filename;
    }
    const filepath = resolveScriptFilePath(filename, baseDir);
    if (!filepath) return; // Not a script path.
    if (isLegacyScript(filepath)) return; // Doesn't work with ns1.
    const script = currServ.scripts.get(filepath);
    if (!script) return; // Doesn't exist.

    let loadedModule;
    try {
      //Will return the already compiled module if recompilation not needed.
      loadedModule = await compile(script, currServ.scripts);
    } catch (e) {
      const errorData = parseUnknownError(e);
      Terminal.error(
        `Cannot compile ${filepath}. Reason: ${errorData.errorAsString}.${
          errorData.causeAsString ? ` Cause: ${errorData.causeAsString}` : ""
        }`,
      );
      return;
    }
    if (!loadedModule) {
      return;
    }
    // Return "--tail" if the player does not define the autocomplete function.
    if (!loadedModule.autocomplete) {
      return ["--tail"];
    }

    const runArgs = { "--tail": Boolean, "-t": Number, "--ram-override": Number, "--temporary": Boolean };
    let flags = {
      _: [],
    };
    try {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
      flags = libarg(runArgs, {
        permissive: true,
        argv: command.slice(2),
      });
    } catch (error) {
      /**
       * This error can only happen when the player specifies "-t" or "--ram-override", then presses [Tab] without
       * giving a number. We don't need to show an error here.
       */
      console.warn(error);
    }
    const flagFunc = Flags(flags._, true);
    const autocompleteData: AutocompleteData = {
      servers: GetAllServers()
        .filter((server) => server.serversOnNetwork.length !== 0)
        .map((server) => server.hostname),
      scripts: [...currServ.scripts.keys()],
      txts: [...currServ.textFiles.keys()],
      enums: enums,
      flags: (schema: unknown) => {
        if (!Array.isArray(schema)) {
          throw new Error("The schema passed to AutocompleteData.flags must be an array of arrays");
        }
        pos2 = schema.map((flag: unknown) => {
          if (!Array.isArray(flag) || flag.length === 0) {
            throw new Error("Each flag in the schema passed to AutocompleteData.flags must be a non-empty array");
          }
          const flagName: unknown = flag[0];
          if (typeof flagName !== "string") {
            throw new Error("The flag name must be a string");
          }
          // Short form
          if (flagName.length === 1) {
            return "-" + flagName;
          }
          // Long form
          return "--" + flagName;
        });
        try {
          return flagFunc(schema);
        } catch (error) {
          throw new Error("Cannot parse the arguments with the schema passed to AutocompleteData.flags", {
            cause: error,
          });
        }
      },
      hostname: currServ.hostname,
      filename: script.filename,
      processes: Array.from(currServ.runningScriptMap.values(), (m) =>
        Array.from(m.values(), (r) => ({
          pid: r.pid,
          filename: r.filename,
          threads: r.threads,
          args: r.args.slice(),
          temporary: r.temporary,
        })),
      ).flat(),
      command: terminalText,
    };
    let pos: string[] = [];
    let pos2: string[] = [];
    try {
      const options = loadedModule.autocomplete(autocompleteData, flags._);
      if (!Array.isArray(options)) {
        throw new Error("The autocomplete function must return an array");
      }
      pos = pos.concat(options.map((x) => String(x)));
    } catch (error) {
      const errorData = parseUnknownError(error);
      Terminal.error(
        `The autocomplete function in ${filepath} throws an error. Reason: ${errorData.errorAsString}.${
          errorData.causeAsString ? ` Cause: ${errorData.causeAsString}` : ""
        }`,
      );
    }
    return pos.concat(pos2);
  }
}
