import { dirname, resolve, extname, normalize, sep } from 'path';
import builtinList from 'builtin-modules';
import deepMerge from 'deepmerge';
import isModule from 'is-module';
import fs, { realpathSync } from 'fs';
import { promisify } from 'util';
import { createFilter } from '@rollup/pluginutils';
import resolveModule from 'resolve';

const exists = promisify(fs.exists);
const readFile = promisify(fs.readFile);
const realpath = promisify(fs.realpath);
const stat = promisify(fs.stat);

const onError = (error) => {
  if (error.code === 'ENOENT') {
    return false;
  }
  throw error;
};

const makeCache = (fn) => {
  const cache = new Map();
  const wrapped = async (param, done) => {
    if (cache.has(param) === false) {
      cache.set(
        param,
        fn(param).catch((err) => {
          cache.delete(param);
          throw err;
        })
      );
    }

    try {
      const result = cache.get(param);
      const value = await result;
      return done(null, value);
    } catch (error) {
      return done(error);
    }
  };

  wrapped.clear = () => cache.clear();

  return wrapped;
};

const isDirCached = makeCache(async (file) => {
  try {
    const stats = await stat(file);
    return stats.isDirectory();
  } catch (error) {
    return onError(error);
  }
});

const isFileCached = makeCache(async (file) => {
  try {
    const stats = await stat(file);
    return stats.isFile();
  } catch (error) {
    return onError(error);
  }
});

const readCachedFile = makeCache(readFile);

const resolveId = promisify(resolveModule);

// returns the imported package name for bare module imports
function getPackageName(id) {
  if (id.startsWith('.') || id.startsWith('/')) {
    return null;
  }

  const split = id.split('/');

  // @my-scope/my-package/foo.js -> @my-scope/my-package
  // @my-scope/my-package -> @my-scope/my-package
  if (split[0][0] === '@') {
    return `${split[0]}/${split[1]}`;
  }

  // my-package/foo.js -> my-package
  // my-package -> my-package
  return split[0];
}

function getMainFields(options) {
  let mainFields;
  if (options.mainFields) {
    ({ mainFields } = options);
  } else {
    mainFields = ['module', 'main'];
  }
  if (options.browser && mainFields.indexOf('browser') === -1) {
    return ['browser'].concat(mainFields);
  }
  if (!mainFields.length) {
    throw new Error('Please ensure at least one `mainFields` value is specified');
  }
  return mainFields;
}

function getPackageInfo(options) {
  const { cache, extensions, pkg, mainFields, preserveSymlinks, useBrowserOverrides } = options;
  let { pkgPath } = options;

  if (cache.has(pkgPath)) {
    return cache.get(pkgPath);
  }

  // browserify/resolve doesn't realpath paths returned in its packageFilter callback
  if (!preserveSymlinks) {
    pkgPath = realpathSync(pkgPath);
  }

  const pkgRoot = dirname(pkgPath);

  const packageInfo = {
    // copy as we are about to munge the `main` field of `pkg`.
    packageJson: Object.assign({}, pkg),

    // path to package.json file
    packageJsonPath: pkgPath,

    // directory containing the package.json
    root: pkgRoot,

    // which main field was used during resolution of this module (main, module, or browser)
    resolvedMainField: 'main',

    // whether the browser map was used to resolve the entry point to this module
    browserMappedMain: false,

    // the entry point of the module with respect to the selected main field and any
    // relevant browser mappings.
    resolvedEntryPoint: ''
  };

  let overriddenMain = false;
  for (let i = 0; i < mainFields.length; i++) {
    const field = mainFields[i];
    if (typeof pkg[field] === 'string') {
      pkg.main = pkg[field];
      packageInfo.resolvedMainField = field;
      overriddenMain = true;
      break;
    }
  }

  const internalPackageInfo = {
    cachedPkg: pkg,
    hasModuleSideEffects: () => null,
    hasPackageEntry: overriddenMain !== false || mainFields.indexOf('main') !== -1,
    packageBrowserField:
      useBrowserOverrides &&
      typeof pkg.browser === 'object' &&
      Object.keys(pkg.browser).reduce((browser, key) => {
        let resolved = pkg.browser[key];
        if (resolved && resolved[0] === '.') {
          resolved = resolve(pkgRoot, resolved);
        }
        /* eslint-disable no-param-reassign */
        browser[key] = resolved;
        if (key[0] === '.') {
          const absoluteKey = resolve(pkgRoot, key);
          browser[absoluteKey] = resolved;
          if (!extname(key)) {
            extensions.reduce((subBrowser, ext) => {
              subBrowser[absoluteKey + ext] = subBrowser[key];
              return subBrowser;
            }, browser);
          }
        }
        return browser;
      }, {}),
    packageInfo
  };

  const browserMap = internalPackageInfo.packageBrowserField;
  if (
    useBrowserOverrides &&
    typeof pkg.browser === 'object' &&
    // eslint-disable-next-line no-prototype-builtins
    browserMap.hasOwnProperty(pkg.main)
  ) {
    packageInfo.resolvedEntryPoint = browserMap[pkg.main];
    packageInfo.browserMappedMain = true;
  } else {
    // index.node is technically a valid default entrypoint as well...
    packageInfo.resolvedEntryPoint = resolve(pkgRoot, pkg.main || 'index.js');
    packageInfo.browserMappedMain = false;
  }

  const packageSideEffects = pkg.sideEffects;
  if (typeof packageSideEffects === 'boolean') {
    internalPackageInfo.hasModuleSideEffects = () => packageSideEffects;
  } else if (Array.isArray(packageSideEffects)) {
    internalPackageInfo.hasModuleSideEffects = createFilter(packageSideEffects, null, {
      resolve: pkgRoot
    });
  }

  cache.set(pkgPath, internalPackageInfo);
  return internalPackageInfo;
}

function normalizeInput(input) {
  if (Array.isArray(input)) {
    return input;
  } else if (typeof input === 'object') {
    return Object.values(input);
  }

  // otherwise it's a string
  return [input];
}

// Resolve module specifiers in order. Promise resolves to the first module that resolves
// successfully, or the error that resulted from the last attempted module resolution.
function resolveImportSpecifiers(importSpecifierList, resolveOptions) {
  let promise = Promise.resolve();

  for (let i = 0; i < importSpecifierList.length; i++) {
    promise = promise.then((value) => {
      // if we've already resolved to something, just return it.
      if (value) {
        return value;
      }

      return resolveId(importSpecifierList[i], resolveOptions).then((result) => {
        if (!resolveOptions.preserveSymlinks) {
          result = realpathSync(result);
        }
        return result;
      });
    });

    if (i < importSpecifierList.length - 1) {
      // swallow MODULE_NOT_FOUND errors from all but the last resolution
      promise = promise.catch((error) => {
        if (error.code !== 'MODULE_NOT_FOUND') {
          throw error;
        }
      });
    }
  }

  return promise;
}

/* eslint-disable no-param-reassign, no-shadow, no-undefined */

const builtins = new Set(builtinList);
const ES6_BROWSER_EMPTY = '\0node-resolve:empty.js';
const nullFn = () => null;
const deepFreeze = (object) => {
  Object.freeze(object);

  for (const value of Object.values(object)) {
    if (typeof value === 'object' && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }

  return object;
};
const defaults = {
  customResolveOptions: {},
  dedupe: [],
  // It's important that .mjs is listed before .js so that Rollup will interpret npm modules
  // which deploy both ESM .mjs and CommonJS .js files as ESM.
  extensions: ['.mjs', '.js', '.json', '.node'],
  resolveOnly: []
};
const DEFAULTS = deepFreeze(deepMerge({}, defaults));

function nodeResolve(opts = {}) {
  const options = Object.assign({}, defaults, opts);
  const { customResolveOptions, extensions, jail } = options;
  const warnings = [];
  const packageInfoCache = new Map();
  const idToPackageInfo = new Map();
  const mainFields = getMainFields(options);
  const useBrowserOverrides = mainFields.indexOf('browser') !== -1;
  const isPreferBuiltinsSet = options.preferBuiltins === true || options.preferBuiltins === false;
  const preferBuiltins = isPreferBuiltinsSet ? options.preferBuiltins : true;
  const rootDir = options.rootDir || process.cwd();
  let { dedupe } = options;
  let rollupOptions;

  if (options.only) {
    warnings.push('node-resolve: The `only` options is deprecated, please use `resolveOnly`');
    options.resolveOnly = options.only;
  }

  if (typeof dedupe !== 'function') {
    dedupe = (importee) =>
      options.dedupe.includes(importee) || options.dedupe.includes(getPackageName(importee));
  }

  const resolveOnly = options.resolveOnly.map((pattern) => {
    if (pattern instanceof RegExp) {
      return pattern;
    }
    const normalized = pattern.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
    return new RegExp(`^${normalized}$`);
  });

  const browserMapCache = new Map();
  let preserveSymlinks;

  return {
    name: 'node-resolve',

    buildStart(options) {
      rollupOptions = options;

      for (const warning of warnings) {
        this.warn(warning);
      }

      ({ preserveSymlinks } = options);
    },

    generateBundle() {
      readCachedFile.clear();
      isFileCached.clear();
      isDirCached.clear();
    },

    async resolveId(importee, importer) {
      if (importee === ES6_BROWSER_EMPTY) {
        return importee;
      }
      // ignore IDs with null character, these belong to other plugins
      if (/\0/.test(importee)) return null;

      // strip hash and query params from import
      const [withoutHash, hash] = importee.split('#');
      const [importPath, params] = withoutHash.split('?');
      const importSuffix = `${params ? `?${params}` : ''}${hash ? `#${hash}` : ''}`;
      importee = importPath;

      const basedir = !importer || dedupe(importee) ? rootDir : dirname(importer);

      // https://github.com/defunctzombie/package-browser-field-spec
      const browser = browserMapCache.get(importer);
      if (useBrowserOverrides && browser) {
        const resolvedImportee = resolve(basedir, importee);
        if (browser[importee] === false || browser[resolvedImportee] === false) {
          return ES6_BROWSER_EMPTY;
        }
        const browserImportee =
          browser[importee] ||
          browser[resolvedImportee] ||
          browser[`${resolvedImportee}.js`] ||
          browser[`${resolvedImportee}.json`];
        if (browserImportee) {
          importee = browserImportee;
        }
      }

      const parts = importee.split(/[/\\]/);
      let id = parts.shift();
      let isRelativeImport = false;

      if (id[0] === '@' && parts.length > 0) {
        // scoped packages
        id += `/${parts.shift()}`;
      } else if (id[0] === '.') {
        // an import relative to the parent dir of the importer
        id = resolve(basedir, importee);
        isRelativeImport = true;
      }

      if (
        !isRelativeImport &&
        resolveOnly.length &&
        !resolveOnly.some((pattern) => pattern.test(id))
      ) {
        if (normalizeInput(rollupOptions.input).includes(importee)) {
          return null;
        }
        return false;
      }

      let hasModuleSideEffects = nullFn;
      let hasPackageEntry = true;
      let packageBrowserField = false;
      let packageInfo;

      const filter = (pkg, pkgPath) => {
        const info = getPackageInfo({
          cache: packageInfoCache,
          extensions,
          pkg,
          pkgPath,
          mainFields,
          preserveSymlinks,
          useBrowserOverrides
        });

        ({ packageInfo, hasModuleSideEffects, hasPackageEntry, packageBrowserField } = info);

        return info.cachedPkg;
      };

      let resolveOptions = {
        basedir,
        packageFilter: filter,
        readFile: readCachedFile,
        isFile: isFileCached,
        isDirectory: isDirCached,
        extensions
      };

      if (preserveSymlinks !== undefined) {
        resolveOptions.preserveSymlinks = preserveSymlinks;
      }

      const importSpecifierList = [];

      if (importer === undefined && !importee[0].match(/^\.?\.?\//)) {
        // For module graph roots (i.e. when importer is undefined), we
        // need to handle 'path fragments` like `foo/bar` that are commonly
        // found in rollup config files. If importee doesn't look like a
        // relative or absolute path, we make it relative and attempt to
        // resolve it. If we don't find anything, we try resolving it as we
        // got it.
        importSpecifierList.push(`./${importee}`);
      }

      const importeeIsBuiltin = builtins.has(importee);

      if (importeeIsBuiltin && (!preferBuiltins || !isPreferBuiltinsSet)) {
        // The `resolve` library will not resolve packages with the same
        // name as a node built-in module. If we're resolving something
        // that's a builtin, and we don't prefer to find built-ins, we
        // first try to look up a local module with that name. If we don't
        // find anything, we resolve the builtin which just returns back
        // the built-in's name.
        importSpecifierList.push(`${importee}/`);
      }

      // TypeScript files may import '.js' to refer to either '.ts' or '.tsx'
      if (importer && importee.endsWith('.js')) {
        for (const ext of ['.ts', '.tsx']) {
          if (importer.endsWith(ext) && extensions.includes(ext)) {
            importSpecifierList.push(importee.replace(/.js$/, ext));
          }
        }
      }

      importSpecifierList.push(importee);
      resolveOptions = Object.assign(resolveOptions, customResolveOptions);

      try {
        let resolved = await resolveImportSpecifiers(importSpecifierList, resolveOptions);

        if (resolved && packageBrowserField) {
          if (Object.prototype.hasOwnProperty.call(packageBrowserField, resolved)) {
            if (!packageBrowserField[resolved]) {
              browserMapCache.set(resolved, packageBrowserField);
              return ES6_BROWSER_EMPTY;
            }
            resolved = packageBrowserField[resolved];
          }
          browserMapCache.set(resolved, packageBrowserField);
        }

        if (hasPackageEntry && !preserveSymlinks && resolved) {
          const fileExists = await exists(resolved);
          if (fileExists) {
            resolved = await realpath(resolved);
          }
        }

        idToPackageInfo.set(resolved, packageInfo);

        if (hasPackageEntry) {
          if (builtins.has(resolved) && preferBuiltins && isPreferBuiltinsSet) {
            return null;
          } else if (importeeIsBuiltin && preferBuiltins) {
            if (!isPreferBuiltinsSet) {
              this.warn(
                `preferring built-in module '${importee}' over local alternative at '${resolved}', pass 'preferBuiltins: false' to disable this behavior or 'preferBuiltins: true' to disable this warning`
              );
            }
            return null;
          } else if (jail && resolved.indexOf(normalize(jail.trim(sep))) !== 0) {
            return null;
          }
        }

        if (resolved && options.modulesOnly) {
          const code = await readFile(resolved, 'utf-8');
          if (isModule(code)) {
            return {
              id: `${resolved}${importSuffix}`,
              moduleSideEffects: hasModuleSideEffects(resolved)
            };
          }
          return null;
        }
        const result = {
          id: `${resolved}${importSuffix}`,
          moduleSideEffects: hasModuleSideEffects(resolved)
        };
        return result;
      } catch (error) {
        return null;
      }
    },

    load(importee) {
      if (importee === ES6_BROWSER_EMPTY) {
        return 'export default {};';
      }
      return null;
    },

    getPackageInfoForId(id) {
      return idToPackageInfo.get(id);
    }
  };
}

export default nodeResolve;
export { DEFAULTS, nodeResolve };
