'use strict';

const {
  ArrayIsArray,
  JSONParse,
  ObjectDefineProperty,
  RegExpPrototypeExec,
  SafeMap,
  StringPrototypeEndsWith,
  StringPrototypeIndexOf,
  StringPrototypeLastIndexOf,
  StringPrototypeSlice,
} = primordials;
const {
  fileURLToPath,
  isURL,
  pathToFileURL,
  URL,
} = require('internal/url');
const { canParse: URLCanParse } = internalBinding('url');
const {
  codes: {
    ERR_INVALID_MODULE_SPECIFIER,
    ERR_MISSING_ARGS,
    ERR_MODULE_NOT_FOUND,
  },
} = require('internal/errors');
const { kEmptyObject } = require('internal/util');
const modulesBinding = internalBinding('modules');
const path = require('path');
const permission = require('internal/process/permission');
const { validateString } = require('internal/validators');
const internalFsBinding = internalBinding('fs');

const nearestParentPackageJSONCache = new SafeMap();

/**
 * @typedef {import('typings/internalBinding/modules').DeserializedPackageConfig} DeserializedPackageConfig
 * @typedef {import('typings/internalBinding/modules').PackageConfig} PackageConfig
 * @typedef {import('typings/internalBinding/modules').SerializedPackageConfig} SerializedPackageConfig
 */

/**
 * @param {URL['pathname']} path
 * @param {SerializedPackageConfig} contents
 * @returns {DeserializedPackageConfig}
 */
function deserializePackageJSON(path, contents) {
  if (contents === undefined) {
    return {
      data: {
        __proto__: null,
        type: 'none', // Ignore unknown types for forwards compatibility
      },
      exists: false,
      path,
    };
  }

  const {
    0: name,
    1: main,
    2: type,
    3: plainImports,
    4: plainExports,
    5: optionalFilePath,
  } = contents;

  const pjsonPath = optionalFilePath ?? path;

  return {
    data: {
      __proto__: null,
      ...(name != null && { name }),
      ...(main != null && { main }),
      ...(type != null && { type }),
      ...(plainImports != null && {
        // This getters are used to lazily parse the imports and exports fields.
        get imports() {
          const value = requiresJSONParse(plainImports) ? JSONParse(plainImports) : plainImports;
          ObjectDefineProperty(this, 'imports', { __proto__: null, value });
          return this.imports;
        },
      }),
      ...(plainExports != null && {
        get exports() {
          const value = requiresJSONParse(plainExports) ? JSONParse(plainExports) : plainExports;
          ObjectDefineProperty(this, 'exports', { __proto__: null, value });
          return this.exports;
        },
      }),
    },
    exists: true,
    path: pjsonPath,
  };
}

// The imports and exports fields can be either undefined or a string.
// - If it's a string, it's either plain string or a stringified JSON string.
// - If it's a stringified JSON string, it starts with either '[' or '{'.
const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' || value[0] === '{'));

/**
 * Reads a package.json file and returns the parsed contents.
 * @param {string} jsonPath
 * @param {{
 *   base?: URL | string,
 *   specifier?: URL | string,
 *   isESM?: boolean,
 * }} options
 * @returns {PackageConfig}
 */
function read(jsonPath, { base, specifier, isESM } = kEmptyObject) {
  // This function will be called by both CJS and ESM, so we need to make sure
  // non-null attributes are converted to strings.
  const parsed = modulesBinding.readPackageJSON(
    jsonPath,
    isESM,
    base == null ? undefined : `${base}`,
    specifier == null ? undefined : `${specifier}`,
  );

  const result = deserializePackageJSON(jsonPath, parsed);

  return {
    __proto__: null,
    ...result.data,
    exists: result.exists,
    pjsonPath: result.path,
  };
}

/**
 * Given a file path, walk the filesystem upwards until we find its closest parent
 * `package.json` file, stopping when:
 * 1. we find a `package.json` file;
 * 2. we find a path that we do not have permission to read;
 * 3. we find a containing `node_modules` directory;
 * 4. or, we reach the filesystem root
 * @returns {undefined | string}
 */
function findParentPackageJSON(checkPath) {
  const enabledPermission = permission.isEnabled();

  const rootSeparatorIndex = StringPrototypeIndexOf(checkPath, path.sep);
  let separatorIndex;

  do {
    separatorIndex = StringPrototypeLastIndexOf(checkPath, path.sep);
    checkPath = StringPrototypeSlice(checkPath, 0, separatorIndex);

    if (enabledPermission && !permission.has('fs.read', checkPath + path.sep)) {
      return undefined;
    }

    if (StringPrototypeEndsWith(checkPath, path.sep + 'node_modules')) {
      return undefined;
    }

    const maybePackageJSONPath = checkPath + path.sep + 'package.json';
    const stat = internalFsBinding.internalModuleStat(checkPath + path.sep + 'package.json');

    const packageJSONExists = stat === 0;
    if (packageJSONExists) {
      return maybePackageJSONPath;
    }
  } while (separatorIndex > rootSeparatorIndex);

  return undefined;
}

/**
 * Get the nearest parent package.json file from a given path.
 * Return the package.json data and the path to the package.json file, or undefined.
 * @param {string} checkPath The path to start searching from.
 * @returns {undefined | DeserializedPackageConfig}
 */
function getNearestParentPackageJSON(checkPath) {
  const nearestParentPackageJSON = findParentPackageJSON(checkPath);

  if (nearestParentPackageJSON === undefined) {
    return undefined;
  }

  if (nearestParentPackageJSONCache.has(nearestParentPackageJSON)) {
    return nearestParentPackageJSONCache.get(nearestParentPackageJSON);
  }

  const result = modulesBinding.readPackageJSON(nearestParentPackageJSON);

  if (result === undefined) {
    nearestParentPackageJSONCache.set(checkPath, undefined);
    return undefined;
  }

  const packageConfig = deserializePackageJSON(checkPath, result);
  nearestParentPackageJSONCache.set(nearestParentPackageJSON, packageConfig);

  return packageConfig;
}

/**
 * Returns the package configuration for the given resolved URL.
 * @param {URL | string} resolved - The resolved URL.
 * @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration.
 */
function getPackageScopeConfig(resolved) {
  const result = modulesBinding.getPackageScopeConfig(`${resolved}`);

  if (ArrayIsArray(result)) {
    const { data, exists, path } = deserializePackageJSON(`${resolved}`, result);

    return {
      __proto__: null,
      ...data,
      exists,
      pjsonPath: path,
    };
  }

  // This means that the response is a string
  // and it is the path to the package.json file
  return {
    __proto__: null,
    pjsonPath: result,
    exists: false,
    type: 'none',
  };
}

/**
 * Returns the package type for a given URL.
 * @param {URL} url - The URL to get the package type for.
 */
function getPackageType(url) {
  const type = modulesBinding.getPackageType(`${url}`);
  return type ?? 'none';
}

const invalidPackageNameRegEx = /^\.|%|\\/;
/**
 * Parse a package name from a specifier.
 * @param {string} specifier - The import specifier.
 * @param {string | URL | undefined} base - The parent URL.
 */
function parsePackageName(specifier, base) {
  let separatorIndex = StringPrototypeIndexOf(specifier, '/');
  let validPackageName = true;
  let isScoped = false;
  if (specifier[0] === '@') {
    isScoped = true;
    if (separatorIndex === -1 || specifier.length === 0) {
      validPackageName = false;
    } else {
      separatorIndex = StringPrototypeIndexOf(
        specifier, '/', separatorIndex + 1);
    }
  }

  const packageName = separatorIndex === -1 ?
    specifier : StringPrototypeSlice(specifier, 0, separatorIndex);

  // Package name cannot have leading . and cannot have percent-encoding or
  // \\ separators.
  if (RegExpPrototypeExec(invalidPackageNameRegEx, packageName) !== null) {
    validPackageName = false;
  }

  if (!validPackageName) {
    throw new ERR_INVALID_MODULE_SPECIFIER(
      specifier, 'is not a valid package name', fileURLToPath(base));
  }

  const packageSubpath = '.' + (separatorIndex === -1 ? '' :
    StringPrototypeSlice(specifier, separatorIndex));

  return { packageName, packageSubpath, isScoped };
}

function getPackageJSONURL(specifier, base) {
  const { packageName, packageSubpath, isScoped } = parsePackageName(specifier, base);

  // ResolveSelf
  const packageConfig = getPackageScopeConfig(base);
  if (packageConfig.exists) {
    if (packageConfig.exports != null && packageConfig.name === packageName) {
      const packageJSONPath = packageConfig.pjsonPath;
      return { packageJSONUrl: pathToFileURL(packageJSONPath), packageJSONPath, packageSubpath };
    }
  }

  let packageJSONUrl = new URL(`./node_modules/${packageName}/package.json`, base);
  let packageJSONPath = fileURLToPath(packageJSONUrl);
  let lastPath;
  do {
    const stat = internalFsBinding.internalModuleStat(
      StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13),
    );
    // Check for !stat.isDirectory()
    if (stat !== 1) {
      lastPath = packageJSONPath;
      packageJSONUrl = new URL(
        `${isScoped ? '../' : ''}../../../node_modules/${packageName}/package.json`,
        packageJSONUrl,
      );
      packageJSONPath = fileURLToPath(packageJSONUrl);
      continue;
    }

    // Package match.
    return { packageJSONUrl, packageJSONPath, packageSubpath };
  } while (packageJSONPath.length !== lastPath.length);

  throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
}

/** @type {import('./esm/resolve.js').defaultResolve} */
let defaultResolve;
/**
 * @param {URL['href'] | string | URL} specifier The location for which to get the "root" package.json
 * @param {URL['href'] | string | URL} [base] The location of the current module (ex file://tmp/foo.js).
 */
function findPackageJSON(specifier, base = 'data:') {
  if (arguments.length === 0) {
    throw new ERR_MISSING_ARGS('specifier');
  }
  try {
    specifier = `${specifier}`;
  } catch {
    validateString(specifier, 'specifier');
  }

  let parentURL = base;
  if (!isURL(base)) {
    validateString(base, 'base');
    parentURL = path.isAbsolute(base) ? pathToFileURL(base) : new URL(base);
  }

  if (specifier && specifier[0] !== '.' && specifier[0] !== '/' && !URLCanParse(specifier)) {
    // If `specifier` is a bare specifier.
    const { packageJSONPath } = getPackageJSONURL(specifier, parentURL);
    return packageJSONPath;
  }

  let resolvedTarget;
  defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;

  try {
    // TODO(@JakobJingleheimer): Detect whether findPackageJSON is being used within a loader
    // (possibly piggyback on `allowImportMetaResolve`)
    // - When inside, use the default resolve
    //   - (I think it's impossible to use the chain because of re-entry & a deadlock from atomics).
    // - When outside, use cascadedLoader.resolveSync (not implemented yet, but the pieces exist).
    resolvedTarget = defaultResolve(specifier, { parentURL: `${parentURL}` }).url;
  } catch (err) {
    if (err.code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
      resolvedTarget = err.url;
    } else {
      throw err;
    }
  }

  const pkg = getNearestParentPackageJSON(fileURLToPath(resolvedTarget));

  return pkg?.path;
}

module.exports = {
  read,
  getNearestParentPackageJSON,
  getPackageScopeConfig,
  getPackageType,
  getPackageJSONURL,
  findPackageJSON,
};
