import type { Template } from "./template.js";
import type { PackageJson } from "@nomicfoundation/hardhat-utils/package";

import path from "node:path";

import {
  assertHardhatInvariant,
  HardhatError,
} from "@nomicfoundation/hardhat-errors";
import { ensureError } from "@nomicfoundation/hardhat-utils/error";
import {
  copy,
  ensureDir,
  exists,
  getAllFilesMatching,
  isDirectory,
  mkdir,
  readJsonFile,
  writeJsonFile,
} from "@nomicfoundation/hardhat-utils/fs";
import { resolveFromRoot } from "@nomicfoundation/hardhat-utils/path";
import chalk from "chalk";
import debug from "debug";
import * as semver from "semver";

import { findClosestHardhatConfig } from "../../config-loading.js";
import { HARDHAT_NAME } from "../../constants.js";
import {
  getHardhatVersion,
  getLatestHardhatVersion,
} from "../../utils/package.js";
import { BannerManager } from "../banner-manager.js";
import { sendProjectTypeAnalytics } from "../telemetry/analytics/analytics.js";
import { sendErrorTelemetry } from "../telemetry/sentry/reporter.js";

import {
  getDevDependenciesInstallationCommand,
  getPackageManager,
  installsPeerDependenciesByDefault,
} from "./package-manager.js";
import {
  promptForMigrateToEsm,
  promptForForce,
  promptForInstall,
  promptForTemplate,
  promptForUpdate,
  promptForWorkspace,
  promptForHardhatVersion,
} from "./prompt.js";
import { spawn } from "./subprocess.js";
import { getTemplates } from "./template.js";

export interface InitHardhatOptions {
  hardhatVersion?: "hardhat-2" | "hardhat-3";
  workspace?: string;
  migrateToEsm?: boolean;
  template?: string;
  force?: boolean;
  install?: boolean;
}

const log = debug("hardhat:cli:init");

/**
 * initHardhat implements the project initialization wizard flow.
 *
 * It can be called with the following options:
 * - workspace: The path to the workspace to initialize the project in.
 *   If not provided, the user will be prompted to select the workspace.
 * - template: The name of the template to use for the project initialization.
 *   If not provided, the user will be prompted to select the template.
 * - force: Whether to overwrite existing files in the workspace.
 *   If not provided and there are files that would be overwritten,
 *   the user will be prompted to confirm.
 * - install: Whether to install the project dependencies.
 *   If not provided and there are dependencies that should be installed,
 *   the user will be prompted to confirm.
 *
 * The flow is as follows:
 * 1. Print the ascii logo.
 * 2. Print the welcome message.
 * 3. Optionally, ask the user for the workspace to initialize the project in.
 * 4. Validate that the package.json exists; otherwise, create it.
 * 5. Validate that the package.json is an esm package; otherwise, ask the user if they want to set it.
 * 6. Optionally, ask the user for the template to use for the project initialization.
 * 7. Optionally, ask the user if files should be overwritten.
 * 8. Copy the template files to the workspace.
 * 9. Ensure telemetry consent.
 * 10. Print the commands to install the project dependencies.
 * 11. Optionally, ask the user if the project dependencies should be installed.
 * 12. Optionally, run the commands to install the project dependencies.
 * 13. Print a message to star the project on GitHub.
 */
export async function initHardhat(options?: InitHardhatOptions): Promise<void> {
  try {
    printAsciiLogo();

    await printWelcomeMessage();

    const hardhatVersion =
      options?.hardhatVersion ?? (await promptForHardhatVersion());

    // Ask the user for the workspace to initialize the project in
    // if it was not provided, and validate that it is not already initialized
    const workspace = await getWorkspace(options?.workspace);

    // Ask the user for the template to use for the project initialization
    // if it was not provided, and validate that it exists
    const [template, projectTypeAnalyticsPromise] = await getTemplate(
      hardhatVersion,
      options?.template,
    );

    // Create the package.json file if it does not exist
    // and validate that it is an esm package
    await validatePackageJson(
      workspace,
      template.packageJson,
      options?.migrateToEsm,
    );

    // Copy the template files to the workspace
    // Overwrite existing files only if the user opts-in to it
    await copyProjectFiles(workspace, template, options?.force);

    // Print the commands to install the project dependencies
    // Run them only if the user opts-in to it
    // Concurrently, await the analytics hit
    await Promise.all([
      installProjectDependencies(workspace, template, options?.install),
      projectTypeAnalyticsPromise,
    ]);

    showStarOnGitHubMessage();

    try {
      const bannerManager = await BannerManager.getInstance();
      await bannerManager.showBanner();
    } catch (bannerError) {
      log("Error showing banner", bannerError);
    }
  } catch (e) {
    if (e === "") {
      // If the user cancels any prompt, we quit silently
      return;
    }

    throw e;
  }
}

// generated based on the "DOS Rebel" font
function printAsciiLogo() {
  const logoLines = `
 █████  █████                         ███  ███                  ███      ██████
░░███  ░░███                         ░███ ░███                 ░███     ███░░███
 ░███   ░███   ██████  ████████   ███████ ░███████    ██████  ███████  ░░░  ░███
 ░██████████  ░░░░░███░░███░░███ ███░░███ ░███░░███  ░░░░░███░░░███░      ████░
 ░███░░░░███   ███████ ░███ ░░░ ░███ ░███ ░███ ░███   ███████  ░███      ░░░░███
 ░███   ░███  ███░░███ ░███     ░███ ░███ ░███ ░███  ███░░███  ░███ ███ ███ ░███
 █████  █████░░███████ █████    ░░███████ ████ █████░░███████  ░░█████ ░░██████
░░░░░  ░░░░░  ░░░░░░░ ░░░░░      ░░░░░░░ ░░░░ ░░░░░  ░░░░░░░    ░░░░░   ░░░░░░
 `;

  // Print an ansi escape sequence to disable auto-wrapping of text in case the
  // logo doesn't fit
  process.stdout.write("\x1b[?7l");

  console.log(chalk.blue(logoLines));

  // Re-enable auto-wapping
  process.stdout.write("\x1b[?7h");
}

// NOTE: This function is exported for testing purposes
export async function printWelcomeMessage(): Promise<void> {
  const hardhatVersion = await getHardhatVersion();

  console.log(
    chalk.cyan(`👷 Welcome to ${HARDHAT_NAME} v${hardhatVersion} 👷\n`),
  );

  // Warn the user if they are using an outdated version of Hardhat
  try {
    const latestHardhatVersion = await getLatestHardhatVersion();
    if (hardhatVersion !== latestHardhatVersion) {
      console.warn(
        chalk.yellow.bold(
          `⚠️ You are using an outdated version of Hardhat. The latest version is v${latestHardhatVersion}. Please consider upgrading to the latest version before continuing with the project initialization. ⚠️\n`,
        ),
      );
    }
  } catch (error) {
    ensureError(error);
    try {
      await sendErrorTelemetry(error);
    } catch (e) {
      log("Couldn't report error to sentry: %O", e);
    }
    console.warn(
      chalk.yellow.bold(
        `⚠️ We couldn't check if you are using the latest version of Hardhat. Please consider upgrading to the latest version if you are not using it yet. ⚠️\n`,
      ),
    );
  }
}

/**
 * getWorkspace asks the user for the workspace to initialize the project in
 * if the input workspace is undefined.
 *
 * It also validates that the workspace is not already initialized.
 *
 * NOTE: This function is exported for testing purposes
 *
 * @param workspace The path to the workspace to initialize the project in.
 * @returns The path to the workspace.
 */
export async function getWorkspace(workspace?: string): Promise<string> {
  // Ask the user for the workspace to initialize the project in if it was not provided
  if (workspace === undefined) {
    workspace = await promptForWorkspace();
  }

  workspace = resolveFromRoot(process.cwd(), workspace);

  if ((await exists(workspace)) && !(await isDirectory(workspace))) {
    throw new HardhatError(
      HardhatError.ERRORS.CORE.GENERAL.WORKSPACE_MUST_BE_A_DIRECTORY,
      {
        workspace,
      },
    );
  }

  // If the path points to a non-existent folder, create it; otherwise, do nothing
  await mkdir(workspace);

  // Validate that the workspace is not already initialized
  try {
    const configFilePath = await findClosestHardhatConfig(workspace);

    throw new HardhatError(
      HardhatError.ERRORS.CORE.GENERAL.HARDHAT_PROJECT_ALREADY_CREATED,
      {
        hardhatProjectRootPath: configFilePath,
      },
    );
  } catch (err) {
    if (
      HardhatError.isHardhatError(err) &&
      err.number ===
        HardhatError.ERRORS.CORE.GENERAL.NO_CONFIG_FILE_FOUND.number
    ) {
      // If a configuration file is not found, it is possible to initialize a new project,
      // hence continuing code execution
      return workspace;
    }

    throw err;
  }
}

/**
 * getTemplate asks the user for the template to use for the project initialization
 * if the input template is undefined.
 *
 * It also validates that the template exists.
 *
 * NOTE: This function is exported for testing purposes
 *
 * @param template The name of the template to use for the project initialization.
 * @returns A tuple with two elements: the template and a promise with the analytics hit.
 */
export async function getTemplate(
  hardhatVersion: "hardhat-2" | "hardhat-3",
  template?: string,
): Promise<[Template, Promise<boolean>]> {
  const templates = await getTemplates(hardhatVersion);

  // Ask the user for the template to use for the project initialization if it was not provided
  if (template === undefined) {
    template = await promptForTemplate(templates);
  }

  const projectTypeAnalyticsPromise = sendProjectTypeAnalytics(
    hardhatVersion,
    template,
  );

  // Validate that the template exists
  for (const t of templates) {
    if (t.name === template) {
      return [t, projectTypeAnalyticsPromise];
    }
  }

  // we wait for the GA hit before throwing
  await projectTypeAnalyticsPromise;

  throw new HardhatError(HardhatError.ERRORS.CORE.GENERAL.TEMPLATE_NOT_FOUND, {
    template,
  });
}

/**
 * validatePackageJson creates the package.json file if it does not exist
 * in the workspace.
 *
 * It also validates that the package.json file is an esm package.
 *
 * NOTE: This function is exported for testing purposes
 *
 * @param workspace The path to the workspace to initialize the project in.
 */
export async function validatePackageJson(
  workspace: string,
  templatePkg: PackageJson,
  migrateToEsm?: boolean,
): Promise<void> {
  const absolutePathToPackageJson = path.join(workspace, "package.json");
  const shouldUseEsm = templatePkg.type === "module";

  // Create the package.json file if it does not exist
  if (!(await exists(absolutePathToPackageJson))) {
    const packageJson: PackageJson = {
      name: path.basename(workspace),
      version: "1.0.0",
    };

    if (shouldUseEsm) {
      packageJson.type = "module";
    }

    await writeJsonFile(absolutePathToPackageJson, packageJson);
  }

  const packageManager = getPackageManager();

  // We know this works with npm, pnpm, but not with yarn. If, so we use
  // pnpm or npm exclusively.
  // If you read this comment and wonder if this is outdated, you can
  // answer it by checking if the most popular versions of yarn and other
  // package managers support `<package manager> pkg set type=module`.
  const packageManagerToUse = packageManager === "pnpm" ? "pnpm" : "npm";

  // We need to set the hardhat version in the package.json file
  // to ensure that the template is compatible with the current Hardhat version.
  // This is needed because we have hardhat-2 and hardhat-3 templates,
  // and the user may install hardhat 3 first and then initialize a project
  // with a hardhat-2 template.
  const templateHardhatVersion = templatePkg.devDependencies?.hardhat ?? "";
  if (templateHardhatVersion.startsWith("^2")) {
    await spawn(
      [packageManagerToUse, "pkg", "delete", "dependencies.hardhat"].join(" "),
      [],
      {
        cwd: workspace,
        shell: true,
        stdio: "inherit",
      },
    );
  }

  if (!shouldUseEsm) {
    return;
  }

  const pkg: PackageJson = await readJsonFile(absolutePathToPackageJson);

  // Validate that the package.json file is an esm package
  if (pkg.type === "module") {
    return;
  }

  if (migrateToEsm === undefined) {
    migrateToEsm = await promptForMigrateToEsm(absolutePathToPackageJson);
  }

  if (!migrateToEsm) {
    throw new HardhatError(HardhatError.ERRORS.CORE.GENERAL.ONLY_ESM_SUPPORTED);
  }

  await spawn(
    [packageManagerToUse, "pkg", "set", "type=module"].join(" "),
    [],
    {
      cwd: workspace,
      shell: true,
      stdio: "inherit",
    },
  );
}

/**
 * The following two functions are used to convert between relative workspace
 * and template paths. To begin with, they are used to handle the special case
 * of .gitignore.
 *
 * The reason for this is that npm ignores .gitignore files
 * during npm pack (see https://github.com/npm/npm/issues/3763). That's why when
 * we encounter a gitignore file in the template, we assume that it should be
 * called .gitignore in the workspace (and vice versa).
 *
 * They are exported for testing purposes only.
 */

export function relativeWorkspaceToTemplatePath(file: string): string {
  if (path.basename(file) === ".gitignore") {
    return path.join(path.dirname(file), "gitignore");
  }
  return file;
}
export function relativeTemplateToWorkspacePath(file: string): string {
  if (path.basename(file) === "gitignore") {
    return path.join(path.dirname(file), ".gitignore");
  }
  return file;
}

/**
 * copyProjectFiles copies the template files to the workspace.
 *
 * If there are clashing files in the workspace, they will be overwritten only
 * if the force option is true or if the user opts-in to it.
 *
 * NOTE: This function is exported for testing purposes
 *
 * @param workspace The path to the workspace to initialize the project in.
 * @param template The template to use for the project initialization.
 * @param force Whether to overwrite existing files in the workspace.
 */
export async function copyProjectFiles(
  workspace: string,
  template: Template,
  force?: boolean,
): Promise<void> {
  // Find all the files in the workspace that would have been overwritten by the template files
  const matchingRelativeWorkspacePaths = await getAllFilesMatching(
    workspace,
    (file) => {
      const relativeWorkspacePath = path.relative(workspace, file);
      const relativeTemplatePath = relativeWorkspaceToTemplatePath(
        relativeWorkspacePath,
      );
      return template.files.includes(relativeTemplatePath);
    },
  ).then((files) => files.map((f) => path.relative(workspace, f)));

  // Ask the user for permission to overwrite existing files if needed
  if (matchingRelativeWorkspacePaths.length !== 0) {
    if (force === undefined) {
      force = await promptForForce(matchingRelativeWorkspacePaths);
    }
  }

  // Copy the template files to the workspace
  for (const relativeTemplatePath of template.files) {
    const relativeWorkspacePath =
      relativeTemplateToWorkspacePath(relativeTemplatePath);

    if (
      force === false &&
      matchingRelativeWorkspacePaths.includes(relativeWorkspacePath)
    ) {
      continue;
    }

    const absoluteTemplatePath = path.join(template.path, relativeTemplatePath);
    const absoluteWorkspacePath = path.join(workspace, relativeWorkspacePath);

    await ensureDir(path.dirname(absoluteWorkspacePath));
    await copy(absoluteTemplatePath, absoluteWorkspacePath);
  }

  console.log(`✨ ${chalk.cyan(`Template files copied`)} ✨`);
}

/**
 * installProjectDependencies prints the commands to install the project dependencies
 * and runs them if the install option is true or if the user opts-in to it.
 *
 * NOTE: This function is exported for testing purposes
 *
 * @param workspace The path to the workspace to initialize the project in.
 * @param template The template to use for the project initialization.
 * @param install Whether to install the project dependencies.
 * @param update Whether to update the project dependencies.
 */
export async function installProjectDependencies(
  workspace: string,
  template: Template,
  install?: boolean,
  update?: boolean,
): Promise<void> {
  const pathToWorkspacePackageJson = path.join(workspace, "package.json");

  const workspacePkg: PackageJson = await readJsonFile(
    pathToWorkspacePackageJson,
  );

  const packageManager = getPackageManager();

  // Find the template dev dependencies that are not already installed
  const templateDependencies = template.packageJson.devDependencies ?? {};
  // If the package manager doesn't install peer dependencies by default,
  // we need to add them to the template dependencies
  if (!(await installsPeerDependenciesByDefault(workspace, packageManager))) {
    const templatePeerDependencies =
      template.packageJson.peerDependencies ?? {};
    for (const [name, version] of Object.entries(templatePeerDependencies)) {
      templateDependencies[name] = version;
    }
  }

  // Checking both workspace dependencies and dev dependencies in case the user
  // installed a dev dependency as a dependency
  const workspaceDependencies = {
    ...(workspacePkg.dependencies ?? {}),
    ...(workspacePkg.devDependencies ?? {}),
  };

  // We need to strip the optional workspace prefix from template dependency versions
  const templateDependencyEntries = Object.entries(templateDependencies).map(
    ([name, version]) => [name, version.replace(/^workspace:/, "")],
  );

  // Finding the dependencies that are not already installed
  const dependenciesToInstall = templateDependencyEntries
    .filter(([name]) => workspaceDependencies[name] === undefined)
    .map(([name, version]) => `${name}@${version}`);

  // Try to install the missing dependencies if there are any
  if (Object.keys(dependenciesToInstall).length !== 0) {
    // Retrieve the package manager specific installation command
    const command = getDevDependenciesInstallationCommand(
      packageManager,
      dependenciesToInstall,
    );
    const commandString = command.join(" ");

    // Ask the user for permission to install the project dependencies
    if (install === undefined) {
      install = await promptForInstall(commandString);
    }

    // If the user grants permission to install the dependencies, run the installation command
    if (install) {
      console.log();
      console.log(commandString);

      await spawn(commandString, [], {
        cwd: workspace,
        // We need to run with `shell: true` for this to work on powershell, but
        // we already enclosed every dependency identifier in quotes, so this
        // is safe.
        shell: true,
        stdio: "inherit",
      });

      console.log(`✨ ${chalk.cyan(`Dependencies installed`)} ✨`);
    }
  }

  // NOTE: Even though the dependency updates are very similar to pure
  // installations, they are kept separate to allow the user to skip one while
  // proceeding with the other, and to allow us to design handling of these
  // two processes independently.

  // Finding the installed dependencies that have an incompatible version
  const dependenciesToUpdate = templateDependencyEntries
    .filter(([dependencyName, templateVersion]) => {
      const workspaceVersion = workspaceDependencies[dependencyName];
      return shouldUpdateDependency(workspaceVersion, templateVersion);
    })
    .map(([name, version]) => `${name}@${version}`);

  // Try to update the missing dependencies if there are any.
  if (dependenciesToUpdate.length !== 0) {
    // Retrieve the package manager specific installation command
    const command = getDevDependenciesInstallationCommand(
      packageManager,
      dependenciesToUpdate,
    );
    const commandString = command.join(" ");

    // Ask the user for permission to update the project dependencies
    if (update === undefined) {
      update = await promptForUpdate(commandString);
    }

    if (update) {
      console.log();
      console.log(commandString);

      await spawn(commandString, [], {
        cwd: workspace,
        // We need to run with `shell: true` for this to work on powershell, but
        // we already enclosed every dependency identifier in quotes, so this
        // is safe.
        shell: true,
        stdio: "inherit",
      });

      console.log(`✨ ${chalk.cyan(`Dependencies updated`)} ✨`);
    }
  }
}

function showStarOnGitHubMessage() {
  console.log(
    chalk.cyan("Give Hardhat a star on Github if you're enjoying it! ⭐️✨"),
  );
  console.log();
  console.log(chalk.cyan("     https://github.com/NomicFoundation/hardhat"));
}

// NOTE: This function is exported for testing purposes only.
export function shouldUpdateDependency(
  workspaceVersion: string | undefined,
  templateVersion: string,
): boolean {
  // We should not update the dependency if it is not yet installed in the workspace.
  if (workspaceVersion === undefined) {
    return false;
  }
  // NOTE: a specific version also a valid range that includes itself only
  const workspaceRange = semver.validRange(workspaceVersion, {
    includePrerelease: true,
  });
  const templateRange = semver.validRange(templateVersion, {
    includePrerelease: true,
  });
  assertHardhatInvariant(
    templateRange !== null,
    "All dependencies of the template should have valid versions",
  );
  // We should update the dependency if the workspace version could not be parsed.
  if (workspaceRange === null) {
    return true;
  }
  // We should update the dependency if the workspace range (or, in particular, a specific version) is not
  // a strict subset of the template range/does not equal the template version.
  return !semver.subset(workspaceRange, templateRange, {
    includePrerelease: true,
  });
}
