import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
  chmod,
  cp,
  lstat,
  mkdir,
  mkdtemp,
  readdir,
  readFile,
  realpath,
  rename,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { auditServerRuntime } from './audit-server-runtime.mjs'
import { assertNativeBinaryTarget } from './native-binary-target.mjs'
import {
  betterSqlite3PrebuildName,
  normalizeRelativePath,
  parseServerTarget,
  resolveServerInput,
  stringifySortedJson,
  validateServerRuntimeContract,
  validateServerSizeBudgets,
} from './server-package-utils.mjs'
import { pruneBuildOnly } from './staged-package-pruning.mjs'

const execFileAsync = promisify(execFile)
const REPOSITORY_ROOT = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  '..'
)
const GENERATED_MANIFEST_FIELDS = [
  'author',
  'description',
  'homepage',
  'license',
  'productName',
  'version',
]

function isInside(root, candidate) {
  const relative = path.relative(root, candidate)
  return (
    relative.length > 0 &&
    relative !== '..' &&
    !relative.startsWith(`..${path.sep}`) &&
    !path.isAbsolute(relative)
  )
}

async function readJson(filePath, label) {
  try {
    return JSON.parse(await readFile(filePath, 'utf8'))
  } catch (error) {
    throw new Error(`${label} is unreadable: ${filePath}`, { cause: error })
  }
}

function generatedManifest(rootManifest, contract) {
  const manifest = {
    name: '@motrix/server-runtime',
  }
  for (const field of GENERATED_MANIFEST_FIELDS) {
    if (rootManifest[field] !== undefined) manifest[field] = rootManifest[field]
  }
  manifest.private = true
  manifest.type = 'module'
  manifest.main = 'dist/server/index.mjs'
  manifest.engines = { node: '>=24' }
  manifest.dependencies = Object.fromEntries(
    contract.runtimeRoots.map((name) => {
      const version = rootManifest.dependencies?.[name]
      if (typeof version !== 'string') {
        throw new Error(`runtime root ${name} is not in root dependencies`)
      }
      return [name, version]
    })
  )
  return manifest
}

function packageAppliesToTarget(manifest, target) {
  function matches(values, actual) {
    if (!Array.isArray(values) || values.length === 0) return true
    const denied = values.includes(`!${actual}`)
    const positive = values.filter((entry) => !entry.startsWith('!'))
    return !denied && (positive.length === 0 || positive.includes(actual))
  }
  return (
    matches(manifest.os, target.platform) && matches(manifest.cpu, target.arch)
  )
}

async function resolvePackageInstance(name, fromRoot, canonicalRepoRoot) {
  const require = createRequire(path.join(fromRoot, 'package.json'))
  const candidates = []
  try {
    candidates.push(require.resolve(`${name}/package.json`))
  } catch {
    for (const searchPath of require.resolve.paths(name) ?? []) {
      candidates.push(path.join(searchPath, name, 'package.json'))
    }
    try {
      let current = path.dirname(require.resolve(name))
      while (current !== path.dirname(current)) {
        candidates.push(path.join(current, 'package.json'))
        current = path.dirname(current)
      }
    } catch {
      // The caller decides whether a missing dependency is optional.
    }
  }

  for (const candidate of candidates) {
    const info = await stat(candidate).catch(() => null)
    if (!info?.isFile()) continue
    const manifest = await readJson(candidate, `package ${name}`)
    if (manifest.name !== name || typeof manifest.version !== 'string') continue
    const sourceRoot = await realpath(path.dirname(candidate))
    if (
      sourceRoot !== canonicalRepoRoot &&
      !isInside(canonicalRepoRoot, sourceRoot)
    ) {
      throw new Error(`resolved package ${name} escapes repository root`)
    }
    return { manifest, sourceRoot }
  }
  return undefined
}

function nodeLookupDestinations(consumerDestination, name) {
  const destinations = []
  let current = consumerDestination
  while (true) {
    if (path.posix.basename(current) !== 'node_modules') {
      destinations.push(path.posix.join(current, 'node_modules', name))
    }
    const parent = path.posix.dirname(current)
    if (parent === current || current === '.') break
    current = parent
  }
  destinations.push(path.posix.join('node_modules', name))
  return [...new Set(destinations.map((entry) => entry.replace(/^\.\//, '')))]
}

async function isRootHoisted(repoRoot, name, sourceRoot) {
  const candidate = await realpath(
    path.join(repoRoot, 'node_modules', name)
  ).catch(() => null)
  return candidate === sourceRoot
}

async function assertSafeDirectory(
  sourceRoot,
  canonicalRepoRoot,
  options = {}
) {
  const visited = new Set()
  async function walk(directory) {
    const canonical = await realpath(directory)
    if (visited.has(canonical)) return
    visited.add(canonical)
    for (const entry of await readdir(directory, { withFileTypes: true })) {
      if (options.skipNodeModules && entry.name === 'node_modules') continue
      const entryPath = path.join(directory, entry.name)
      if (entry.isSymbolicLink()) {
        const target = await realpath(entryPath).catch(() => null)
        if (
          !target ||
          (target !== canonicalRepoRoot && !isInside(canonicalRepoRoot, target))
        ) {
          throw new Error(
            `source symlink escapes repository root: ${entryPath}`
          )
        }
        const targetInfo = await stat(target)
        if (targetInfo.isDirectory()) await walk(target)
      } else if (entry.isDirectory()) {
        await walk(entryPath)
      }
    }
  }
  await walk(sourceRoot)
}

async function normalizeDirectoryModes(directory) {
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const entryPath = path.join(directory, entry.name)
    if (entry.isDirectory()) {
      await chmod(entryPath, 0o755)
      await normalizeDirectoryModes(entryPath)
    } else {
      const info = await lstat(entryPath)
      if (info.isSymbolicLink()) {
        throw new Error(`staging emitted a symlink: ${entryPath}`)
      }
      await chmod(entryPath, (info.mode & 0o111) === 0 ? 0o644 : 0o755)
    }
  }
}

async function copyPath(source, destination, canonicalRepoRoot, options = {}) {
  const sourceInfo = await lstat(source)
  if (sourceInfo.isSymbolicLink()) {
    throw new Error(`top-level staging input is a symlink: ${source}`)
  }
  await mkdir(path.dirname(destination), { recursive: true })
  if (sourceInfo.isFile()) {
    await cp(source, destination)
    await chmod(destination, (sourceInfo.mode & 0o111) === 0 ? 0o644 : 0o755)
    return
  }
  if (!sourceInfo.isDirectory()) {
    throw new Error(`staging input is not a file or directory: ${source}`)
  }
  await assertSafeDirectory(source, canonicalRepoRoot, options)
  await cp(source, destination, {
    recursive: true,
    dereference: true,
    filter: (candidate) => {
      const relative = path.relative(source, candidate)
      if (
        options.skipNodeModules &&
        (relative === 'node_modules' ||
          relative.startsWith(`node_modules${path.sep}`))
      ) {
        return false
      }
      return options.filter ? options.filter(relative) : true
    },
  })
  await chmod(destination, 0o755)
  await normalizeDirectoryModes(destination)
}

function packageCopyFilter(name, target) {
  if (name !== 'better-sqlite3') return pruneBuildOnly()
  const selected = `prebuilds/${betterSqlite3PrebuildName(target)}`
  return pruneBuildOnly((relative) => {
    const portable = relative.replaceAll(path.sep, '/')
    return (
      portable === 'package.json' ||
      /^LICENSE(?:\..*)?$/i.test(portable) ||
      portable === 'lib' ||
      portable.startsWith('lib/') ||
      portable === 'prebuilds' ||
      portable === selected
    )
  })
}

async function collectNativeModules(directory) {
  const files = []
  async function walk(current) {
    for (const entry of await readdir(current, { withFileTypes: true })) {
      const entryPath = path.join(current, entry.name)
      if (entry.isDirectory()) await walk(entryPath)
      else if (entry.name.endsWith('.node')) files.push(entryPath)
    }
  }
  await walk(directory)
  return files.sort()
}

async function validateStagedNativeModules(directory, target, packageName) {
  const nativeModules = await collectNativeModules(directory)
  for (const file of nativeModules) {
    await assertNativeBinaryTarget(file, target.platform, target.arch, {
      label: `${packageName} native module`,
    })
  }
  if (packageName === 'better-sqlite3') {
    const expected = path.join(
      directory,
      'prebuilds',
      betterSqlite3PrebuildName(target)
    )
    if (nativeModules.length !== 1 || nativeModules[0] !== expected) {
      throw new Error(
        `better-sqlite3 must stage exactly one ${target.key} prebuild`
      )
    }
  }
}

async function fingerprintInput(repoRoot, input) {
  const source = path.join(repoRoot, input.source)
  const records = []
  async function addFile(filePath, relativePath) {
    const bytes = await readFile(filePath)
    records.push({
      path: relativePath,
      bytes: bytes.length,
      sha256: createHash('sha256').update(bytes).digest('hex'),
    })
  }
  if (input.type === 'file') {
    await addFile(source, input.source)
  } else {
    async function walk(directory, relativeDirectory) {
      const entries = await readdir(directory, { withFileTypes: true })
      entries.sort((left, right) => left.name.localeCompare(right.name))
      for (const entry of entries) {
        const entryPath = path.join(directory, entry.name)
        const relativePath = path.posix.join(relativeDirectory, entry.name)
        if (entry.isDirectory()) await walk(entryPath, relativePath)
        else if (entry.isFile()) await addFile(entryPath, relativePath)
        else
          throw new Error(`fingerprint input contains a symlink: ${entryPath}`)
      }
    }
    await walk(source, input.source)
  }
  return {
    source: input.source,
    destination: input.destination,
    files: records,
  }
}

async function inventoryTree(directory) {
  let bytes = 0
  let files = 0
  async function walk(current) {
    const entries = await readdir(current, { withFileTypes: true })
    entries.sort((left, right) => left.name.localeCompare(right.name))
    for (const entry of entries) {
      const entryPath = path.join(current, entry.name)
      if (entry.isDirectory()) await walk(entryPath)
      else if (entry.isFile()) {
        bytes += (await stat(entryPath)).size
        files += 1
      } else {
        throw new Error(`staged application contains a symlink: ${entryPath}`)
      }
    }
  }
  await walk(directory)
  return { bytes, files }
}

export async function assertServerExternalsResolve(stageRoot, specifiers) {
  const script = [
    `const specifiers = ${JSON.stringify(specifiers)}`,
    'for (const specifier of specifiers) {',
    '  try { import.meta.resolve(specifier) }',
    '  catch (error) {',
    '    console.error(specifier + ": " + error.message)',
    '    process.exitCode = 1',
    '  }',
    '}',
  ].join('\n')
  try {
    await execFileAsync(
      process.execPath,
      ['--input-type=module', '--eval', script],
      {
        cwd: stageRoot,
        encoding: 'utf8',
      }
    )
  } catch (error) {
    throw new Error(
      `staged Server cannot resolve built externals: ${error.stderr?.trim() ?? error.message}`,
      { cause: error }
    )
  }
}

async function replaceStageAtomically(stageRoot, temporaryRoot) {
  const backup = `${stageRoot}.previous-${process.pid}-${Date.now()}`
  let hadPrevious = false
  try {
    await rename(stageRoot, backup)
    hadPrevious = true
  } catch (error) {
    if (error?.code !== 'ENOENT') throw error
  }
  try {
    await rename(temporaryRoot, stageRoot)
  } catch (error) {
    if (hadPrevious) await rename(backup, stageRoot)
    throw error
  }
  if (hadPrevious) await rm(backup, { recursive: true })
}

export async function stageServerApp(options = {}) {
  const repoRoot = path.resolve(options.repoRoot ?? REPOSITORY_ROOT)
  const canonicalRepoRoot = await realpath(repoRoot)
  const target = parseServerTarget({
    platform: options.platform,
    arch: options.arch,
    libc: options.libc,
    strict: options.strict,
    ci: options.ci,
  })
  const contract = validateServerRuntimeContract(
    options.contract ??
      (await readJson(
        path.join(repoRoot, 'scripts/server-runtime-dependencies.json'),
        'server runtime contract'
      ))
  )
  const budgets = validateServerSizeBudgets(
    options.budgets ??
      (await readJson(
        path.join(repoRoot, 'scripts/server-package-size-budgets.json'),
        'server size budgets'
      ))
  )
  if (!contract.supportedTargets.includes(target.key)) {
    throw new Error(`unsupported Server package target ${target.key}`)
  }
  const resolvedContract = {
    ...contract,
    buildInputs: contract.buildInputs.map((input) =>
      resolveServerInput(input, target)
    ),
    resourceInputs: contract.resourceInputs.map((input) =>
      resolveServerInput(input, target)
    ),
  }
  const inputs = [
    ...resolvedContract.buildInputs,
    ...resolvedContract.resourceInputs,
  ]
  const inputDestinations = inputs.map((input) => input.destination)
  if (new Set(inputDestinations).size !== inputDestinations.length) {
    throw new Error(
      `Server runtime contract has duplicate destinations for ${target.key}`
    )
  }

  const requestedStageRoot = path.resolve(
    options.outputDir ?? path.join(repoRoot, 'dist/server-app')
  )
  const stageRelative = path.relative(repoRoot, requestedStageRoot)
  if (
    stageRelative.length === 0 ||
    stageRelative === '..' ||
    stageRelative.startsWith(`..${path.sep}`) ||
    path.isAbsolute(stageRelative)
  ) {
    throw new Error(`stage output must stay within repository root`)
  }
  const stageRoot = path.join(canonicalRepoRoot, stageRelative)
  await mkdir(path.dirname(stageRoot), { recursive: true })

  const audit = await auditServerRuntime({
    repoRoot,
    contract: resolvedContract,
    budgets,
  })
  const rootManifest = await readJson(
    path.join(repoRoot, 'package.json'),
    'root package manifest'
  )
  const appManifest = generatedManifest(rootManifest, contract)
  const inputFingerprints = []
  for (const input of inputs) {
    inputFingerprints.push(await fingerprintInput(repoRoot, input))
  }

  const temporaryRoot = await mkdtemp(
    path.join(path.dirname(stageRoot), `.${path.basename(stageRoot)}-`)
  )
  try {
    for (const input of inputs) {
      await copyPath(
        path.join(repoRoot, input.source),
        path.join(temporaryRoot, input.destination),
        canonicalRepoRoot
      )
    }
    await writeFile(
      path.join(temporaryRoot, 'package.json'),
      stringifySortedJson(appManifest),
      { mode: 0o644 }
    )

    const placements = new Map()
    const queue = []
    const optionalOmissions = []

    async function addDependency(name, consumer, optional) {
      const instance = await resolvePackageInstance(
        name,
        consumer.sourceRoot,
        canonicalRepoRoot
      )
      if (!instance) {
        if (optional) {
          optionalOmissions.push({
            name,
            requestedBy: `${consumer.manifest.name}@${consumer.manifest.version}`,
          })
          return
        }
        throw new Error(
          `required dependency ${name} requested by ${consumer.manifest.name}@${consumer.manifest.version} is missing`
        )
      }
      if (!packageAppliesToTarget(instance.manifest, target)) {
        if (optional) {
          optionalOmissions.push({
            name,
            requestedBy: `${consumer.manifest.name}@${consumer.manifest.version}`,
          })
          return
        }
        throw new Error(
          `required dependency ${name} is incompatible with ${target.key}`
        )
      }

      for (const candidate of nodeLookupDestinations(
        consumer.destination,
        name
      )) {
        const existing = placements.get(candidate)
        if (existing?.sourceRoot === instance.sourceRoot) return
      }

      let destination = path.posix.join(
        consumer.destination,
        'node_modules',
        name
      )
      if (await isRootHoisted(repoRoot, name, instance.sourceRoot)) {
        destination = path.posix.join('node_modules', name)
      }
      destination = normalizeRelativePath(
        destination,
        `destination for ${name}`
      )
      const conflict = placements.get(destination)
      if (conflict) {
        if (conflict.sourceRoot === instance.sourceRoot) return
        throw new Error(
          `dependency placement conflict at ${destination}: ${conflict.manifest.version} vs ${instance.manifest.version}`
        )
      }
      const placement = { ...instance, destination }
      placements.set(destination, placement)
      queue.push(placement)
    }

    const rootConsumer = {
      destination: '.',
      manifest: rootManifest,
      sourceRoot: canonicalRepoRoot,
    }
    for (const name of contract.runtimeRoots) {
      await addDependency(name, rootConsumer, false)
    }
    for (let index = 0; index < queue.length; index += 1) {
      const placement = queue[index]
      for (const name of Object.keys(
        placement.manifest.dependencies ?? {}
      ).sort()) {
        await addDependency(name, placement, false)
      }
      for (const name of Object.keys(
        placement.manifest.optionalDependencies ?? {}
      ).sort()) {
        await addDependency(name, placement, true)
      }
    }

    const sortedPlacements = [...placements.values()].sort((left, right) =>
      left.destination.localeCompare(right.destination)
    )
    const packageRecords = sortedPlacements.map((placement) => ({
      destination: placement.destination,
      name: placement.manifest.name,
      source: normalizeRelativePath(
        path.relative(canonicalRepoRoot, placement.sourceRoot),
        `source for ${placement.manifest.name}`
      ),
      version: placement.manifest.version,
    }))
    for (const placement of sortedPlacements) {
      const destination = path.join(temporaryRoot, placement.destination)
      await copyPath(placement.sourceRoot, destination, canonicalRepoRoot, {
        filter: packageCopyFilter(placement.manifest.name, target),
        skipNodeModules: true,
      })
      await validateStagedNativeModules(
        destination,
        target,
        placement.manifest.name
      )
    }

    await assertServerExternalsResolve(
      temporaryRoot,
      audit.externals.specifiers
    )
    optionalOmissions.sort((left, right) =>
      `${left.name}\0${left.requestedBy}`.localeCompare(
        `${right.name}\0${right.requestedBy}`
      )
    )
    const inventory = await inventoryTree(temporaryRoot)
    const stageManifest = {
      schemaVersion: 1,
      target,
      rootVersion: rootManifest.version,
      externals: audit.externals,
      inputFingerprints,
      packages: packageRecords,
      optionalOmissions,
      inventory,
    }
    await writeFile(
      path.join(temporaryRoot, '.motrix-server-stage.json'),
      stringifySortedJson(stageManifest),
      { mode: 0o644 }
    )
    await replaceStageAtomically(stageRoot, temporaryRoot)
    return { manifest: stageManifest, stageRoot }
  } catch (error) {
    await rm(temporaryRoot, { recursive: true }).catch(() => undefined)
    throw error
  }
}

function parseArguments(argv) {
  const values = new Map()
  for (let index = 0; index < argv.length; index += 1) {
    const option = argv[index]
    if (option === '--' && index === 0) continue
    if (option === '--strict') {
      values.set(option, true)
      continue
    }
    if (!['--arch', '--libc', '--platform'].includes(option)) {
      throw new Error(`unknown staging argument: ${option}`)
    }
    const value = argv[index + 1]
    if (!value || value.startsWith('--')) {
      throw new Error(`missing value for staging argument: ${option}`)
    }
    if (values.has(option))
      throw new Error(`duplicate staging argument: ${option}`)
    values.set(option, value)
    index += 1
  }
  return values
}

export async function main(argv = process.argv.slice(2)) {
  const args = parseArguments(argv)
  const result = await stageServerApp({
    platform: args.get('--platform'),
    arch: args.get('--arch'),
    libc: args.get('--libc'),
    strict: args.has('--strict') || Boolean(process.env.CI),
  })
  console.log(
    `Staged ${result.manifest.target.key} Server app at ${result.stageRoot}`
  )
  return result
}

if (
  process.argv[1] &&
  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : String(error))
    process.exitCode = 1
  })
}