import type {
  LocalUserRemapping,
  ResolvedNpmUserRemapping,
  InstallationName,
  RemappedNpmPackagesGraph,
  Remapping,
  ResolvedUserRemapping,
  UnresolvedNpmUserRemapping,
  RemappedNpmPackagesGraphJson,
} from "./types.js";
import type {
  ResolvedFile,
  ResolvedNpmPackage,
  UserRemappingError,
} from "../../../../../types/solidity.js";
import type { Result } from "../../../../../types/utils.js";

import path from "node:path";

import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors";
import {
  getAllFilesMatching,
  readJsonFile,
  readUtf8File,
} from "@nomicfoundation/hardhat-utils/fs";
import {
  findDependencyPackageJson,
  type PackageJson,
} from "@nomicfoundation/hardhat-utils/package";

import { UserRemappingErrorType } from "../../../../../types/solidity.js";

import { getNpmPackageName } from "./npm-module-parsing.js";
import { parseRemappingString, selectBestRemapping } from "./remappings.js";
import { sourceNamePathJoin } from "./source-name-utils.js";
import { UserRemappingType } from "./types.js";

const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project";

/**
 * Returns a normalized version of the path if it refers to a node_modules in
 * the root directory (i.e. node_modules/...), or a `node_modules` directory
 * in a parent directory (i.e. ../../node_modules/...).
 *
 * Otherwise returns `undefined`.
 *
 * @param pathToNormalize The path to normalize.
 * @returns The normalized path (node_modules/...), or `undefined`.
 */
export function getNormalizeNodeModulesPath(
  pathToNormalize: string,
): string | undefined {
  if (pathToNormalize.startsWith("node_modules/")) {
    return pathToNormalize;
  }

  const normalized = path.posix.normalize(pathToNormalize);

  if (!/^(?:\.\.\/)*node_modules\//.test(normalized)) {
    return undefined;
  }

  return normalized.substring(normalized.indexOf("node_modules/"));
}

export type RemappingsReaderFunction = (
  packageName: string,
  packageVersion: string,
  packagePath: string,
  defaultBehavior: (
    name: string,
    version: string,
    path: string,
  ) => Promise<Array<{ remappings: string[]; source: string }>>,
) => Promise<Array<{ remappings: string[]; source: string }>>;

export function isResolvedUserRemapping(
  remapping: Remapping | ResolvedUserRemapping,
): remapping is ResolvedUserRemapping {
  return (
    "type" in remapping &&
    (remapping.type === UserRemappingType.NPM ||
      remapping.type === UserRemappingType.LOCAL)
  );
}

export class RemappedNpmPackagesGraphImplementation
  implements RemappedNpmPackagesGraph
{
  /**
   * The Hardhat project itself.
   */
  readonly #hardhatProjectPackage: ResolvedNpmPackage;

  /**
   * The remappings reader function to use when reading package remappings.
   */
  readonly #remappingsReader: RemappingsReaderFunction;

  /**
   * This is a map of all the npm packages. Every package that has been
   * loaded by this class, is present in this map.
   *
   * Its value is another map, where the keys are the installation name of each
   * dependency of the package that has been loaded, and the values are objects
   * with the resolved npm package and the remapping that we generate for that
   * package -- installationName --> package relationship.
   *
   * The generated remapping is generated once and stored for each relationship,
   * to preserve its uniqueness.
   */
  readonly #installationMap = new Map<
    ResolvedNpmPackage,
    Map<
      InstallationName,
      { package: ResolvedNpmPackage; generatedRemapping: Remapping }
    >
  >();

  /**
   * A map of all the npm packages, indexed by their input source name root.
   */
  readonly #packageByInputSourceNameRoot = new Map<
    string,
    ResolvedNpmPackage
  >();

  /**
   * A map of all the user remappings of each npm package.
   */
  readonly #userRemappingsPerPackage = new Map<
    ResolvedNpmPackage,
    Array<ResolvedUserRemapping | UnresolvedNpmUserRemapping>
  >();

  /**
   * A map of all the remappings generated to map a direct import within a
   * package to a particular npm file. This is used to generate remappings into
   * packages that use package.exports, as we can't generate more generic
   * remappings for them.
   */
  readonly #generatedRemappingsIntoNpmFiles = new Map<
    ResolvedNpmPackage,
    Map<string, Remapping>
  >();

  public static async create(
    projectRootPath: string,
    remappingsReader: RemappingsReaderFunction = (
      packageName,
      packageVersion,
      packagePath,
      defaultBehavior,
    ) => defaultBehavior(packageName, packageVersion, packagePath),
  ): Promise<RemappedNpmPackagesGraphImplementation> {
    const projectPackageJson = await readJsonFile<PackageJson>(
      path.join(projectRootPath, "package.json"),
    );

    const resolvedNpmPackage: ResolvedNpmPackage = {
      name: projectPackageJson.name,
      version: projectPackageJson.version,
      exports: projectPackageJson.exports,
      rootFsPath: projectRootPath,
      inputSourceNameRoot: HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT,
    };

    return new RemappedNpmPackagesGraphImplementation(
      resolvedNpmPackage,
      remappingsReader,
    );
  }

  private constructor(
    hardhatProjectPackage: ResolvedNpmPackage,
    remappingsReader: RemappingsReaderFunction,
  ) {
    this.#hardhatProjectPackage = hardhatProjectPackage;
    this.#remappingsReader = remappingsReader;
    this.#insertNewPackage(hardhatProjectPackage);
  }

  public getHardhatProjectPackage(): ResolvedNpmPackage {
    return this.#hardhatProjectPackage;
  }

  /**
   * Resolves a dependency of the package `from` by its installation name.
   *
   * This method modifies the graph, potentially loading new packages, but it
   * doesn't read its remappings, and it doesn't take user remappings into
   * account.
   *
   * This method is pretty complex, so read the comments carefully.
   *
   * @param from The package from which the dependency is being resolved.
   * @param installationName The installation name of the dependency.
   * @returns The package and generated remappings, or undefined if the
   * dependency could not be resolved.
   */
  public async resolveDependencyByInstallationName(
    from: ResolvedNpmPackage,
    installationName: InstallationName,
  ): Promise<
    { package: ResolvedNpmPackage; generatedRemapping: Remapping } | undefined
  > {
    // We may need to modify the installation map, so we need to access it.
    const npmPackageDependenciesMap = this.#installationMap.get(from);
    assertHardhatInvariant(
      npmPackageDependenciesMap !== undefined,
      "The npm package must be present in the map",
    );

    // If the dependency already exists with this same installation name we
    // reuse it.
    const existingDependencyNpmPackageByInstallationName =
      npmPackageDependenciesMap.get(installationName);

    if (existingDependencyNpmPackageByInstallationName !== undefined) {
      return existingDependencyNpmPackageByInstallationName;
    }

    // Otherwise, we try to get it's package.json to:
    //  1) Load it if necessary.
    //  2) Add it to the installation map.
    const dependencyPackageJsonPath = await findDependencyPackageJson(
      from.rootFsPath,
      installationName,
    );

    // If we can't find the package.json, it hasn't been installed.
    if (dependencyPackageJsonPath === undefined) {
      return undefined;
    }

    // We read the package.json file of the dependency.
    const dependencyPackageJson = await readJsonFile<PackageJson>(
      dependencyPackageJsonPath,
    );

    // We treat packages from within the monorepo a bit differently, so we
    // check it here. All we do is using a different version to compute
    // its input source name root.
    const dependencyVersion = this.#isPackageJsonFromMonorepo(
      dependencyPackageJsonPath,
    )
      ? "local"
      : dependencyPackageJson.version;

    // We get the input source name root of the dependency, to check if it
    // already exists in the graph.
    const inputSourceNameRoot =
      dependencyPackageJsonPath ===
      path.join(this.#hardhatProjectPackage.rootFsPath, "package.json")
        ? HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT
        : this.#npmPackageToInputSourceNameRoot(
            dependencyPackageJson.name,
            dependencyVersion,
          );

    // If it exists, we need to update the installation map, as it was missing
    // there with this installation name, and we return it.
    const existingDependencyNpmPackageBySourceName =
      this.#packageByInputSourceNameRoot.get(inputSourceNameRoot);
    if (existingDependencyNpmPackageBySourceName !== undefined) {
      const resultOfExistingPackage = {
        package: existingDependencyNpmPackageBySourceName,
        generatedRemapping: this.#generateNpmRemapping(
          from,
          installationName,
          existingDependencyNpmPackageBySourceName,
        ),
      };

      npmPackageDependenciesMap.set(installationName, resultOfExistingPackage);

      return resultOfExistingPackage;
    }

    // Otherwise it's the first time we see this package, so we add it to the
    // graph.
    const newDependencyNpmPackage: ResolvedNpmPackage = {
      name: dependencyPackageJson.name,
      version: dependencyVersion,
      rootFsPath: path.dirname(dependencyPackageJsonPath),
      inputSourceNameRoot,
      exports: dependencyPackageJson.exports,
    };

    this.#insertNewPackage(newDependencyNpmPackage);

    const resultOfNewPackage = {
      package: newDependencyNpmPackage,
      generatedRemapping: this.#generateNpmRemapping(
        from,
        installationName,
        newDependencyNpmPackage,
      ),
    };

    // We also need to add it to the installation map, as a dependency of `from`.
    npmPackageDependenciesMap.set(installationName, resultOfNewPackage);

    return resultOfNewPackage;
  }

  public async selectBestUserRemapping(
    from: ResolvedFile,
    directImport: string,
  ): Promise<Result<ResolvedUserRemapping | undefined, UserRemappingError[]>> {
    let userRemappings = this.#userRemappingsPerPackage.get(from.package);

    if (userRemappings === undefined) {
      const readResult = await this.#readPackageRemappings(from.package);

      if (!readResult.success) {
        return { success: false, error: readResult.error };
      }

      userRemappings = readResult.value;
      this.#userRemappingsPerPackage.set(from.package, userRemappings);
    }

    const bestUserRemappingIndex = selectBestRemapping(
      from.inputSourceName,
      directImport,
      userRemappings,
    );

    if (bestUserRemappingIndex === undefined) {
      return { success: true, value: undefined };
    }

    const bestUserRemapping = userRemappings[bestUserRemappingIndex];

    if (
      bestUserRemapping.type === UserRemappingType.LOCAL ||
      bestUserRemapping.type === UserRemappingType.NPM
    ) {
      return { success: true, value: bestUserRemapping };
    }

    const result = await this.#resolveNpmUserRemapping(
      from.package,
      bestUserRemapping,
    );

    if (!result.success) {
      return { success: false, error: [result.error] };
    }

    // We replace the unresolved user remapping with the resolved one
    userRemappings[bestUserRemappingIndex] = result.value;

    return { success: true, value: result.value };
  }

  public async generateRemappingIntoNpmFile(
    fromNpmPackage: ResolvedNpmPackage,
    directImport: string,
    targetInputSourceName: string,
  ): Promise<Remapping> {
    const remappingsIntoFiles =
      this.#generatedRemappingsIntoNpmFiles.get(fromNpmPackage);
    assertHardhatInvariant(
      remappingsIntoFiles !== undefined,
      "Map of generated remappings should exist",
    );

    const existing = remappingsIntoFiles.get(directImport);
    if (existing !== undefined) {
      assertHardhatInvariant(
        existing.target === targetInputSourceName,
        "Trying to generate different remappings for the same direct import into an npm file",
      );

      return existing;
    }

    const remapping = {
      context: fromNpmPackage.inputSourceNameRoot + "/",
      prefix: directImport,
      target: targetInputSourceName,
    };

    remappingsIntoFiles.set(directImport, remapping);

    return remapping;
  }

  public toJSON(): RemappedNpmPackagesGraphJson {
    return {
      hardhatProjectPackage: this.#hardhatProjectPackage,
      packageByInputSourceNameRoot: Object.fromEntries(
        this.#packageByInputSourceNameRoot.entries(),
      ),
      installationMap: Object.fromEntries(
        Array.from(this.#installationMap.entries()).map(
          ([pkg, dependenciesMap]) => {
            return [
              pkg.inputSourceNameRoot,
              Object.fromEntries(dependenciesMap.entries()),
            ];
          },
        ),
      ),
      userRemappingsPerPackage: Object.fromEntries(
        Array.from(this.#userRemappingsPerPackage.entries()).map(
          ([pkg, remappings]) => {
            return [pkg.inputSourceNameRoot, remappings];
          },
        ),
      ),
      generatedRemappingsIntoNpmFiles: Object.fromEntries(
        Array.from(this.#generatedRemappingsIntoNpmFiles.entries()).map(
          ([pkg, remappings]) => {
            return [pkg.inputSourceNameRoot, Object.fromEntries(remappings)];
          },
        ),
      ),
    };
  }

  /**
   * Inserts a new package into the maps and queues, maintaining the invariants
   * of this class.
   *
   * @param npmPackage The package.
   */
  #insertNewPackage(npmPackage: ResolvedNpmPackage) {
    this.#installationMap.set(npmPackage, new Map());
    this.#packageByInputSourceNameRoot.set(
      npmPackage.inputSourceNameRoot,
      npmPackage,
    );
    this.#generatedRemappingsIntoNpmFiles.set(npmPackage, new Map());
    // Note: We intentionally don't add an empty array to the map of user
    // remappings, so that we can easily check if they have been processed.
  }

  /**
   * Reads all the user remappings of a package, validating their format and
   * processing them, but without loading their npm packages (if any).
   *
   * @param npmPackage The package.
   */
  async #readPackageRemappings(
    npmPackage: ResolvedNpmPackage,
  ): Promise<
    Result<
      Array<LocalUserRemapping | UnresolvedNpmUserRemapping>,
      UserRemappingError[]
    >
  > {
    const allRemappings = await this.#remappingsReader(
      npmPackage.name,
      npmPackage.version,
      npmPackage.rootFsPath,
      async (_packageName, _packageVersion, packagePath) =>
        await this.#defaultReadPackageRemappings(packagePath),
    );

    return this.#parseAndDeduplicateRemappings(npmPackage, allRemappings);
  }

  /**
   * The default behavior of reading all the remappings.txt files in a package.
   * @param packagePath The fs path to the root of the package.
   * @returns An array with one entry per remappings.txt file, with the
   * contents of the file and the fs path to the file.
   */
  async #defaultReadPackageRemappings(
    packagePath: string,
  ): Promise<Array<{ remappings: string[]; source: string }>> {
    const remappingsTxtFiles = await getAllFilesMatching(
      packagePath,
      (f) => path.basename(f) === "remappings.txt",
      (f) => !f.endsWith("node_modules"),
    );

    // Sort by path so the first-wins dedup downstream is deterministic across
    // filesystems. We sort here (and not after hook composition) so that hooks
    // can rely on the contract that remappings they append after next() come
    // last — e.g. hardhat-foundry appends forge's remappings after the
    // package's own remappings.txt files, expecting remappings.txt to win.
    remappingsTxtFiles.sort();

    const results: Array<{ remappings: string[]; source: string }> = [];
    for (const file of remappingsTxtFiles) {
      const contents = await readUtf8File(file);
      const lines = contents
        .split("\n")
        .map((line) => line.trim())
        .filter((line) => line !== "" && !line.startsWith("#"));
      results.push({ remappings: lines, source: file });
    }

    return results;
  }

  /**
   * Parses and deduplicates by "context:prefix" all the remappings from the
   * package.
   *
   * @param npmPackage The npm package.
   * @param allRemappings An array with all the remappings.txt files in the
   * package and their content.
   * @returns A result with the parsed and deduplicated remappings, or an error
   * if there was a problem parsing any of them.
   */
  #parseAndDeduplicateRemappings(
    npmPackage: ResolvedNpmPackage,
    allRemappings: Array<{ remappings: string[]; source: string }>,
  ): Result<
    Array<LocalUserRemapping | UnresolvedNpmUserRemapping>,
    UserRemappingError[]
  > {
    const remappings: Array<LocalUserRemapping | UnresolvedNpmUserRemapping> =
      [];
    const errors: UserRemappingError[] = [];
    const seen = new Set<string>(); // Track by "context:prefix"

    for (const { remappings: remappingStrings, source } of allRemappings) {
      for (const remappingString of remappingStrings) {
        const result = this.#parseUserRemapping(
          npmPackage,
          source,
          remappingString,
        );

        if (!result.success) {
          errors.push(result.error);
          continue;
        }

        if (result.value === undefined) {
          continue;
        }

        // Deduplicate by (context + prefix) - first occurrence wins
        const key = `${result.value.context}:${result.value.prefix}`;
        if (!seen.has(key)) {
          seen.add(key);
          remappings.push(result.value);
        }
      }
    }

    if (errors.length > 0) {
      return { success: false, error: errors };
    }

    return { success: true, value: remappings };
  }

  /**
   * Parses a user remapping, validating it, and preprocessing it, but without
   * loading its npm package (if any).
   *
   * @param npmPackage The npm package, which remapping is being resolved.
   * @param sourceOfTheRemapping The source of the remapping.
   * @param remappingString The remapping in raw format.
   * @returns The parsed user remapping, or undefined if it should be ignored.
   * If the parsing and validation fails, an error is returned.
   */
  #parseUserRemapping(
    npmPackage: ResolvedNpmPackage,
    sourceOfTheRemapping: string,
    remappingString: string,
  ): Result<
    LocalUserRemapping | UnresolvedNpmUserRemapping | undefined,
    UserRemappingError
  > {
    // We first parse the remapping string and validate that it doesn't have
    // a context starting with `npm/`, and that the prefix and targets end in /.
    const remapping = parseRemappingString(remappingString);

    if (remapping === undefined) {
      return {
        success: false,
        error: {
          remapping: remappingString,
          type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX,
          source: sourceOfTheRemapping,
        },
      };
    }

    // Note: User remappings must have each of their components ending with `/`,
    // except for the context. If they don't end with a slash, we add it.
    const context = remapping.context;
    const prefix = remapping.prefix.endsWith("/")
      ? remapping.prefix
      : remapping.prefix + "/";
    let target = remapping.target.endsWith("/")
      ? remapping.target
      : remapping.target + "/";

    const relativeFsPathToRemappingsFile = path.relative(
      npmPackage.rootFsPath,
      path.dirname(sourceOfTheRemapping),
    );

    // If the remapping's target starts with `node_modules/`, we treat it as
    // trying to load an npm dependency, otherwise we treat it as a local
    // remapping.
    const normalizedNodeModulesTarget = getNormalizeNodeModulesPath(target);

    // Local remapping case
    if (normalizedNodeModulesTarget === undefined) {
      return {
        success: true,
        value: {
          type: UserRemappingType.LOCAL,
          context: this.#updateRemappingsTxFragment(
            npmPackage,
            relativeFsPathToRemappingsFile,
            context,
          ),
          prefix,
          target: this.#updateRemappingsTxFragment(
            npmPackage,
            relativeFsPathToRemappingsFile,
            target,
          ),
          originalFormat: remappingString,
          source: sourceOfTheRemapping,
        },
      };
    } else {
      // We update the target to the normalized version
      target = normalizedNodeModulesTarget;
    }

    // If we are here the remapping is a npm remapping.
    // We first remove the node_modules/ prefix from the actual target.
    const targetWithoutNodeModules = target.substring("node_modules/".length);

    // If after doing that the prefix and target are the same, we skip it
    // so that it doesn't even go unnecessarily go through a user remapping.
    if (prefix === targetWithoutNodeModules) {
      return { success: true, value: undefined };
    }

    // If we are treating it as remapping into an npm package, it's syntax,
    // after removing the `node_modules/` prefix, should be similar to
    // an npm module's (i.e. `<package-name>/<file-path>`), except that
    // `<file-path>` here could be a prefix, and not a file path.
    //
    // Note that that package name is the installation name of the dependency
    // within the npm package, not the actual dependency name.
    const installationName = getNpmPackageName(targetWithoutNodeModules);

    if (installationName === undefined) {
      return {
        success: false,
        error: {
          type: UserRemappingErrorType.REMAPPING_WITH_INVALID_SYNTAX,
          source: sourceOfTheRemapping,
          remapping: remappingString,
        },
      };
    }

    return {
      success: true,
      value: {
        type: "UNRESOLVED_NPM",
        installationName,
        context: this.#updateRemappingsTxFragment(
          npmPackage,
          relativeFsPathToRemappingsFile,
          context,
        ),
        prefix,
        target,
        originalFormat: remappingString,
        source: sourceOfTheRemapping,
      },
    };
  }

  async #resolveNpmUserRemapping(
    npmPackage: ResolvedNpmPackage,
    unresolvedNpmRemapping: UnresolvedNpmUserRemapping,
  ): Promise<Result<ResolvedNpmUserRemapping, UserRemappingError>> {
    const dependency = await this.resolveDependencyByInstallationName(
      npmPackage,
      unresolvedNpmRemapping.installationName,
    );

    // If we can't find the dependency, it hasn't been installed.
    if (dependency === undefined) {
      return {
        success: false,
        error: {
          remapping: unresolvedNpmRemapping.originalFormat,
          type: UserRemappingErrorType.REMAPPING_TO_UNINSTALLED_PACKAGE,
          source: unresolvedNpmRemapping.source,
        },
      };
    }

    const target =
      dependency.package.inputSourceNameRoot +
      unresolvedNpmRemapping.target.substring(
        "node_modules/".length + unresolvedNpmRemapping.installationName.length,
      );

    return {
      success: true,
      value: {
        type: UserRemappingType.NPM,
        context: unresolvedNpmRemapping.context,
        prefix: unresolvedNpmRemapping.prefix,
        originalFormat: unresolvedNpmRemapping.originalFormat,
        source: unresolvedNpmRemapping.source,
        target,
        targetNpmPackage: {
          installationName: unresolvedNpmRemapping.installationName,
          package: dependency.package,
        },
      },
    };
  }

  /**
   * Generates a remapping used to resolve an import from `from` to `to` using
   * the installation name `installationName` as a prefix.
   */
  #generateNpmRemapping(
    from: ResolvedNpmPackage,
    installationName: string,
    to: ResolvedNpmPackage,
  ): Remapping {
    return {
      context: from.inputSourceNameRoot + "/",
      prefix: installationName + "/",
      target: to.inputSourceNameRoot + "/",
    };
  }

  #isPackageJsonFromMonorepo(packageJsonFsPath: string): boolean {
    return (
      !packageJsonFsPath.includes("node_modules") &&
      !packageJsonFsPath.startsWith(
        this.#hardhatProjectPackage.rootFsPath + path.sep,
      )
    );
  }

  #npmPackageToInputSourceNameRoot(name: string, version: string): string {
    return `npm/${name}@${version}`;
  }

  /**
   * Updates a fragment of a remapping found in a remappings.txt in the package
   * from.
   *
   * This is used to update both contexts and targets.
   *
   * This function doesn't update any fragment starting with npm/
   */
  #updateRemappingsTxFragment(
    from: ResolvedNpmPackage,
    relativeFsPathToRemappingsFileFromPackage: string,
    remappingFragment: string,
  ): string {
    if (remappingFragment.startsWith("npm/")) {
      return remappingFragment;
    }

    return sourceNamePathJoin(
      // We add a slash here so that it mains it if the rest of the path is empty
      from.inputSourceNameRoot + "/",
      // Same here
      relativeFsPathToRemappingsFileFromPackage + "/",
      remappingFragment,
    );
  }
}
