import * as Debug from "debug";
import * as fs from "fs";
import * as mkdirp from "mkdirp";
import * as path from "path";

import { Docker, DockerOptions } from "../docker";
import {
  ArchiveResult,
  DestinationDir,
  DockerInspectOutput,
  ImageDetails,
} from "./types";

export { getImageArchive, extractImageDetails, pullIfNotLocal };

const debug = Debug("snyk");

async function getInspectResult(
  docker: Docker,
  targetImage: string,
): Promise<DockerInspectOutput> {
  const info = await docker.inspectImage(targetImage);
  return JSON.parse(info.stdout)[0];
}

function cleanupCallback(imageFolderPath: string, imageName: string) {
  return () => {
    const fullImagePath = path.join(imageFolderPath, imageName);
    if (fs.existsSync(fullImagePath)) {
      fs.unlinkSync(fullImagePath);
    }
    fs.rmdir(imageFolderPath, (err) => {
      if (err !== null) {
        debug(`Can't remove folder ${imageFolderPath}, got error ${err}`);
      }
    });
  };
}

async function pullWithDockerBinary(
  docker: Docker,
  targetImage: string,
  saveLocation: string,
  username: string | undefined,
  password: string | undefined,
  platform: string | undefined,
): Promise<boolean> {
  let pullAndSaveSuccessful = false;
  try {
    if (username || password) {
      debug(
        "using local docker binary credentials. the credentials you provided will be ignored",
      );
    }
    await docker.pullCli(targetImage, { platform });
    await docker.save(targetImage, saveLocation);
    return (pullAndSaveSuccessful = true);
  } catch (err) {
    debug(
      `couldn't pull ${targetImage} using docker binary: ${JSON.stringify(
        err,
      )}`,
    );

    if (
      err.stderr &&
      err.stderr.includes("unknown operating system or architecture")
    ) {
      throw new Error("Unknown operating system or architecture");
    }

    if (
      err.stderr &&
      err.stderr.includes("operating system is not supported")
    ) {
      throw new Error(`Operating system is not supported`);
    }

    if (err.stderr && err.stderr.includes("no matching manifest for")) {
      if (platform) {
        throw new Error(`The image does not exist for ${platform}`);
      }
      throw new Error(`The image does not exist for the current platform`);
    }

    if (err.stderr && err.stderr.includes("invalid reference format")) {
      throw new Error(`invalid image format`);
    }

    if (err.stderr.includes("unknown flag: --platform")) {
      throw new Error(
        '"--platform" is only supported on a Docker daemon with version later than 17.09',
      );
    }

    if (
      err.stderr &&
      err.stderr ===
        '"--platform" is only supported on a Docker daemon with experimental features enabled'
    ) {
      throw new Error(err.stderr);
    }

    return pullAndSaveSuccessful;
  }
}

async function pullFromContainerRegistry(
  docker: Docker,
  targetImage: string,
  imageSavePath: string,
  username: string | undefined,
  password: string | undefined,
): Promise<void> {
  const { hostname, imageName, tag } = extractImageDetails(targetImage);
  debug(
    `Attempting to pull: registry: ${hostname}, image: ${imageName}, tag: ${tag}`,
  );
  await docker.pull(
    hostname,
    imageName,
    tag,
    imageSavePath,
    username,
    password,
  );
}

async function pullImage(
  docker: Docker,
  targetImage: string,
  saveLocation: string,
  imageSavePath: string,
  username: string | undefined,
  password: string | undefined,
  platform: string | undefined,
): Promise<void> {
  if (await Docker.binaryExists()) {
    const pullAndSaveSuccessful = await pullWithDockerBinary(
      docker,
      targetImage,
      saveLocation,
      username,
      password,
      platform,
    );
    if (pullAndSaveSuccessful) {
      return;
    }
  }

  await pullFromContainerRegistry(
    docker,
    targetImage,
    imageSavePath,
    username,
    password,
  );
}

/**
 * In the case that an `ImageType.Identifier` is detected we need to produce
 * an image archive, either by saving the image if it's already loaded into
 * the local docker daemon, or by pulling the image from a remote registry and
 * saving it to the filesystem directly.
 *
 * Users may also provide us with a URL to an image in a Docker compatible
 * remote registry.
 *
 * @param {string} targetImage - The image to test, this could be in one of
 *    the following forms:
 *      * [registry/]<repo>/<image>[:tag]
 *      * <repo>/<image>[:tag]
 *      * <image>[:tag]
 *    In the case that a registry is not provided, the plugin will default
 *    this to Docker Hub. If a tag is not provided this will default to
 *    `latest`.
 * @param {string} [username] - Optional username for private repo auth.
 * @param {string} [password] - Optional password for private repo auth.
 * @param {string} [platform] - Optional platform parameter to pull specific image arch.
 */
async function getImageArchive(
  targetImage: string,
  imageSavePath: string,
  username?: string,
  password?: string,
  platform?: string,
): Promise<ArchiveResult> {
  const docker = new Docker();
  mkdirp.sync(imageSavePath);
  const destination: DestinationDir = {
    name: imageSavePath,
    removeCallback: cleanupCallback(imageSavePath, "image.tar"),
  };
  const saveLocation: string = path.join(destination.name, "image.tar");
  let inspectResult: DockerInspectOutput | undefined;

  try {
    inspectResult = await getInspectResult(docker, targetImage);
  } catch {
    debug(`${targetImage} does not exist locally, proceeding to pull image.`);
  }

  if (inspectResult === undefined) {
    await pullImage(
      docker,
      targetImage,
      saveLocation,
      imageSavePath,
      username,
      password,
      platform,
    );

    return {
      path: saveLocation,
      removeArchive: destination.removeCallback,
    };
  }

  if (
    platform !== undefined &&
    inspectResult &&
    !isLocalImageSameArchitecture(platform, inspectResult.Architecture)
  ) {
    await pullImage(
      docker,
      targetImage,
      saveLocation,
      imageSavePath,
      username,
      password,
      platform,
    );
  } else {
    await docker.save(targetImage, saveLocation);
  }

  return {
    path: saveLocation,
    removeArchive: destination.removeCallback,
  };
}

function extractImageDetails(targetImage: string): ImageDetails {
  let remainder: string;
  let hostname: string;
  let imageName: string;
  let tag: string;

  // We need to detect if the `targetImage` is part of a URL. Based on the Docker specification,
  // the hostname should contain a `.` or `:` before the first instance of a `/` otherwise the
  // default hostname will be used (registry-1.docker.io). ref: https://stackoverflow.com/a/37867949
  const i = targetImage.indexOf("/");
  if (
    i === -1 ||
    (!targetImage.substring(0, i).includes(".") &&
      !targetImage.substring(0, i).includes(":") &&
      targetImage.substring(0, i) !== "localhost")
  ) {
    hostname = "registry-1.docker.io";
    remainder = targetImage;
    // First assume the remainder is image@sha
    [imageName, tag] = remainder.split("@");
    if (tag === undefined) {
      [imageName, tag] = remainder.split(":");
    }
    imageName =
      imageName.indexOf("/") === -1 ? "library/" + imageName : imageName;
  } else {
    hostname = targetImage.substring(0, i);
    remainder = targetImage.substring(i + 1);
    [imageName, tag] = remainder.split("@");
    if (tag === undefined) {
      [imageName, tag] = remainder.split(":");
    }
  }

  // Assume the latest tag if no tag was found.
  tag = tag || "latest";

  return {
    hostname,
    imageName,
    tag,
  };
}

function isLocalImageSameArchitecture(
  platformOption: string,
  inspectResultArchitecture: string,
): boolean {
  let platformArchitecture: string;
  try {
    // Note: this is using the same flag/input pattern as the new Docker buildx: eg. linux/arm64/v8
    platformArchitecture = platformOption.split("/")[1];
  } catch (error) {
    debug(`Error parsing platform flag: '${JSON.stringify(error)}'`);
    return false;
  }

  return platformArchitecture === inspectResultArchitecture;
}

async function pullIfNotLocal(targetImage: string, options?: DockerOptions) {
  const docker = new Docker();
  try {
    await docker.inspectImage(targetImage);
    return;
  } catch (err) {
    // image doesn't exist locally
  }
  await docker.pullCli(targetImage);
}
