import { Dockerfile, Instruction } from "dockerfile-ast";
import { UnresolvedDockerfileVariableHandling } from "../types";
import {
  DockerFileAnalysisErrorCode,
  DockerFileLayers,
  DockerFilePackages,
  GetDockerfileBaseImageNameResult,
} from "./types";

export {
  getDockerfileBaseImageName,
  getLayersFromPackages,
  getPackagesFromDockerfile,
  instructionDigest,
  getPackagesFromRunInstructions,
};

// Naive regex; see tests for cases
// tslint:disable-next-line:max-line-length
const installRegex =
  /(rpm\s+-i|rpm\s+--install|apk\s+((--update|-u|--no-cache)\s+)*add(\s+(--update|-u|--no-cache))*|apt-get\s+((--assume-yes|--yes|-y)\s+)*install(\s+(--assume-yes|--yes|-y))*|apt\s+((--assume-yes|--yes|-y)\s+)*install|dnf\s+((--assumeyes|--best|--nodocs|--allowerasing|-y)\s+)*install(\s+(--assumeyes|--best|--nodocs|--allowerasing|-y))*|microdnf\s+((--nodocs|--best|--assumeyes|-y)\s+)*install(\s+(--nodocs|--best|--assumeyes|-y))*|yum\s+install|aptitude\s+install)\s+/;

function getPackagesFromDockerfile(dockerfile: Dockerfile): DockerFilePackages {
  const runInstructions = getRunInstructionsFromDockerfile(dockerfile);
  return getPackagesFromRunInstructions(runInstructions);
}

function getRunInstructionsFromDockerfile(dockerfile: Dockerfile) {
  return dockerfile
    .getInstructions()
    .filter(
      (instruction) => instruction.getInstruction().toUpperCase() === "RUN",
    )
    .map((instruction) =>
      getInstructionExpandVariables(
        instruction,
        dockerfile,
        UnresolvedDockerfileVariableHandling.Continue,
      ),
    );
}

/*
 * This is fairly ugly because a single RUN could contain multiple install
 * commands, which in turn may install multiple packages, so we've got a
 * 3-level nested array (RUN instruction[] -> install[] -> package[])
 *
 * We also need to account for the multiple ways to split commands, and
 * arbitrary whitespace
 */
function getPackagesFromRunInstructions(runInstructions: string[]) {
  return runInstructions.reduce((dockerfilePackages, instruction) => {
    const cleanedInstruction = cleanInstruction(instruction);
    const commands = cleanedInstruction.split(/\s?(;|&&)\s?/);
    const installCommands = commands.filter((command) =>
      installRegex.test(command),
    );

    if (installCommands.length) {
      // Get the packages per install command and flatten them
      for (const command of installCommands) {
        const packages = command
          .replace(installRegex, "")
          .split(/\s+/)
          .filter((arg) => arg && !arg.startsWith("-"));

        packages.forEach((pkg) => {
          // Use package name without version as the key
          let name = pkg.split("=")[0];
          if (name.startsWith("$")) {
            name = name.slice(1);
          }
          const installCommand =
            installCommands
              .find((cmd) => cmd.indexOf(name) !== -1)
              ?.replace(/\s+/g, " ")
              .trim() || "Unknown";
          dockerfilePackages[name] = {
            instruction,
            installCommand,
          };
        });
      }
    }

    return dockerfilePackages;
  }, {});
}

/**
 * Return the instruction text without any of the image prefixes
 * @param instruction the full RUN instruction extracted from image
 */
function cleanInstruction(instruction: string): string {
  let cleanedInstruction = instruction;
  const runDefs = ["RUN ", "/bin/sh ", "-c "];
  const argsPrefixRegex = /^\|\d .*?=/;

  for (const runDef of runDefs) {
    if (cleanedInstruction.startsWith(runDef)) {
      cleanedInstruction = cleanedInstruction.slice(runDef.length);
      if (runDef === runDefs[0] && argsPrefixRegex.test(cleanedInstruction)) {
        const match = installRegex.exec(cleanedInstruction);
        if (match) {
          cleanedInstruction = cleanedInstruction.slice(match.index);
        }
      }
    }
  }
  return cleanedInstruction;
}

/**
 * Return the specified text with variables expanded
 * @param instruction the instruction associated with this string
 * @param dockerfile Dockerfile to use for expanding the variables
 * @param unresolvedVariableHandling Strategy for reacting to unresolved vars
 * @param text a string with variables to expand, if not specified
 *  the instruction text is used
 */
function getInstructionExpandVariables(
  instruction: Instruction,
  dockerfile: Dockerfile,
  unresolvedVariableHandling: UnresolvedDockerfileVariableHandling,
  text?: string,
): string {
  let str = text || instruction.toString();
  const resolvedVariables = {};

  for (const variable of instruction.getVariables()) {
    const line = variable.getRange().start.line;
    const name = variable.getName();
    resolvedVariables[name] = dockerfile.resolveVariable(name, line);
  }
  for (const variable of Object.keys(resolvedVariables)) {
    if (
      unresolvedVariableHandling ===
        UnresolvedDockerfileVariableHandling.Abort &&
      !resolvedVariables[variable]
    ) {
      str = "";
      break;
    }

    // The $ is a special regexp character that should be escaped with a backslash
    // Support both notations either with $variable_name or ${variable_name}
    // The global search "g" flag is used to match and replace all occurrences
    str = str.replace(
      RegExp(`\\$\{${variable}\}|\\$${variable}`, "g"),
      resolvedVariables[variable] || "",
    );
  }

  return str;
}

/**
 * Return the image name of the last from stage, after resolving all aliases
 * @param dockerfile Dockerfile to use for retrieving the last stage image name
 */
function getDockerfileBaseImageName(
  dockerfile: Dockerfile,
): GetDockerfileBaseImageNameResult {
  const froms = dockerfile.getFROMs();
  // collect stages names
  const stagesNames = froms.reduce(
    (stagesNames, fromInstruction) => {
      const fromName = fromInstruction.getImage() as string;
      const args = fromInstruction.getArguments();
      // the FROM expanded base name
      const expandedName = getInstructionExpandVariables(
        fromInstruction,
        dockerfile,
        UnresolvedDockerfileVariableHandling.Abort,
        fromName,
      );

      const hasUnresolvedVariables =
        expandedName.split(":").some((name) => !name) ||
        expandedName.split("@").some((name) => !name);

      if (args.length > 2 && args[1].getValue().toUpperCase() === "AS") {
        // the AS alias name
        const aliasName = args[2].getValue();
        // support nested referral
        if (!expandedName) {
          stagesNames.aliases[aliasName] = null;
        } else {
          stagesNames.aliases[aliasName] =
            stagesNames.aliases[expandedName] || expandedName;
        }
      }

      const hasUnresolvedAlias =
        Object.keys(stagesNames.aliases).includes(expandedName) &&
        !stagesNames.aliases[expandedName];

      if (expandedName === "" || hasUnresolvedVariables || hasUnresolvedAlias) {
        return {
          ...stagesNames,
          last: undefined,
        };
      }

      // store the resolved stage name
      stagesNames.last = stagesNames.aliases[expandedName] || expandedName;

      return stagesNames;
    },
    { last: undefined, aliases: {} },
  );

  if (stagesNames.last) {
    return {
      baseImage: stagesNames.last,
    };
  }

  if (!froms.length) {
    return {
      error: {
        code: DockerFileAnalysisErrorCode.BASE_IMAGE_NAME_NOT_FOUND,
      },
    };
  }

  return {
    error: {
      code: DockerFileAnalysisErrorCode.BASE_IMAGE_NON_RESOLVABLE,
    },
  };
}

function instructionDigest(instruction): string {
  return Buffer.from(instruction).toString("base64");
}

function getLayersFromPackages(
  dockerfilePkgs: DockerFilePackages,
): DockerFileLayers {
  return Object.keys(dockerfilePkgs).reduce((res, pkg) => {
    const { instruction } = dockerfilePkgs[pkg];
    res[instructionDigest(instruction)] = { instruction };
    return res;
  }, {});
}
