import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'

import { Comparator, Range, minVersion, subset } from 'semver'

const require = createRequire(import.meta.url)
const minimumWasiNodeVersion = '>=14.0.0'

import {
  applyDefaultCreateNpmDirsOptions,
  type CreateNpmDirsOptions,
} from '../def/create-npm-dirs.js'
import {
  debugFactory,
  readNapiConfig,
  mkdirAsync as rawMkdirAsync,
  pick,
  writeFileAsync as rawWriteFileAsync,
  type Target,
  type CommonPackageJsonFields,
} from '../utils/index.js'

const debug = debugFactory('create-npm-dirs')

export interface PackageMeta {
  'dist-tags': { [index: string]: string }
}

const WASM_RUNTIME_PACKAGE_NAME = '@napi-rs/wasm-runtime'

async function getLatestWasmRuntimeVersion() {
  const npmRegistryBase =
    process.env.npm_config_registry?.replace(/\/?$/, '/') ??
    'https://registry.npmjs.org/'
  const packageMetadataUrl = `${npmRegistryBase}${WASM_RUNTIME_PACKAGE_NAME}`
  let response: Response

  try {
    response = await fetch(packageMetadataUrl)
  } catch (error) {
    throw new Error(
      `Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}. Check your network connection and npm registry availability.`,
      { cause: error },
    )
  }

  if (!response.ok) {
    throw new Error(
      `Failed to fetch ${packageMetadataUrl} while resolving ${WASM_RUNTIME_PACKAGE_NAME}: npm registry responded with ${response.status} ${response.statusText || 'Unknown Status'}`,
    )
  }

  let packageMeta: PackageMeta

  try {
    packageMeta = (await response.json()) as PackageMeta
  } catch (error) {
    throw new Error(
      `Failed to parse npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl}`,
      { cause: error },
    )
  }

  const latestVersion = packageMeta['dist-tags']?.latest

  if (typeof latestVersion !== 'string' || latestVersion.trim().length === 0) {
    throw new Error(
      `npm registry metadata for ${WASM_RUNTIME_PACKAGE_NAME} from ${packageMetadataUrl} did not include a latest dist-tag`,
    )
  }

  return latestVersion.trim()
}

export async function createNpmDirs(userOptions: CreateNpmDirsOptions) {
  const options = applyDefaultCreateNpmDirsOptions(userOptions)

  async function mkdirAsync(dir: string) {
    debug('Try to create dir: %i', dir)
    if (options.dryRun) {
      return
    }

    await rawMkdirAsync(dir, {
      recursive: true,
    })
  }

  async function writeFileAsync(file: string, content: string) {
    debug('Writing file %i', file)

    if (options.dryRun) {
      debug(content)
      return
    }

    await rawWriteFileAsync(file, content)
  }

  const packageJsonPath = resolve(options.cwd, options.packageJsonPath)
  const npmPath = resolve(options.cwd, options.npmDir)

  debug(`Read content from [${options.configPath ?? packageJsonPath}]`)

  const { targets, binaryName, packageName, packageJson } =
    await readNapiConfig(
      packageJsonPath,
      options.configPath ? resolve(options.cwd, options.configPath) : undefined,
    )
  const wasmRuntimeVersion = targets.some((target) => target.arch === 'wasm32')
    ? await getLatestWasmRuntimeVersion()
    : undefined

  for (const target of targets) {
    const targetDir = join(npmPath, `${target.platformArchABI}`)
    await mkdirAsync(targetDir)

    const binaryFileName =
      target.arch === 'wasm32'
        ? `${binaryName}.${target.platformArchABI}.wasm`
        : `${binaryName}.${target.platformArchABI}.node`
    const scopedPackageJson: CommonPackageJsonFields = {
      name: `${packageName}-${target.platformArchABI}`,
      version: packageJson.version,
      cpu: target.arch !== 'universal' ? [target.arch] : undefined,
      main: binaryFileName,
      files: [binaryFileName],
      ...pick(
        packageJson,
        'description',
        'keywords',
        'author',
        'authors',
        'homepage',
        'license',
        'engines',
        'repository',
        'bugs',
      ),
    }
    if (packageJson.publishConfig) {
      scopedPackageJson.publishConfig = pick(
        packageJson.publishConfig,
        'registry',
        'access',
      )
    }
    if (target.arch !== 'wasm32') {
      scopedPackageJson.os = [target.platform]
    } else {
      const entry = `${binaryName}.wasi.cjs`
      scopedPackageJson.main = entry
      scopedPackageJson.browser = `${binaryName}.wasi-browser.js`
      scopedPackageJson.files?.push(
        entry,
        scopedPackageJson.browser,
        `wasi-worker.mjs`,
        `wasi-worker-browser.mjs`,
      )
      scopedPackageJson.engines = {
        ...scopedPackageJson.engines,
        node: scopedPackageJson.engines?.node
          ? restrictWasiNodeEngine(scopedPackageJson.engines.node)
          : minimumWasiNodeVersion,
      }
      const emnapiVersion = require('emnapi/package.json').version
      scopedPackageJson.dependencies = {
        '@napi-rs/wasm-runtime': `^${wasmRuntimeVersion}`,
        '@emnapi/core': emnapiVersion,
        '@emnapi/runtime': emnapiVersion,
      }
    }

    if (target.abi === 'gnu') {
      scopedPackageJson.libc = ['glibc']
    } else if (target.abi === 'musl') {
      scopedPackageJson.libc = ['musl']
    }

    const targetPackageJson = join(targetDir, 'package.json')
    await writeFileAsync(
      targetPackageJson,
      JSON.stringify(scopedPackageJson, null, 2) + '\n',
    )
    const targetReadme = join(targetDir, 'README.md')
    await writeFileAsync(targetReadme, readme(packageName, target))

    debug.info(`${packageName} -${target.platformArchABI} created`)
  }
}

function readme(packageName: string, target: Target) {
  return `# \`${packageName}-${target.platformArchABI}\`

This is the **${target.triple}** binary for \`${packageName}\`
`
}

function restrictWasiNodeEngine(nodeRange: string) {
  try {
    if (subset(nodeRange, minimumWasiNodeVersion)) {
      return nodeRange
    }

    if (subset(minimumWasiNodeVersion, nodeRange)) {
      return minimumWasiNodeVersion
    }

    const minimumComparator = new Comparator(minimumWasiNodeVersion)
    const restrictedRangeSets = new Range(nodeRange).set
      .map((comparators) =>
        normalizeComparatorSet([...comparators, minimumComparator]),
      )
      .filter(
        (candidate): candidate is string =>
          candidate !== undefined && minVersion(candidate) !== null,
      )

    if (restrictedRangeSets.length > 0) {
      return restrictedRangeSets.join(' || ')
    }
  } catch {
    // ignore
  }

  return minimumWasiNodeVersion
}

function normalizeComparatorSet(comparators: Comparator[]) {
  const exactMatch = comparators.find(({ operator }) => operator === '')
  if (exactMatch) {
    return comparators.every((comparator) => comparator.test(exactMatch.semver))
      ? exactMatch.value
      : undefined
  }

  let lowerBound: Comparator | undefined
  let upperBound: Comparator | undefined

  for (const comparator of comparators) {
    if (comparator.operator === '>' || comparator.operator === '>=') {
      if (
        !lowerBound ||
        comparator.semver.compare(lowerBound.semver) > 0 ||
        (comparator.semver.compare(lowerBound.semver) === 0 &&
          comparator.operator === '>')
      ) {
        lowerBound = comparator
      }
    } else if (comparator.operator === '<' || comparator.operator === '<=') {
      if (
        !upperBound ||
        comparator.semver.compare(upperBound.semver) < 0 ||
        (comparator.semver.compare(upperBound.semver) === 0 &&
          comparator.operator === '<')
      ) {
        upperBound = comparator
      }
    }
  }

  return [lowerBound?.value, upperBound?.value].filter(Boolean).join(' ')
}
