'use strict'

import getEsmExports from './get-esm-exports.mjs'
import { parse as parseCjs, init as parserInit } from 'cjs-module-lexer'
import { readFileSync, existsSync } from 'fs'
import { builtinModules, createRequire } from 'module'
import { fileURLToPath, pathToFileURL } from 'url'
import { dirname, join } from 'path'

const nodeMajor = Number(process.versions.node.split('.')[0])
export const hasModuleExportsCJSDefault = nodeMajor >= 23

let parserInitialized = false

function addDefault (arr) {
  return new Set(['default', ...arr])
}

// Cached exports for Node built-in modules
const BUILT_INS = new Map()

let require

function getExportsForNodeBuiltIn (name) {
  let exports = BUILT_INS.get()

  if (!require) {
    require = createRequire(import.meta.url)
  }

  if (!exports) {
    exports = new Set(addDefault(Object.keys(require(name))))
    BUILT_INS.set(name, exports)
  }

  return exports
}

const urlsBeingProcessed = new Set() // Guard against circular imports.

/**
 * This function looks for the package.json which contains the specifier trying to resolve.
 * Once the package.json file has been found, we extract the file path from the specifier
 * @param {string} specifier The specifier that is being search for inside the imports object
 * @param {URL|string} fromUrl The url from which the search starts from
 * @returns array with url and resolvedExport
 */
function resolvePackageImports (specifier, fromUrl) {
  try {
    const fromPath = fileURLToPath(fromUrl)
    let currentDir = dirname(fromPath)

    // search for package.json file which has the real url to export
    while (currentDir !== dirname(currentDir)) {
      const packageJsonPath = join(currentDir, 'package.json')

      if (existsSync(packageJsonPath)) {
        const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
        if (packageJson.imports && packageJson.imports[specifier]) {
          const imports = packageJson.imports[specifier]

          // Look for path inside packageJson
          let resolvedExport
          if (imports && typeof imports === 'object') {
            const requireExport = imports.require
            const importExport = imports.import
            // look for the possibility of require and import which is standard for CJS/ESM
            if (requireExport || importExport) {
              // trying to resolve based on order of importance
              resolvedExport = requireExport.node || requireExport.default || importExport.node || importExport.default
            } else if (imports.node || imports.default) {
              resolvedExport = imports.node || imports.default
            }
          } else if (typeof imports === 'string') {
            resolvedExport = imports
          }

          if (resolvedExport) {
            const url = resolvedExport.startsWith('.')
              ? pathToFileURL(join(currentDir, resolvedExport))
              : fromUrl
            return [url, resolvedExport]
          }
        }
        // return if we find a package.json but did not find an import
        return null
      }

      currentDir = dirname(currentDir)
    }
  } catch (cause) {
    throw Error(`Failed to find export: ${specifier}`, { cause })
  }
  return null
}

async function getCjsExports (url, context, parentLoad, source) {
  if (urlsBeingProcessed.has(url)) {
    return []
  }
  urlsBeingProcessed.add(url)

  try {
    if (!parserInitialized) {
      await parserInit()
      parserInitialized = true
    }
    const result = parseCjs(source)
    const full = addDefault(result.exports)

    await Promise.all(result.reexports.map(async re => {
      if (re.startsWith('node:') || builtinModules.includes(re)) {
        for (const each of getExportsForNodeBuiltIn(re)) {
          full.add(each)
        }
      } else {
        if (re === '.') {
          re = './'
        }

        // Entries in the import field should always start with #
        if (re.startsWith('#')) {
          const resolved = resolvePackageImports(re, url)
          if (!resolved) return
          [url, re] = resolved
        }

        // Resolve the re-exported module relative to the current module.
        if (!require) {
          require = createRequire(import.meta.url)
        }
        const newUrl = pathToFileURL(require.resolve(re, { paths: [dirname(fileURLToPath(url))] })).href

        if (newUrl.endsWith('.node') || newUrl.endsWith('.json')) {
          return
        }

        for (const each of await getExports(newUrl, context, parentLoad)) {
          full.add(each)
        }
      }
    }))

    // added in node 23 as alias for default in cjs modules
    if (full.has('default') && hasModuleExportsCJSDefault) {
      full.add('module.exports')
    }

    // we know that it's commonjs at this point, because ESM failed
    context.format = 'commonjs'
    return full
  } finally {
    urlsBeingProcessed.delete(url)
  }
}

/**
 * Inspects a module for its type (commonjs or module), attempts to get the
 * source code for said module from the loader API, and parses the result
 * for the entities exported from that module.
 *
 * @param {string} url A file URL string pointing to the module that
 * we should get the exports of.
 * @param {object} context Context object as provided by the `load`
 * hook from the loaders API.
 * @param {Function} parentLoad Next hook function in the loaders API
 * hook chain.
 *
 * @returns {Promise<Set<string>>} An array of identifiers exported by the module.
 * Please see {@link getEsmExports} for caveats on special identifiers that may
 * be included in the result set.
 */
export async function getExports (url, context, parentLoad) {
  // `parentLoad` gives us the possibility of getting the source
  // from an upstream loader. This doesn't always work though,
  // so later on we fall back to reading it from disk.
  const parentCtx = await parentLoad(url, context)
  let source = parentCtx.source
  const format = parentCtx.format

  if (!source) {
    if (format === 'builtin') {
      // Builtins don't give us the source property, so we're stuck
      // just requiring it to get the exports.
      return getExportsForNodeBuiltIn(url)
    }

    // Sometimes source is retrieved by parentLoad, CommonJs isn't.
    source = readFileSync(fileURLToPath(url), 'utf8')
  }

  try {
    if (format === 'module') {
      return getEsmExports(source)
    }

    if (format === 'commonjs') {
      return await getCjsExports(url, context, parentLoad, source)
    }

    // At this point our `format` is either undefined or not known by us. Fall
    // back to parsing as ESM/CJS.
    const esmExports = getEsmExports(source)
    if (!esmExports.length) {
      // TODO(bengl) it's might be possible to get here if somehow the format
      // isn't set at first and yet we have an ESM module with no exports.
      // I couldn't construct an example that would do this, so maybe it's
      // impossible?
      return await getCjsExports(url, context, parentLoad, source)
    }
  } catch (cause) {
    const err = new Error(`Failed to parse '${url}'`)
    err.cause = cause
    throw err
  }
}
