import { execa } from "execa";
import { ExternalExtension, Options, SolidityFramework, TemplateDescriptor } from "../types";
import { findFilesRecursiveSync } from "../utils/find-files-recursively";
import { mergePackageJson } from "../utils/merge-package-json";
import fs from "fs";
import { pathToFileURL } from "url";
import ncp from "ncp";
import path from "path";
import { promisify } from "util";
import link from "../utils/link";
import { getArgumentFromExternalExtensionOption } from "../utils/external-extensions";
import { BASE_DIR, SOLIDITY_FRAMEWORKS, SOLIDITY_FRAMEWORKS_DIR, EXAMPLE_CONTRACTS_DIR } from "../utils/consts";

const EXTERNAL_EXTENSION_TMP_DIR = "tmp-external-extension";

const copy = promisify(ncp);
let copyOrLink = copy;

const isTemplateRegex = /([^/\\]*?)\.template\./;
const isPackageJsonRegex = /package\.json/;
const isYarnLockRegex = /yarn\.lock/;
const isConfigRegex = /([^/\\]*?)\\config\.json/;
const isArgsRegex = /([^/\\]*?)\.args\./;
const isSolidityFrameworkFolderRegex = /solidity-frameworks$/;
const isPackagesFolderRegex = /packages$/;
const isDeployedContractsRegex = /packages\/nextjs\/contracts\/deployedContracts\.ts/;

const getSolidityFrameworkPath = (solidityFramework: SolidityFramework, templatesDirectory: string) =>
  path.resolve(templatesDirectory, SOLIDITY_FRAMEWORKS_DIR, solidityFramework);

const copyBaseFiles = async (basePath: string, targetDir: string, { dev: isDev }: Options) => {
  await copyOrLink(basePath, targetDir, {
    clobber: false,
    filter: fileName => {
      const isTemplate = isTemplateRegex.test(fileName);

      const isYarnLock = isYarnLockRegex.test(fileName);
      const isDeployedContracts = isDeployedContractsRegex.test(fileName);
      const isPackageJson = isPackageJsonRegex.test(fileName);
      const skipDevOnly = isDev && (isYarnLock || isDeployedContracts || isPackageJson);

      return !isTemplate && !skipDevOnly;
    },
  });

  if (isDev) {
    // We don't want symlink below files in dev mode
    const baseYarnLockPaths = findFilesRecursiveSync(basePath, path => isYarnLockRegex.test(path));
    baseYarnLockPaths.forEach(yarnLockPath => {
      const partialPath = yarnLockPath.split(basePath)[1];
      void copy(path.join(basePath, partialPath), path.join(targetDir, partialPath));
    });

    const basePackageJsonPaths = findFilesRecursiveSync(basePath, path => isPackageJsonRegex.test(path));
    basePackageJsonPaths.forEach(packageJsonPath => {
      const partialPath = packageJsonPath.split(basePath)[1];
      mergePackageJson(path.join(targetDir, partialPath), path.join(basePath, partialPath), isDev);
    });

    const baseDeployedContractsPaths = findFilesRecursiveSync(basePath, path => isDeployedContractsRegex.test(path));
    baseDeployedContractsPaths.forEach(deployedContractsPath => {
      const partialPath = deployedContractsPath.split(basePath)[1];
      void copy(path.join(basePath, partialPath), path.join(targetDir, partialPath));
    });
  }
};

const isUnselectedSolidityFrameworkFile = ({
  path,
  solidityFramework,
}: {
  path: string;
  solidityFramework: SolidityFramework | null;
}) => {
  const unselectedSolidityFrameworks = [SOLIDITY_FRAMEWORKS.FOUNDRY, SOLIDITY_FRAMEWORKS.HARDHAT].filter(
    sf => sf !== solidityFramework,
  );
  return unselectedSolidityFrameworks.map(sf => new RegExp(`${sf}`)).some(sfregex => sfregex.test(path));
};

const copyExtensionFiles = async (
  { dev: isDev, solidityFramework }: Options,
  extensionPath: string,
  targetDir: string,
) => {
  // copy (or link if dev) root files
  await copyOrLink(extensionPath, path.join(targetDir), {
    clobber: false,
    filter: path => {
      const isConfig = isConfigRegex.test(path);
      const isArgs = isArgsRegex.test(path);
      const isSolidityFrameworkFolder = isSolidityFrameworkFolderRegex.test(path) && fs.lstatSync(path).isDirectory();
      const isPackagesFolder = isPackagesFolderRegex.test(path) && fs.lstatSync(path).isDirectory();
      const isTemplate = isTemplateRegex.test(path);
      // PR NOTE: this wasn't needed before because ncp had the clobber: false
      const isPackageJson = isPackageJsonRegex.test(path);
      const shouldSkip =
        isConfig || isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder;
      return !shouldSkip;
    },
  });

  // merge root package.json
  mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "package.json"), isDev);

  const extensionPackagesPath = path.join(extensionPath, "packages");
  const hasPackages = fs.existsSync(extensionPackagesPath);
  if (hasPackages) {
    // copy extension packages files
    await copyOrLink(extensionPackagesPath, path.join(targetDir, "packages"), {
      clobber: false,
      filter: path => {
        const isArgs = isArgsRegex.test(path);
        const isTemplate = isTemplateRegex.test(path);
        const isPackageJson = isPackageJsonRegex.test(path);

        const isUnselectedSolidityFramework = isUnselectedSolidityFrameworkFile({ path, solidityFramework });

        const shouldSkip = isArgs || isTemplate || isPackageJson || isUnselectedSolidityFramework;

        return !shouldSkip;
      },
    });

    // copy each package's package.json
    const extensionPackages = fs.readdirSync(extensionPackagesPath);
    extensionPackages.forEach(packageName => {
      const isUnselectedSolidityFramework = isUnselectedSolidityFrameworkFile({
        path: path.join(targetDir, "packages", packageName, "package.json"),
        solidityFramework,
      });

      if (isUnselectedSolidityFramework) {
        return;
      }

      mergePackageJson(
        path.join(targetDir, "packages", packageName, "package.json"),
        path.join(extensionPath, "packages", packageName, "package.json"),
        isDev,
      );
    });
  }
};

const processTemplatedFiles = async (
  { solidityFramework, externalExtension, dev: isDev }: Options,
  basePath: string,
  solidityFrameworkPath: string | null,
  exampleContractsPath: string | null,
  targetDir: string,
) => {
  const baseTemplatedFileDescriptors: TemplateDescriptor[] = findFilesRecursiveSync(basePath, path =>
    isTemplateRegex.test(path),
  ).map(baseTemplatePath => ({
    path: baseTemplatePath,
    fileUrl: pathToFileURL(baseTemplatePath).href,
    relativePath: baseTemplatePath.split(basePath)[1],
    source: "base",
  }));

  const solidityFrameworkTemplatedFileDescriptors: TemplateDescriptor[] = solidityFrameworkPath
    ? findFilesRecursiveSync(solidityFrameworkPath, filePath => isTemplateRegex.test(filePath))
        .map(solidityFrameworkTemplatePath => ({
          path: solidityFrameworkTemplatePath,
          fileUrl: pathToFileURL(solidityFrameworkTemplatePath).href,
          relativePath: solidityFrameworkTemplatePath.split(solidityFrameworkPath)[1],
          source: `extension ${solidityFramework}`,
        }))
        .flat()
    : [];

  const starterContractsTemplateFileDescriptors: TemplateDescriptor[] = exampleContractsPath
    ? findFilesRecursiveSync(exampleContractsPath, filePath => isTemplateRegex.test(filePath))
        .map(exampleContractTemplatePath => ({
          path: exampleContractTemplatePath,
          fileUrl: pathToFileURL(exampleContractTemplatePath).href,
          relativePath: exampleContractTemplatePath.split(exampleContractsPath)[1],
          source: `example-contracts ${solidityFramework}`,
        }))
        .flat()
    : [];

  const externalExtensionFolder = isDev
    ? typeof externalExtension === "string"
      ? path.join(basePath, "../../externalExtensions", externalExtension, "extension")
      : undefined
    : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension");

  const externalExtensionTemplatedFileDescriptors: TemplateDescriptor[] =
    externalExtension && externalExtensionFolder
      ? findFilesRecursiveSync(externalExtensionFolder, filePath => isTemplateRegex.test(filePath)).map(
          extensionTemplatePath => ({
            path: extensionTemplatePath,
            fileUrl: pathToFileURL(extensionTemplatePath).href,
            relativePath: extensionTemplatePath.split(externalExtensionFolder)[1],
            source: `external extension ${isDev ? (externalExtension as string) : getArgumentFromExternalExtensionOption(externalExtension)}`,
          }),
        )
      : [];

  await Promise.all(
    [
      ...baseTemplatedFileDescriptors,
      ...solidityFrameworkTemplatedFileDescriptors,
      ...externalExtensionTemplatedFileDescriptors,
      ...starterContractsTemplateFileDescriptors,
    ].map(async templateFileDescriptor => {
      const templateTargetName = templateFileDescriptor.path.match(isTemplateRegex)?.[1] as string;

      const argsPath = templateFileDescriptor.relativePath.replace(isTemplateRegex, `${templateTargetName}.args.`);

      const argsFileUrls = [];

      if (solidityFrameworkPath) {
        const argsFilePath = path.join(solidityFrameworkPath, argsPath);
        const fileExists = fs.existsSync(argsFilePath);
        if (fileExists) {
          argsFileUrls.push(pathToFileURL(argsFilePath).href);
        }
      }

      if (exampleContractsPath) {
        const argsFilePath = path.join(exampleContractsPath, argsPath);
        const fileExists = fs.existsSync(argsFilePath);
        if (fileExists) {
          argsFileUrls.push(pathToFileURL(argsFilePath).href);
        }
      }

      if (externalExtension) {
        const argsFilePath = isDev
          ? path.join(basePath, "../../externalExtensions", externalExtension as string, "extension", argsPath)
          : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension", argsPath);

        const fileExists = fs.existsSync(argsFilePath);
        if (fileExists) {
          argsFileUrls?.push(pathToFileURL(argsFilePath).href);
        }
      }

      const args = await Promise.all(
        argsFileUrls.map(async argsFileUrl => (await import(argsFileUrl)) as Record<string, any>),
      );

      const fileTemplate = (await import(templateFileDescriptor.fileUrl)).default as (
        args: Record<string, string[]>,
      ) => string;

      if (!fileTemplate) {
        throw new Error(
          `Template ${templateTargetName} from ${templateFileDescriptor.source} doesn't have a default export`,
        );
      }
      if (typeof fileTemplate !== "function") {
        throw new Error(
          `Template ${templateTargetName} from ${templateFileDescriptor.source} is not exporting a function by default`,
        );
      }

      const allKeys = [...new Set(args.flatMap(Object.keys))];

      const freshArgs: { [key: string]: string[] } = Object.fromEntries(
        allKeys.map(key => [
          key, // INFO: key for the freshArgs object
          [], // INFO: initial value for the freshArgs object
        ]),
      );

      const combinedArgs = args.reduce<typeof freshArgs>((accumulated, arg) => {
        Object.entries(arg).map(([key, value]) => {
          accumulated[key]?.push(value);
        });
        return accumulated;
      }, freshArgs);

      const output = fileTemplate(combinedArgs);

      const targetPath = path.join(
        targetDir,
        templateFileDescriptor.relativePath.split(templateTargetName)[0],
        templateTargetName,
      );
      fs.writeFileSync(targetPath, output);

      if (isDev) {
        const hasCombinedArgs = Object.keys(combinedArgs).length > 0;
        const hasArgsPaths = argsFileUrls.length > 0;
        const devOutput = `--- TEMPLATE FILE
templates/${templateFileDescriptor.source}${templateFileDescriptor.relativePath}


--- ARGS FILES
${
  hasArgsPaths
    ? argsFileUrls.map(url => `\t- ${url.split("templates")[1] || url.split("externalExtensions")[1]}`).join("\n")
    : "(no args files writing to the template)"
}


--- RESULTING ARGS
${
  hasCombinedArgs
    ? Object.entries(combinedArgs)
        .map(([argName, argValue]) => `\t- ${argName}:\t[${argValue.join(",")}]`)
        // TODO improvement: figure out how to add the values added by each args file
        .join("\n")
    : "(no args sent for the template)"
}
`;
        fs.writeFileSync(`${targetPath}.dev`, devOutput);
      }
    }),
  );
};

const setUpExternalExtensionFiles = async (options: Options, tmpDir: string) => {
  // 1. Create tmp directory to clone external extension
  await fs.promises.mkdir(tmpDir);

  const { repository, branch } = options.externalExtension as ExternalExtension;

  // 2. Clone external extension
  if (branch) {
    await execa("git", ["clone", "--branch", branch, repository, tmpDir], {
      cwd: tmpDir,
    });
  } else {
    await execa("git", ["clone", repository, tmpDir], { cwd: tmpDir });
  }
};

export async function copyTemplateFiles(options: Options, templateDir: string, targetDir: string) {
  copyOrLink = options.dev ? link : copy;
  const basePath = path.join(templateDir, BASE_DIR);
  const tmpDir = path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR);

  // 1. Copy base template to target directory
  await copyBaseFiles(basePath, targetDir, options);

  // 2. Copy solidity framework folder
  const solidityFrameworkPath =
    options.solidityFramework && getSolidityFrameworkPath(options.solidityFramework, templateDir);
  if (solidityFrameworkPath) {
    await copyExtensionFiles(options, solidityFrameworkPath, targetDir);
  }

  const exampleContractsPath =
    options.solidityFramework && path.resolve(templateDir, EXAMPLE_CONTRACTS_DIR, options.solidityFramework);

  // 3. Set up external extension if needed
  if (options.externalExtension) {
    let externalExtensionPath = path.join(tmpDir, "extension");
    if (options.dev) {
      externalExtensionPath = path.join(
        templateDir,
        "../externalExtensions",
        options.externalExtension as string,
        "extension",
      );
    } else {
      await setUpExternalExtensionFiles(options, tmpDir);
    }

    if (options.solidityFramework) {
      const externalExtensionSolidityPath = path.join(
        externalExtensionPath,
        "packages",
        options.solidityFramework,
        "contracts",
      );
      // if external extension does not have solidity framework, we copy the example contracts
      if (!fs.existsSync(externalExtensionSolidityPath) && exampleContractsPath) {
        await copyExtensionFiles(options, exampleContractsPath, targetDir);
      }
    }

    await copyExtensionFiles(options, externalExtensionPath, targetDir);
  }

  const shouldCopyExampleContracts = !options.externalExtension && options.solidityFramework && exampleContractsPath;
  if (shouldCopyExampleContracts) {
    await copyExtensionFiles(options, exampleContractsPath, targetDir);
  }

  // 4. Process templated files and generate output
  await processTemplatedFiles(
    options,
    basePath,
    solidityFrameworkPath,
    shouldCopyExampleContracts ? exampleContractsPath : null,
    targetDir,
  );

  // 5. Delete tmp directory
  if (options.externalExtension && !options.dev) {
    await fs.promises.rm(tmpDir, { recursive: true });
  }

  // 6. Initialize git repo to avoid husky error
  await execa("git", ["init"], { cwd: targetDir });
  await execa("git", ["checkout", "-b", "main"], { cwd: targetDir });
}
