import { Module, createRequire } from 'node:module'
import vm from 'node:vm'
import { sep } from 'node:path'
import { join, dirname, sep as posixSep, resolve as pathResolve } from 'node:path/posix'
import { fileURLToPath, pathToFileURL } from 'node:url'

const toPosixPath = (path: string) => path.split(sep).join(posixSep)

type RegisteredModule = {
  source: string
  loaded: boolean
  filepath: string
  // lazily parsed json string
  parsedJson?: any
}
type ModuleResolutions = (subpath: string, handleExportMap?: boolean) => string

const registeredSymlinks = new Map<string, string>()

class SymlinkAwareRegisteredModulesMap extends Map<string, RegisteredModule> {
  private resolveSymlink(path: string) {
    for (const [symlinkPath, targetPath] of registeredSymlinks) {
      if (path === symlinkPath) {
        return targetPath
      }
      if (path.startsWith(symlinkPath + '/')) {
        return targetPath + path.slice(symlinkPath.length)
      }
    }

    return path
  }

  override get(key: string): RegisteredModule | undefined {
    return super.get(this.resolveSymlink(key))
  }
}

const registeredModules = new SymlinkAwareRegisteredModulesMap()
const memoizedPackageResolvers = new WeakMap<RegisteredModule, ModuleResolutions>()

const require = createRequire(import.meta.url)

let hookedIn = false

function parseJson(matchedModule: RegisteredModule) {
  if (matchedModule.parsedJson) {
    return matchedModule.parsedJson
  }

  try {
    const jsonContent = JSON.parse(matchedModule.source)
    matchedModule.parsedJson = jsonContent
    return jsonContent
  } catch (error) {
    throw new Error(`Failed to parse JSON module: ${matchedModule.filepath}`, { cause: error })
  }
}

type Condition = string // 'import', 'require', 'default', 'node-addon' etc
type SubpathMatcher = string
type ConditionalTarget = { [key in Condition]: string | ConditionalTarget }
type SubpathTarget = string | ConditionalTarget
/**
 * @example
 * {
 *   ".": "./main.js",
 *   "./foo": {
 *     "import": "./foo.js",
 *     "require": "./foo.cjs"
 *   }
 * }
 */
type NormalizedExports = Record<SubpathMatcher, SubpathTarget | Record<Condition, SubpathTarget>>

// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L555
function isConditionalExportsMainSugar(exports: any) {
  if (typeof exports === 'string' || Array.isArray(exports)) {
    return true
  }
  if (typeof exports !== 'object' || exports === null) {
    return false
  }

  // not doing validation at this point, if the package.json was misconfigured
  // we would not get to this point as it would throw when running `next build`
  const keys = Object.keys(exports)
  return keys.length > 0 && (keys[0] === '' || keys[0][0] !== '.')
}

// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L671
function patternKeyCompare(a: string, b: string) {
  const aPatternIndex = a.indexOf('*')
  const bPatternIndex = b.indexOf('*')
  const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1
  const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1
  if (baseLenA > baseLenB) {
    return -1
  }
  if (baseLenB > baseLenA) {
    return 1
  }
  if (aPatternIndex === -1) {
    return 1
  }
  if (bPatternIndex === -1) {
    return -1
  }
  if (a.length > b.length) {
    return -1
  }
  if (b.length > a.length) {
    return 1
  }
  return 0
}

function applyWildcardMatch(target: string, bestMatchSubpath?: string) {
  return bestMatchSubpath ? target.replace('*', bestMatchSubpath) : target
}

// https://github.com/nodejs/node/blob/323f19c18fea06b9234a0c945394447b077fe565/lib/internal/modules/helpers.js#L76
const conditions = new Set(['require', 'node', 'node-addons', 'default'])

// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L480
function matchConditions(target: SubpathTarget, bestMatchSubpath?: string) {
  if (typeof target === 'string') {
    return applyWildcardMatch(target, bestMatchSubpath)
  }

  if (Array.isArray(target) && target.length > 0) {
    for (const targetItem of target) {
      return matchConditions(targetItem, bestMatchSubpath)
    }
  }

  if (typeof target === 'object' && target !== null) {
    for (const [condition, targetValue] of Object.entries(target)) {
      if (conditions.has(condition)) {
        return matchConditions(targetValue, bestMatchSubpath)
      }
    }
  }

  throw new Error('Invalid package target')
}

function getPackageResolver(packageJsonMatchedModule: RegisteredModule) {
  const memoized = memoizedPackageResolvers.get(packageJsonMatchedModule)
  if (memoized) {
    return memoized
  }

  // https://nodejs.org/api/packages.html#package-entry-points

  const pkgJson = parseJson(packageJsonMatchedModule)

  let exports: NormalizedExports | null = null
  if (pkgJson.exports) {
    // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L590
    exports = isConditionalExportsMainSugar(pkgJson.exports)
      ? { '.': pkgJson.exports }
      : pkgJson.exports
  }

  const resolveInPackage: ModuleResolutions = (subpath: string, handleExportMap = true) => {
    if (handleExportMap && exports) {
      const normalizedSubpath = subpath.length === 0 ? '.' : './' + subpath

      // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L594
      // simple case with matching as-is
      if (
        normalizedSubpath in exports &&
        !normalizedSubpath.includes('*') &&
        !normalizedSubpath.endsWith('/')
      ) {
        return matchConditions(exports[normalizedSubpath])
      }

      // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L610
      let bestMatchKey = ''
      let bestMatchSubpath
      for (const key of Object.keys(exports)) {
        const patternIndex = key.indexOf('*')
        if (patternIndex !== -1 && normalizedSubpath.startsWith(key.slice(0, patternIndex))) {
          const patternTrailer = key.slice(patternIndex + 1)
          if (
            normalizedSubpath.length > key.length &&
            normalizedSubpath.endsWith(patternTrailer) &&
            patternKeyCompare(bestMatchKey, key) === 1 &&
            key.lastIndexOf('*') === patternIndex
          ) {
            bestMatchKey = key
            bestMatchSubpath = normalizedSubpath.slice(
              patternIndex,
              normalizedSubpath.length - patternTrailer.length,
            )
          }
        }
      }

      if (bestMatchKey && typeof bestMatchSubpath === 'string') {
        const matchedTarget = exports[bestMatchKey]
        return matchConditions(matchedTarget, bestMatchSubpath)
      }

      // if exports are defined, they are source of truth and any imports not allowed by it will fail
      throw new Error(`Cannot find module '${normalizedSubpath}'`)
    }

    if (subpath.length === 0 && pkgJson.main && typeof pkgJson.main === 'string') {
      return pkgJson.main as string
    }

    return subpath
  }

  memoizedPackageResolvers.set(packageJsonMatchedModule, resolveInPackage)

  return resolveInPackage
}

function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) {
  if (matchedModule.loaded) {
    return matchedModule.filepath
  }
  const { source, filepath } = matchedModule

  const mod = new Module(filepath)
  mod.parent = parent
  mod.filename = filepath
  mod.path = dirname(filepath)
  // @ts-expect-error - private untyped API
  mod.paths = Module._nodeModulePaths(mod.path)
  require.cache[filepath] = mod

  try {
    if (filepath.endsWith('.json')) {
      Object.assign(mod.exports, parseJson(matchedModule))
    } else {
      const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});`
      const compiled = vm.runInThisContext(wrappedSource, {
        filename: filepath,
        lineOffset: 0,
        displayErrors: true,
      })
      const modRequire = createRequire(pathToFileURL(filepath, { windows: false }))
      compiled(mod.exports, modRequire, mod, filepath, dirname(filepath))
    }
    mod.loaded = matchedModule.loaded = true
  } catch (error) {
    throw new Error(`Failed to compile CJS module: ${filepath}`, { cause: error })
  }

  return filepath
}

// ideally require.extensions could be used, but it does NOT include '.cjs', so hardcoding instead
const exts = ['.js', '.cjs', '.json']

function tryWithExtensions(filename: string) {
  let matchedModule = registeredModules.get(filename)
  if (!matchedModule) {
    for (const ext of exts) {
      // require("./test") might resolve to ./test.js
      const targetWithExt = filename + ext

      matchedModule = registeredModules.get(targetWithExt)
      if (matchedModule) {
        break
      }
    }
  }

  return matchedModule
}

function tryMatchingWithIndex(target: string) {
  let matchedModule = tryWithExtensions(target)
  if (!matchedModule) {
    // require("./test") might resolve to ./test/index.js
    const indexTarget = join(target, 'index')
    matchedModule = tryWithExtensions(indexTarget)
  }

  return matchedModule
}

export function registerCJSModules(
  baseUrl: URL,
  modules: Map<string, string>,
  symlinks: Map<string, string> = new Map(),
) {
  const basePath = dirname(toPosixPath(fileURLToPath(baseUrl, { windows: false })))

  for (const [filename, source] of modules.entries()) {
    const target = join(basePath, filename)
    registeredModules.set(target, { source, loaded: false, filepath: target })
  }

  for (const [symlinkPath, targetPath] of symlinks) {
    const source = join(basePath, symlinkPath)
    const target = pathResolve(dirname(source), targetPath)
    registeredSymlinks.set(source, target)
  }

  if (!hookedIn) {
    // @ts-expect-error - private untyped API
    const original_resolveFilename = Module._resolveFilename.bind(Module)
    // @ts-expect-error - private untyped API
    Module._resolveFilename = (...args) => {
      let target = args[0]
      let isRelative = args?.[0].startsWith('.')

      if (isRelative) {
        // only handle relative require paths
        const requireFrom = toPosixPath(args?.[1]?.filename)

        target = join(dirname(requireFrom), args[0])
      }

      let matchedModule = tryMatchingWithIndex(target)

      if (!isRelative && !target.startsWith('/')) {
        const packageName = target.startsWith('@')
          ? target.split('/').slice(0, 2).join('/')
          : target.split('/')[0]
        const moduleInPackagePath = target.slice(packageName.length + 1)

        for (const nodeModulePathsRaw of args[1].paths) {
          const nodeModulePaths = toPosixPath(nodeModulePathsRaw)
          const potentialPackageJson = join(nodeModulePaths, packageName, 'package.json')

          const maybePackageJson = registeredModules.get(potentialPackageJson)

          let relativeTarget = moduleInPackagePath

          if (maybePackageJson) {
            const packageResolver = getPackageResolver(maybePackageJson)

            relativeTarget = packageResolver(moduleInPackagePath)
          }

          let potentialPath = join(nodeModulePaths, packageName, relativeTarget)

          // we need to also check if there is package.json at the potentialPath location
          // as it might have "main" field that redirects further
          const potentialNestedPackageJson = join(potentialPath, 'package.json')
          const maybeNestedPackageJson = registeredModules.get(potentialNestedPackageJson)
          if (maybeNestedPackageJson) {
            const packageResolver = getPackageResolver(maybeNestedPackageJson)
            const maybeMain = packageResolver('', false)
            if (maybeMain) {
              potentialPath = join(potentialPath, maybeMain)
            }
          }

          matchedModule = tryMatchingWithIndex(potentialPath)
          if (matchedModule) {
            break
          }
        }
      }

      if (matchedModule) {
        return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1])
      }

      return original_resolveFilename(...args)
    }

    hookedIn = true
  }
}
