import { lstatSync, realpathSync } from 'node:fs'
import { builtinModules } from 'node:module'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import {
  basename,
  dirname,
  isAbsolute,
  join,
  relative,
  resolve,
  sep,
} from 'node:path'

import { parse as parseToml, stringify as stringifyToml } from '@std/toml'
import { load as yamlParse, dump as yamlStringify } from 'js-yaml'
import { isNil, omitBy, pick } from 'es-toolkit'

import { applyDefaultRenameOptions, type RenameOptions } from '../def/rename.js'
import {
  commitFileSystemTransaction,
  readConfig,
  readFileAsync,
  resolvePackageReconciliationPaths,
  type Target,
  wasiLoaderSuffix,
  wasiTargetHasThreads,
  withPackageFileSystemReconciliation,
} from '../utils/index.js'

const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:'
const SCOPED_PACKAGE_PATTERN = /^(?:@([^/]+?)\/)?([^/]+?)$/
const EXCLUDED_PACKAGE_NAMES = new Set(['node_modules', 'favicon.ico'])
const WINDOWS_RESERVED_FILENAME =
  /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
const PACKAGE_REFERENCE_FIELDS = new Set([
  'browser',
  'exports',
  'files',
  'imports',
  'main',
  'man',
  'module',
  'types',
  'typesVersions',
  'typings',
])
const DEPENDENCY_FIELDS = new Set([
  'dependencies',
  'devDependencies',
  'optionalDependencies',
  'peerDependencies',
  'peerDependenciesMeta',
])
const RECURSIVE_PACKAGE_KEY_FIELDS = new Set(['overrides', 'resolutions'])
const PNPM_PACKAGE_ARRAY_FIELDS = new Set([
  'ignoredBuiltDependencies',
  'ignoredOptionalDependencies',
  'minimumReleaseAgeExclude',
  'neverBuiltDependencies',
  'onlyBuiltDependencies',
  'trustPolicyExclude',
])

type JsonRecord = Record<string, unknown>

interface ManagedFileRename {
  source: string
  destination: string
}

interface PlannedManagedFileRename extends ManagedFileRename {
  replacesSameFile: boolean
}

interface PreparedWrite {
  content: Buffer
  destination: string
  mode: number
  removeBeforeWrite?: string
}

class RenameTransactionPlan {
  readonly removals = new Set<string>()
  readonly writes = new Map<string, PreparedWrite>()

  addWrite(
    destination: string,
    content: Buffer | string,
    mode: number,
    removeBeforeWrite?: string,
  ) {
    const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content)
    const existing = this.writes.get(destination)
    if (existing) {
      if (
        !existing.content.equals(buffer) ||
        existing.mode !== mode ||
        existing.removeBeforeWrite !== removeBeforeWrite
      ) {
        throw new Error(
          `Rename prepared conflicting updates for ${destination}`,
        )
      }
      return
    }
    this.writes.set(destination, {
      content: buffer,
      destination,
      mode,
      removeBeforeWrite,
    })
  }

  hasWrite(destination: string) {
    return this.writes.has(destination)
  }
}

function pathExists(path: string) {
  try {
    lstatSync(path)
    return true
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      return false
    }
    throw error
  }
}

function assertRegularFile(path: string, description: string) {
  const stats = lstatSync(path)
  if (!stats.isFile()) {
    throw new Error(`${description} must be a regular file: ${path}`)
  }
  return stats
}

function fileMode(path: string, description: string) {
  return assertRegularFile(path, description).mode & 0o7777
}

function assertSafeBinaryName(name: unknown, description: string) {
  const hasControlCharacter =
    typeof name === 'string' &&
    [...name].some((character) => character.charCodeAt(0) <= 0x1f)
  if (
    typeof name !== 'string' ||
    name.length === 0 ||
    name === '.' ||
    name === '..' ||
    name.trim() !== name ||
    isAbsolute(name) ||
    /[<>:"/\\|?*]/.test(name) ||
    hasControlCharacter ||
    name.endsWith('.') ||
    WINDOWS_RESERVED_FILENAME.test(name)
  ) {
    throw new Error(
      `${description} must be a safe filename stem: ${JSON.stringify(name)}`,
    )
  }
}

function npmPackageNameIssues(name: unknown) {
  if (typeof name !== 'string') {
    return { errors: ['name must be a string'], warnings: [] }
  }

  const errors: string[] = []
  const warnings: string[] = []
  if (name.length === 0) {
    errors.push('name length must be greater than zero')
  }
  if (name.length > 214) {
    warnings.push('name can no longer contain more than 214 characters')
  }
  if (name.startsWith('.')) {
    errors.push('name cannot start with a period')
  }
  if (name.startsWith('_')) {
    errors.push('name cannot start with an underscore')
  }
  if (name.trim() !== name) {
    errors.push('name cannot contain leading or trailing spaces')
  }
  if (name.toLowerCase() !== name) {
    warnings.push('name can no longer contain capital letters')
  }
  if (EXCLUDED_PACKAGE_NAMES.has(name.toLowerCase())) {
    errors.push(`${name} is not a valid package name`)
  }
  if (builtinModules.includes(name.toLowerCase())) {
    warnings.push(`${name} is a core module name`)
  }

  const match = name.match(SCOPED_PACKAGE_PATTERN)
  const scope = match?.[1]
  const packageName = match?.[2]
  if (packageName && /[~'!()*]/.test(packageName)) {
    warnings.push('name can no longer contain special characters ("~\'!()*")')
  }
  if (
    !packageName ||
    packageName.startsWith('.') ||
    (scope
      ? encodeURIComponent(scope) !== scope ||
        encodeURIComponent(packageName) !== packageName
      : encodeURIComponent(name) !== name)
  ) {
    errors.push('name can only contain URL-friendly characters')
  }
  return {
    errors: [...new Set(errors)],
    warnings: [...new Set(warnings)],
  }
}

function assertValidNpmPackageName(
  name: unknown,
  description: string,
  strict: boolean,
): asserts name is string {
  const { errors, warnings } = npmPackageNameIssues(name)
  const issues = strict ? [...errors, ...warnings] : errors
  if (issues.length > 0) {
    throw new Error(
      `${description} is not a valid npm package name: ${issues.join('; ')}`,
    )
  }
}

function validatePackageIdentity(
  packageName: unknown,
  targets: Target[],
  description: string,
  strict: boolean,
) {
  assertValidNpmPackageName(packageName, description, strict)
  for (const target of targets) {
    assertValidNpmPackageName(
      `${packageName}-${target.platformArchABI}`,
      `${description} flavor for ${target.triple}`,
      strict,
    )
  }
}

function createManagedWasiFiles(binaryName: string, targets: Target[]) {
  const files = new Set<string>()
  const add = (suffix: string) => files.add(`${binaryName}.${suffix}`)
  const wasiTargets = targets.filter((target) => target.platform === 'wasi')
  const flavors =
    wasiTargets.length > 0
      ? wasiTargets.map((target) => ({
          hasThreads: wasiTargetHasThreads(target),
          platformArchABI: target.platformArchABI,
        }))
      : [{ hasThreads: true, platformArchABI: 'wasm32-wasi' }]

  for (const flavor of flavors) {
    const loaderSuffix = wasiLoaderSuffix(flavor.platformArchABI)
    for (const suffix of [
      `${flavor.platformArchABI}.wasm`,
      `${flavor.platformArchABI}.debug.wasm`,
      `${loaderSuffix}.cjs`,
      `${loaderSuffix}.d.cts`,
      `${loaderSuffix}-browser.js`,
    ]) {
      add(suffix)
    }
    if (!flavor.hasThreads) {
      for (const suffix of [
        `${flavor.platformArchABI}.wasm.d.ts`,
        `${flavor.platformArchABI}.wasm.d.mts`,
        `${flavor.platformArchABI}.workerd.mjs`,
        `${flavor.platformArchABI}.workerd.d.mts`,
        `${loaderSuffix}-deferred.js`,
        `${loaderSuffix}-deferred.d.ts`,
      ]) {
        add(suffix)
      }
    }
  }

  add('wasm')
  add('debug.wasm')
  return files
}

function createManagedWasiRenames(
  oldName: string,
  newName: string,
  targets: Target[],
) {
  const renames = new Map<string, string>()
  for (const oldFile of createManagedWasiFiles(oldName, targets)) {
    renames.set(oldFile, `${newName}${oldFile.slice(oldName.length)}`)
  }
  return renames
}

function createManagedPackageRenames(
  oldPackageName: string,
  newPackageName: string,
  targets: Target[],
) {
  return new Map(
    targets.map((target) => [
      `${oldPackageName}-${target.platformArchABI}`,
      `${newPackageName}-${target.platformArchABI}`,
    ]),
  )
}

function replaceManifestReference(
  value: string,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  const directFile = fileRenames.get(value)
  if (directFile) {
    return directFile
  }
  if (value.startsWith('./')) {
    const relativeFile = fileRenames.get(value.slice(2))
    if (relativeFile) {
      return `./${relativeFile}`
    }
  }
  return replacePackageSelectors(value, packageRenames, false)
}

function isPackageSelectorPrefix(
  value: string,
  index: number,
  packageName: string,
  allowSelectorPrefix: boolean,
) {
  if (index === 0 || (index === 4 && value.startsWith('npm:'))) {
    return true
  }
  if (!allowSelectorPrefix) {
    return false
  }

  const previous = value[index - 1]
  if (previous !== '/' && previous !== '>') {
    return false
  }
  if (previous === '/' && !packageName.startsWith('@')) {
    const previousSlash = value.lastIndexOf('/', index - 2)
    const previousSegment = value
      .slice(previousSlash + 1, index - 1)
      .replace(/^npm:/, '')
    if (previousSegment.startsWith('@')) {
      return false
    }
  }
  return true
}

function replacePackageSelectors(
  value: string,
  packageRenames: Map<string, string>,
  allowSelectorPrefix: boolean,
) {
  let cursor = 0
  let updated = ''
  const renames = [...packageRenames].sort(
    ([left], [right]) => right.length - left.length,
  )

  while (cursor < value.length) {
    let match: { index: number; name: string; replacement: string } | undefined
    for (const [name, replacement] of renames) {
      let index = value.indexOf(name, cursor)
      while (index !== -1) {
        const next = value[index + name.length]
        if (
          isPackageSelectorPrefix(value, index, name, allowSelectorPrefix) &&
          (next === undefined || next === '/' || next === '@')
        ) {
          break
        }
        index = value.indexOf(name, index + name.length)
      }
      if (
        index !== -1 &&
        (!match ||
          index < match.index ||
          (index === match.index && name.length > match.name.length))
      ) {
        match = { index, name, replacement }
      }
    }

    if (!match) {
      updated += value.slice(cursor)
      break
    }
    updated += value.slice(cursor, match.index)
    updated += match.replacement
    cursor = match.index + match.name.length
  }
  return updated || value
}

function defineJsonProperty(target: JsonRecord, key: string, value: unknown) {
  Object.defineProperty(target, key, {
    configurable: true,
    enumerable: true,
    value,
    writable: true,
  })
}

function createJsonRecord() {
  return Object.create(null) as JsonRecord
}

function rewriteReferenceTree(
  value: unknown,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
  rewriteKeys = true,
): unknown {
  if (typeof value === 'string') {
    return replaceManifestReference(value, fileRenames, packageRenames)
  }
  if (Array.isArray(value)) {
    return value.map((entry) =>
      rewriteReferenceTree(entry, fileRenames, packageRenames, rewriteKeys),
    )
  }
  if (typeof value !== 'object' || value === null) {
    return value
  }

  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    const updatedKey = rewriteKeys
      ? replaceManifestReference(key, fileRenames, packageRenames)
      : key
    if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
      throw new Error(
        `Renaming managed manifest reference ${key} conflicts with existing key ${updatedKey}`,
      )
    }
    defineJsonProperty(
      updated,
      updatedKey,
      rewriteReferenceTree(entry, fileRenames, packageRenames, rewriteKeys),
    )
  }
  return updated
}

function rewritePackageMap(
  value: unknown,
  packageRenames: Map<string, string>,
  recursive: boolean,
): unknown {
  if (Array.isArray(value)) {
    return rewritePackageSelectorArray(value, packageRenames)
  }
  if (typeof value !== 'object' || value === null) {
    return value
  }

  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    const updatedKey = replacePackageSelectors(key, packageRenames, true)
    if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
      throw new Error(
        `Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
      )
    }
    const updatedEntry =
      typeof entry === 'string'
        ? replacePackageDescriptor(entry, packageRenames)
        : recursive
          ? rewritePackageMap(entry, packageRenames, true)
          : entry
    defineJsonProperty(updated, updatedKey, updatedEntry)
  }
  return updated
}

function replacePackageDescriptor(
  value: string,
  packageRenames: Map<string, string>,
) {
  if (value.startsWith('npm:')) {
    return replacePackageSelectors(value, packageRenames, false)
  }
  if (!value.startsWith('patch:')) {
    return value
  }

  const source = value.slice('patch:'.length)
  const packageSeparator = source.startsWith('@')
    ? source.indexOf('@', source.indexOf('/') + 1)
    : source.indexOf('@')
  if (packageSeparator <= 0) {
    return value
  }
  const packageName = source.slice(0, packageSeparator)
  const replacement = packageRenames.get(packageName)
  return replacement
    ? `patch:${replacement}${source.slice(packageSeparator)}`
    : value
}

function rewritePackageSelectorArray(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (!Array.isArray(value)) {
    return value
  }
  return value.map((entry) =>
    typeof entry === 'string'
      ? replacePackageSelectors(entry, packageRenames, true)
      : entry,
  )
}

function rewritePackageSelectorKeys(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    const updatedKey = replacePackageSelectors(key, packageRenames, true)
    if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
      throw new Error(
        `Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
      )
    }
    defineJsonProperty(updated, updatedKey, entry)
  }
  return updated
}

function rewritePackageExtensions(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    const updatedKey = replacePackageSelectors(key, packageRenames, true)
    if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
      throw new Error(
        `Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
      )
    }
    if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
      defineJsonProperty(updated, updatedKey, entry)
      continue
    }

    const updatedExtension = createJsonRecord()
    for (const [field, fieldValue] of Object.entries(entry)) {
      defineJsonProperty(
        updatedExtension,
        field,
        DEPENDENCY_FIELDS.has(field)
          ? rewritePackageMap(fieldValue, packageRenames, false)
          : fieldValue,
      )
    }
    defineJsonProperty(updated, updatedKey, updatedExtension)
  }
  return updated
}

function rewritePnpmPeerDependencyRules(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    defineJsonProperty(
      updated,
      key,
      key === 'ignoreMissing' || key === 'allowAny'
        ? rewritePackageSelectorArray(entry, packageRenames)
        : key === 'allowedVersions'
          ? rewritePackageSelectorKeys(entry, packageRenames)
          : entry,
    )
  }
  return updated
}

function rewritePnpmUpdateConfig(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    defineJsonProperty(
      updated,
      key,
      key === 'ignoreDependencies'
        ? rewritePackageSelectorArray(entry, packageRenames)
        : entry,
    )
  }
  return updated
}

function rewritePnpmConfig(
  value: unknown,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    let rewritten = entry
    if (key === 'overrides') {
      rewritten = rewritePackageMap(entry, packageRenames, true)
    } else if (key === 'patchedDependencies') {
      rewritten = rewritePackageSelectorKeys(entry, packageRenames)
    } else if (key === 'packageExtensions') {
      rewritten = rewritePackageExtensions(entry, packageRenames)
    } else if (key === 'allowedDeprecatedVersions') {
      rewritten = rewritePackageSelectorKeys(entry, packageRenames)
    } else if (key === 'allowBuilds') {
      rewritten = rewritePackageSelectorKeys(entry, packageRenames)
    } else if (key === 'peerDependencyRules') {
      rewritten = rewritePnpmPeerDependencyRules(entry, packageRenames)
    } else if (key === 'updateConfig') {
      rewritten = rewritePnpmUpdateConfig(entry, packageRenames)
    } else if (PNPM_PACKAGE_ARRAY_FIELDS.has(key)) {
      rewritten = rewritePackageSelectorArray(entry, packageRenames)
    }
    defineJsonProperty(updated, key, rewritten)
  }
  return updated
}

function replaceScriptArgument(
  argument: string,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  const direct = replaceManifestReference(argument, fileRenames, packageRenames)
  if (direct !== argument) {
    return direct
  }

  const assignment = argument.match(
    /^([A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9][A-Za-z0-9-]*)=(.+)$/,
  )
  if (!assignment) {
    return argument
  }
  const replacement = replaceManifestReference(
    assignment[2],
    fileRenames,
    packageRenames,
  )
  return replacement === assignment[2]
    ? argument
    : `${assignment[1]}=${replacement}`
}

function rewriteScriptCommand(
  command: string,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  let index = 0
  let updated = ''
  while (index < command.length) {
    const character = command[index]
    if (/\s|[|&;()<>]/.test(character)) {
      updated += character
      index += 1
      continue
    }

    if (character === '"' || character === "'") {
      const quote = character
      let end = index + 1
      while (end < command.length) {
        if (command[end] === quote) {
          break
        }
        if (
          quote === '"' &&
          command[end] === '\\' &&
          end + 1 < command.length
        ) {
          end += 2
        } else {
          end += 1
        }
      }
      if (end >= command.length) {
        updated += command.slice(index)
        break
      }
      const argument = command.slice(index + 1, end)
      updated += `${quote}${replaceScriptArgument(
        argument,
        fileRenames,
        packageRenames,
      )}${quote}`
      index = end + 1
      continue
    }

    let end = index + 1
    while (end < command.length && !/\s|[|&;()<>"']/.test(command[end])) {
      end += 1
    }
    updated += replaceScriptArgument(
      command.slice(index, end),
      fileRenames,
      packageRenames,
    )
    index = end
  }
  return updated
}

function rewriteScripts(
  value: unknown,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    defineJsonProperty(
      updated,
      key,
      typeof entry === 'string'
        ? rewriteScriptCommand(entry, fileRenames, packageRenames)
        : entry,
    )
  }
  return updated
}

function rewritePublishConfig(
  value: unknown,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    return value
  }
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(value)) {
    defineJsonProperty(
      updated,
      key,
      PACKAGE_REFERENCE_FIELDS.has(key)
        ? rewriteReferenceTree(entry, fileRenames, packageRenames)
        : entry,
    )
  }
  return updated
}

function rewritePackageManifest(
  manifest: JsonRecord,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  const updated = createJsonRecord()
  for (const [key, entry] of Object.entries(manifest)) {
    let rewritten = entry
    if (key === 'name' && typeof entry === 'string') {
      rewritten = replaceManifestReference(entry, new Map(), packageRenames)
    } else if (PACKAGE_REFERENCE_FIELDS.has(key)) {
      rewritten = rewriteReferenceTree(entry, fileRenames, packageRenames)
    } else if (key === 'bin') {
      rewritten = rewriteReferenceTree(
        entry,
        fileRenames,
        packageRenames,
        false,
      )
    } else if (DEPENDENCY_FIELDS.has(key)) {
      rewritten = rewritePackageMap(entry, packageRenames, false)
    } else if (key === 'dependenciesMeta') {
      rewritten = rewritePackageSelectorKeys(entry, packageRenames)
    } else if (key === 'trustedDependencies') {
      rewritten = rewritePackageSelectorArray(entry, packageRenames)
    } else if (key === 'bundledDependencies' || key === 'bundleDependencies') {
      rewritten = rewritePackageMap(entry, packageRenames, false)
    } else if (RECURSIVE_PACKAGE_KEY_FIELDS.has(key)) {
      rewritten = rewritePackageMap(entry, packageRenames, true)
    } else if (key === 'pnpm') {
      rewritten = rewritePnpmConfig(entry, packageRenames)
    } else if (key === 'scripts') {
      rewritten = rewriteScripts(entry, fileRenames, packageRenames)
    } else if (key === 'publishConfig') {
      rewritten = rewritePublishConfig(entry, fileRenames, packageRenames)
    }
    defineJsonProperty(updated, key, rewritten)
  }
  return updated
}

function replaceManagedTextReferences(
  content: string,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  const references = new Map<
    string,
    { replacement: string; packageName: boolean }
  >()
  for (const [name, replacement] of fileRenames) {
    references.set(name, { replacement, packageName: false })
  }
  for (const [name, replacement] of packageRenames) {
    references.set(name, { replacement, packageName: true })
  }
  if (references.size === 0) {
    return content
  }

  const pattern = new RegExp(
    [...references.keys()]
      .sort((left, right) => right.length - left.length)
      .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
      .join('|'),
    'g',
  )
  return content.replace(pattern, (name: string, offset: number) => {
    const reference = references.get(name)!
    const previous = content[offset - 1]
    const next = content[offset + name.length]
    if (reference.packageName) {
      if (previous && /[A-Za-z0-9._~@/-]/.test(previous)) {
        return name
      }
      if (next && next !== '/' && /[A-Za-z0-9._~-]/.test(next)) {
        return name
      }
    } else {
      if (previous && /[A-Za-z0-9._-]/.test(previous)) {
        return name
      }
      if (next && /[A-Za-z0-9._-]/.test(next)) {
        return name
      }
    }
    return reference.replacement
  })
}

function isPathWithin(root: string, path: string, allowRoot: boolean) {
  const relativePath = relative(root, path)
  return (
    (allowRoot && relativePath === '') ||
    (relativePath !== '' &&
      relativePath !== '..' &&
      !relativePath.startsWith(`..${sep}`) &&
      !isAbsolute(relativePath))
  )
}

function assertPathWithin(
  root: string,
  path: string,
  description: string,
  allowRoot = false,
) {
  if (!isPathWithin(root, path, allowRoot)) {
    throw new Error(`${description} escapes project root ${root}: ${path}`)
  }
}

function assertRenamePathIdentity(
  expected: string,
  actual: string,
  description: string,
) {
  if (relative(expected, actual) === '' && relative(actual, expected) === '') {
    return
  }
  const error = new Error(
    `${description} changed after package reconciliation paths were resolved: ${expected} -> ${actual}`,
  ) as NodeJS.ErrnoException
  error.code = 'ESTALE'
  error.path = actual
  throw error
}

function revalidateRenameReconciliationPaths(
  options: ReturnType<typeof applyDefaultRenameOptions>,
  expected: ReturnType<typeof resolvePackageReconciliationPaths>,
) {
  let actual: ReturnType<typeof resolvePackageReconciliationPaths>
  try {
    actual = resolvePackageReconciliationPaths(
      options.cwd,
      options.packageJsonPath,
      [options.npmDir],
    )
  } catch (cause) {
    const error = new Error(
      'Package reconciliation paths changed after their locks were acquired',
      { cause },
    ) as NodeJS.ErrnoException
    error.code = 'ESTALE'
    error.path = expected.boundary
    throw error
  }

  assertRenamePathIdentity(
    expected.boundary,
    actual.boundary,
    'Package reconciliation boundary',
  )
  assertRenamePathIdentity(expected.cwd, actual.cwd, 'Project root')
  assertRenamePathIdentity(
    expected.packageRoot,
    actual.packageRoot,
    'Package root',
  )
  assertRenamePathIdentity(
    expected.packageJsonPath,
    actual.packageJsonPath,
    'Package manifest',
  )
  if (expected.managedPaths.length !== actual.managedPaths.length) {
    const error = new Error(
      'Managed package paths changed after their locks were acquired',
    ) as NodeJS.ErrnoException
    error.code = 'ESTALE'
    error.path = actual.cwd
    throw error
  }
  for (let index = 0; index < expected.managedPaths.length; index++) {
    assertRenamePathIdentity(
      expected.managedPaths[index],
      actual.managedPaths[index],
      `Managed package path ${index + 1}`,
    )
  }
  return actual
}

async function canonicalizePath(path: string) {
  let current = resolve(path)
  const missingSegments: string[] = []
  while (true) {
    try {
      return join(await realpath(current), ...missingSegments)
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        throw error
      }
      const parent = dirname(current)
      if (parent === current) {
        return join(current, ...missingSegments)
      }
      missingSegments.unshift(basename(current))
      current = parent
    }
  }
}

async function resolveCanonicalRoot(
  projectRoot: string,
  path: string,
  description: string,
) {
  const lexicalPath = resolve(path)
  assertPathWithin(projectRoot, lexicalPath, description, true)
  const canonicalPath = await canonicalizePath(lexicalPath)
  assertPathWithin(projectRoot, canonicalPath, description, true)
  return canonicalPath
}

async function resolveCanonicalFile(
  projectRoot: string,
  path: string,
  description: string,
) {
  const lexicalPath = resolve(path)
  assertPathWithin(projectRoot, lexicalPath, description)
  const canonicalParent = await canonicalizePath(dirname(lexicalPath))
  assertPathWithin(projectRoot, canonicalParent, `${description} parent`, true)
  const canonicalPath = join(canonicalParent, basename(lexicalPath))
  assertPathWithin(projectRoot, canonicalPath, description)
  if (pathExists(canonicalPath)) {
    assertRegularFile(canonicalPath, description)
  }
  return canonicalPath
}

function resolveManagedPath(
  directory: string,
  entry: string,
  description: string,
) {
  if (!entry || isAbsolute(entry)) {
    throw new Error(
      `${description} must be a non-empty relative path: ${entry}`,
    )
  }
  const root = resolve(directory)
  const path = resolve(root, entry)
  assertPathWithin(root, path, description)
  return path
}

async function resolveProjectEntry(
  projectRoot: string,
  entry: string,
  description: string,
) {
  const path = resolve(projectRoot, entry)
  if (!isPathWithin(projectRoot, path, false)) {
    return
  }
  return resolveCanonicalFile(projectRoot, path, description)
}

function collectManagedFileRenames(
  directory: string,
  renames: Map<string, string>,
) {
  const operations: ManagedFileRename[] = []
  for (const [oldFile, newFile] of renames) {
    const source = resolveManagedPath(
      directory,
      oldFile,
      'Managed rename source',
    )
    const destination = resolveManagedPath(
      directory,
      newFile,
      'Managed rename destination',
    )
    if (source !== destination && pathExists(source)) {
      assertRegularFile(source, 'Managed rename source')
      operations.push({ source, destination })
    }
  }
  return operations
}

function pathsReferToSameFile(left: string, right: string) {
  try {
    const leftStats = lstatSync(left)
    const rightStats = lstatSync(right)
    if (!leftStats.isFile() || !rightStats.isFile()) {
      return false
    }
    const leftPath = realpathSync.native(left)
    const rightPath = realpathSync.native(right)
    if (
      leftPath === rightPath ||
      (process.platform === 'win32' &&
        leftPath.toLowerCase() === rightPath.toLowerCase())
    ) {
      return true
    }
    return (
      (process.platform === 'darwin' || process.platform === 'win32') &&
      resolve(left).toLowerCase() === resolve(right).toLowerCase() &&
      leftStats.dev === rightStats.dev &&
      leftStats.ino === rightStats.ino
    )
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      return false
    }
    throw error
  }
}

function preflightManagedFileRenames(operations: ManagedFileRename[]) {
  const operationsBySource = new Map<string, ManagedFileRename>()
  const sources = new Set<string>()
  const destinations = new Map<string, string>()

  for (const operation of operations) {
    const existingSource = operationsBySource.get(operation.source)
    if (existingSource) {
      if (existingSource.destination !== operation.destination) {
        throw new Error(
          `Managed rename source ${operation.source} has conflicting destinations ${existingSource.destination} and ${operation.destination}`,
        )
      }
      continue
    }
    operationsBySource.set(operation.source, operation)
    sources.add(operation.source)

    const existingDestination = destinations.get(operation.destination)
    if (existingDestination) {
      throw new Error(
        `Managed rename destination ${operation.destination} is targeted by both ${existingDestination} and ${operation.source}`,
      )
    }
    destinations.set(operation.destination, operation.source)
  }

  const planned: PlannedManagedFileRename[] = []
  for (const operation of operationsBySource.values()) {
    const replacesSameFile =
      pathExists(operation.destination) &&
      pathsReferToSameFile(operation.source, operation.destination)
    if (
      pathExists(operation.destination) &&
      !sources.has(operation.destination) &&
      !replacesSameFile
    ) {
      throw new Error(
        `Cannot rename managed file ${operation.source}: destination already exists: ${operation.destination}`,
      )
    }
    planned.push({ ...operation, replacesSameFile })
  }
  return planned
}

async function prepareManagedFileRenames(
  plan: RenameTransactionPlan,
  operations: PlannedManagedFileRename[],
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  const destinations = new Set(
    operations.map((operation) => operation.destination),
  )
  for (const operation of operations) {
    const mode = fileMode(operation.source, 'Managed rename source')
    const content = await readFileAsync(operation.source)
    plan.addWrite(
      operation.destination,
      operation.source.endsWith('.wasm')
        ? content
        : replaceManagedTextReferences(
            content.toString('utf8'),
            fileRenames,
            packageRenames,
          ),
      mode,
      operation.replacesSameFile ? operation.source : undefined,
    )
    if (!operation.replacesSameFile && !destinations.has(operation.source)) {
      plan.removals.add(operation.source)
    }
  }
}

async function prepareManagedTextWrite(
  plan: RenameTransactionPlan,
  path: string,
  fileRenames: Map<string, string>,
  packageRenames: Map<string, string>,
) {
  if (plan.hasWrite(path) || !pathExists(path) || path.endsWith('.wasm')) {
    return
  }
  const mode = fileMode(path, 'Managed text file')
  const content = await readFileAsync(path, 'utf8')
  const updated = replaceManagedTextReferences(
    content,
    fileRenames,
    packageRenames,
  )
  if (updated !== content) {
    plan.addWrite(path, updated, mode)
  }
}

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

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

async function prepareManagedPackageReadme(
  plan: RenameTransactionPlan,
  directory: string,
  oldPackageName: string,
  newPackageName: string,
  target: Target,
) {
  const path = resolveManagedPath(
    directory,
    'README.md',
    'Managed package README',
  )
  if (!pathExists(path)) {
    return
  }
  const mode = fileMode(path, 'Managed package README')
  const content = await readFileAsync(path, 'utf8')
  if (content === managedPackageReadme(oldPackageName, target)) {
    plan.addWrite(path, managedPackageReadme(newPackageName, target), mode)
  }
}

function managedRootEntries(content: string) {
  const firstLine = content.split(/\r?\n/, 1)[0]
  if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) {
    return []
  }
  try {
    const metadata = JSON.parse(
      firstLine.slice(WASI_ARTIFACT_METADATA_PREFIX.length),
    ) as {
      rootEntry?: unknown
      managedRootEntries?: unknown
    }
    return [
      ...(typeof metadata.rootEntry === 'string' ? [metadata.rootEntry] : []),
      ...(Array.isArray(metadata.managedRootEntries)
        ? metadata.managedRootEntries.filter(
            (entry): entry is string => typeof entry === 'string',
          )
        : []),
    ]
  } catch {
    return []
  }
}

function asJsonRecord(value: unknown): JsonRecord | undefined {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
    ? (value as JsonRecord)
    : undefined
}

function updateNapiConfigRecord(record: JsonRecord, options: RenameOptions) {
  if (options.binaryName !== undefined) {
    defineJsonProperty(record, 'binaryName', options.binaryName)
    delete record.name
  }
  if (options.packageName !== undefined) {
    defineJsonProperty(record, 'packageName', options.packageName)
  }
}

function serializeJsonLike(content: string, value: unknown) {
  return `${JSON.stringify(value, null, 2)}${content.endsWith('\n') ? '\n' : ''}`
}

function sanitizeCargoPackageName(binaryName: string) {
  return binaryName.replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()
}

async function executeRenameTransaction(
  projectRoot: string,
  plan: RenameTransactionPlan,
) {
  if (plan.writes.size === 0 && plan.removals.size === 0) {
    return
  }

  const stagingRoot = await mkdtemp(join(tmpdir(), 'napi-rename-stage-'))
  try {
    const writes = []
    let sequence = 0
    for (const prepared of plan.writes.values()) {
      const source = join(stagingRoot, String(sequence++))
      await writeFile(source, prepared.content, { mode: prepared.mode })
      writes.push({
        destination: prepared.destination,
        mode: prepared.mode,
        removeBeforeWrite: prepared.removeBeforeWrite,
        source,
      })
    }

    await commitFileSystemTransaction(projectRoot, writes, [...plan.removals])
  } finally {
    await rm(stagingRoot, { force: true, recursive: true })
  }
}

async function renameProjectUnlocked(
  userOptions: RenameOptions,
  resolvedPaths: ReturnType<typeof resolvePackageReconciliationPaths>,
) {
  const options = applyDefaultRenameOptions(userOptions)
  const lockedPaths = revalidateRenameReconciliationPaths(
    options,
    resolvedPaths,
  )
  const projectRoot = await realpath(resolve(options.cwd))
  assertRenamePathIdentity(lockedPaths.cwd, projectRoot, 'Project root')
  const requestedPackageJsonPath = await resolveCanonicalFile(
    projectRoot,
    resolve(projectRoot, options.packageJsonPath),
    'package.json',
  )
  const packageJsonPath = await realpath(requestedPackageJsonPath)
  assertRenamePathIdentity(
    lockedPaths.packageJsonPath,
    packageJsonPath,
    'Package manifest',
  )
  const configPath = options.configPath
    ? await resolveCanonicalFile(
        projectRoot,
        resolve(projectRoot, options.configPath),
        'NAPI config',
      )
    : undefined
  const npmRoot = await resolveCanonicalRoot(
    projectRoot,
    resolve(projectRoot, options.npmDir),
    'npm package root',
  )
  assertRenamePathIdentity(
    lockedPaths.managedPaths[0],
    npmRoot,
    'npm package root',
  )
  const normalizedOptions = {
    ...options,
    cwd: projectRoot,
    packageJsonPath,
    configPath,
    npmDir: npmRoot,
  }

  const napiConfig = await readConfig(normalizedOptions)
  const oldName = napiConfig.binaryName
  const oldPackageName = napiConfig.packageName

  assertSafeBinaryName(oldName, 'Configured binary name')
  validatePackageIdentity(
    oldPackageName,
    napiConfig.targets,
    'Configured package name',
    false,
  )
  if (options.binaryName !== undefined) {
    assertSafeBinaryName(options.binaryName, 'Requested binary name')
  }
  if (options.name !== undefined) {
    assertValidNpmPackageName(options.name, 'Requested root package name', true)
  }
  if (options.packageName !== undefined) {
    validatePackageIdentity(
      options.packageName,
      napiConfig.targets,
      'Requested package name',
      true,
    )
  }

  const newName = options.binaryName ?? oldName
  const newPackageName = options.packageName ?? oldPackageName
  const binaryNameChanged =
    options.binaryName !== undefined && oldName !== options.binaryName
  const packageNameChanged =
    options.packageName !== undefined && oldPackageName !== options.packageName
  const managedWasiRenames = binaryNameChanged
    ? createManagedWasiRenames(oldName, newName, napiConfig.targets)
    : new Map<string, string>()
  const managedPackageRenames = packageNameChanged
    ? createManagedPackageRenames(
        oldPackageName,
        newPackageName,
        napiConfig.targets,
      )
    : new Map<string, string>()
  const hasManagedReferenceRenames =
    managedWasiRenames.size > 0 || managedPackageRenames.size > 0

  const packageJsonMode = fileMode(packageJsonPath, 'package.json')
  const packageJsonContent = await readFileAsync(packageJsonPath, 'utf8')
  const parsedPackageJson = JSON.parse(packageJsonContent)
  const packageJsonData = asJsonRecord(parsedPackageJson)
  if (!packageJsonData) {
    throw new Error(
      `package.json must contain a JSON object: ${packageJsonPath}`,
    )
  }

  const managedRootEntryNames = new Set<string>()
  if (hasManagedReferenceRenames) {
    for (const field of ['main', 'module', 'browser', 'types'] as const) {
      const entry = packageJsonData[field]
      if (typeof entry === 'string') {
        managedRootEntryNames.add(entry)
      }
    }
    for (const oldFile of createManagedWasiFiles(oldName, napiConfig.targets)) {
      if (!oldFile.endsWith('.cjs')) {
        continue
      }
      const loaderPath = resolveManagedPath(
        projectRoot,
        oldFile,
        'Managed WASI loader',
      )
      if (pathExists(loaderPath)) {
        assertRegularFile(loaderPath, 'Managed WASI loader')
        for (const entry of managedRootEntries(
          await readFileAsync(loaderPath, 'utf8'),
        )) {
          managedRootEntryNames.add(entry)
        }
      }
    }
  }

  for (const [key, value] of Object.entries(
    omitBy(
      // @ts-expect-error missing fields: author and license
      pick(options, ['name', 'description', 'author', 'license']),
      isNil,
    ),
  )) {
    defineJsonProperty(packageJsonData, key, value)
  }
  if (options.binaryName !== undefined || options.packageName !== undefined) {
    const napi = asJsonRecord(packageJsonData.napi) ?? createJsonRecord()
    updateNapiConfigRecord(napi, options)
    defineJsonProperty(packageJsonData, 'napi', napi)
  }
  if (options.repository) {
    const repository = asJsonRecord(packageJsonData.repository)
    if (repository) {
      defineJsonProperty(repository, 'url', options.repository)
    } else {
      defineJsonProperty(packageJsonData, 'repository', options.repository)
    }
  }

  const plan = new RenameTransactionPlan()
  plan.addWrite(
    packageJsonPath,
    serializeJsonLike(
      packageJsonContent,
      rewritePackageManifest(
        packageJsonData,
        managedWasiRenames,
        managedPackageRenames,
      ),
    ),
    packageJsonMode,
  )

  if (configPath) {
    const configMode = fileMode(configPath, 'NAPI config')
    const configContent = await readFileAsync(configPath, 'utf8')
    const configData = asJsonRecord(JSON.parse(configContent))
    if (!configData) {
      throw new Error(`NAPI config must contain a JSON object: ${configPath}`)
    }
    updateNapiConfigRecord(configData, options)
    plan.addWrite(
      configPath,
      serializeJsonLike(configContent, configData),
      configMode,
    )
  }

  if (binaryNameChanged) {
    const cargoTomlPath = await resolveCanonicalFile(
      projectRoot,
      resolve(projectRoot, options.manifestPath),
      'Cargo manifest',
    )
    const cargoTomlMode = fileMode(cargoTomlPath, 'Cargo manifest')
    const tomlContent = await readFileAsync(cargoTomlPath, 'utf8')
    const cargoToml = parseToml(tomlContent) as any
    if (cargoToml.package) {
      cargoToml.package.name = sanitizeCargoPackageName(newName)
      plan.addWrite(cargoTomlPath, stringifyToml(cargoToml), cargoTomlMode)
    }

    const workflowPath = join(projectRoot, '.github', 'workflows', 'CI.yml')
    if (pathExists(workflowPath)) {
      const canonicalWorkflowPath = await resolveCanonicalFile(
        projectRoot,
        workflowPath,
        'GitHub Actions workflow',
      )
      const workflowMode = fileMode(
        canonicalWorkflowPath,
        'GitHub Actions workflow',
      )
      const workflowContent = await readFileAsync(canonicalWorkflowPath, 'utf8')
      const workflowData = yamlParse(workflowContent) as any
      if (workflowData.env?.APP_NAME) {
        workflowData.env.APP_NAME = newName
        plan.addWrite(
          canonicalWorkflowPath,
          yamlStringify(workflowData, {
            lineWidth: -1,
            noRefs: true,
            sortKeys: false,
          }),
          workflowMode,
        )
      }
    }
  }

  const targetDirectories: Array<{ directory: string; target: Target }> = []
  for (const target of napiConfig.targets) {
    const directory = await resolveCanonicalRoot(
      projectRoot,
      resolve(npmRoot, target.platformArchABI),
      `npm package directory for ${target.triple}`,
    )
    targetDirectories.push({ directory, target })
  }

  const managedFileRenames = collectManagedFileRenames(
    projectRoot,
    managedWasiRenames,
  )
  for (const { target, directory } of targetDirectories) {
    if (!pathExists(directory)) {
      continue
    }
    if (target.platform === 'wasi') {
      managedFileRenames.push(
        ...collectManagedFileRenames(directory, managedWasiRenames),
      )
    }
    if (hasManagedReferenceRenames) {
      const targetPackageJsonPath = resolveManagedPath(
        directory,
        'package.json',
        'Managed package manifest',
      )
      if (pathExists(targetPackageJsonPath)) {
        const manifestMode = fileMode(
          targetPackageJsonPath,
          'Managed package manifest',
        )
        const content = await readFileAsync(targetPackageJsonPath, 'utf8')
        const manifest = asJsonRecord(JSON.parse(content))
        if (!manifest) {
          throw new Error(
            `Managed package manifest must contain a JSON object: ${targetPackageJsonPath}`,
          )
        }
        const updatedContent = serializeJsonLike(
          content,
          rewritePackageManifest(
            manifest,
            managedWasiRenames,
            managedPackageRenames,
          ),
        )
        if (updatedContent !== content) {
          plan.addWrite(targetPackageJsonPath, updatedContent, manifestMode)
        }
      }
    }
  }

  const plannedFileRenames = preflightManagedFileRenames(managedFileRenames)
  await prepareManagedFileRenames(
    plan,
    plannedFileRenames,
    managedWasiRenames,
    managedPackageRenames,
  )

  if (hasManagedReferenceRenames) {
    for (const file of createManagedWasiFiles(newName, napiConfig.targets)) {
      await prepareManagedTextWrite(
        plan,
        resolveManagedPath(projectRoot, file, 'Managed WASI file'),
        managedWasiRenames,
        managedPackageRenames,
      )
    }

    for (const { target, directory } of targetDirectories) {
      if (!pathExists(directory)) {
        continue
      }
      if (target.platform === 'wasi') {
        for (const file of createManagedWasiFiles(newName, [target])) {
          await prepareManagedTextWrite(
            plan,
            resolveManagedPath(directory, file, 'Managed package WASI file'),
            managedWasiRenames,
            managedPackageRenames,
          )
        }
      }
      if (managedPackageRenames.size > 0) {
        await prepareManagedPackageReadme(
          plan,
          directory,
          oldPackageName,
          newPackageName,
          target,
        )
      }
    }

    for (const entry of managedRootEntryNames) {
      const updatedEntry = replaceManifestReference(
        entry,
        managedWasiRenames,
        new Map(),
      )
      const path = await resolveProjectEntry(
        projectRoot,
        updatedEntry,
        'Managed root entry',
      )
      if (path) {
        await prepareManagedTextWrite(
          plan,
          path,
          managedWasiRenames,
          managedPackageRenames,
        )
      }
    }

    if (managedWasiRenames.size > 0) {
      await prepareManagedTextWrite(
        plan,
        resolveManagedPath(
          projectRoot,
          '.gitattributes',
          'Managed .gitattributes file',
        ),
        managedWasiRenames,
        new Map(),
      )
    }
  }

  await executeRenameTransaction(projectRoot, plan)
}

export async function renameProject(userOptions: RenameOptions) {
  const options = applyDefaultRenameOptions(userOptions)
  const resolvedPaths = resolvePackageReconciliationPaths(
    options.cwd,
    options.packageJsonPath,
    [options.npmDir],
  )
  return withPackageFileSystemReconciliation(resolvedPaths, () =>
    renameProjectUnlocked(options, resolvedPaths),
  )
}
