import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  rmSync,
  writeFileSync,
} from 'node:fs'
import { exec } from 'node:child_process'
import {
  copyFile,
  mkdir,
  readFile,
  readdir,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname, resolve } from 'node:path'
import { join as posixJoin, sep as posixSep } from 'node:path/posix'
import { sep as win32Sep } from 'node:path/win32'
import { fileURLToPath } from 'node:url'

import ava, { type ExecutionContext, type TestFn } from 'ava'

import {
  buildProject,
  generateTypeDef,
  napiCrossToolchainEnvs,
  validateCrossCompileFlags,
  validateNapiCrossSupport,
  writeJsBinding,
} from '../build.js'
import { getSystemDefaultTarget } from '../../utils/index.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../../../..')

const test = ava as TestFn<{
  tmpDir: string
  projectDir: string
  typeDefDir: string
}>

test.beforeEach(async (t) => {
  const timestamp = Date.now()
  const random = Math.random().toString(36).substring(7)
  const tmpDir = posixJoin(
    tmpdir(),
    'napi-rs-test',
    `build-spec-${timestamp}-${random}`,
  )
  const projectDir = posixJoin(tmpDir, 'project')
  const typeDefDir = posixJoin(projectDir, 'target', 'type-def')

  await mkdir(typeDefDir, { recursive: true })

  t.context = { tmpDir, projectDir, typeDefDir }
})

test.afterEach.always(async (t) => {
  if (existsSync(t.context.tmpDir)) {
    await rm(t.context.tmpDir, { recursive: true, force: true })
  }
})

test('build pipeline generates bindings and artifacts', async (t) => {
  const { projectDir, typeDefDir } = t.context
  const crateName = 'build_integration'
  const binaryName = 'build-integration'
  const packageName = 'build-integration'
  const version = '0.1.0'
  const target = getSystemDefaultTarget()

  const napiPath = posixJoin(repoRoot, 'crates', 'napi').replaceAll(
    win32Sep,
    posixSep,
  )
  const napiDerivePath = posixJoin(repoRoot, 'crates', 'macro').replaceAll(
    win32Sep,
    posixSep,
  )
  const napiBuildPath = posixJoin(repoRoot, 'crates', 'build').replaceAll(
    win32Sep,
    posixSep,
  )

  await mkdir(join(projectDir, 'src'), { recursive: true })

  const cargoToml = `[package]
name = "${crateName}"
version = "${version}"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { path = "${napiPath}" }
napi-derive = { path = "${napiDerivePath}" }

[build-dependencies]
napi-build = { path = "${napiBuildPath}" }
`

  await writeFile(join(projectDir, 'Cargo.toml'), cargoToml)
  await writeFile(
    join(projectDir, 'package.json'),
    `${JSON.stringify(
      {
        name: packageName,
        version,
        napi: {
          binaryName,
          targets: [target.triple],
        },
      },
      null,
      2,
    )}\n`,
  )
  await writeFile(
    join(projectDir, 'build.rs'),
    'fn main() {\n    napi_build::setup();\n}\n',
  )
  await writeFile(
    join(projectDir, 'src', 'lib.rs'),
    'use napi_derive::napi;\n\n#[napi]\npub fn sum(a: i32, b: i32) -> i32 {\n    a + b\n}\n',
  )

  const buildCmd = `cargo build --target ${target.triple}`

  await new Promise<void>((resolve, reject) => {
    const child = exec(buildCmd, {
      cwd: projectDir,
      env: { ...process.env, NAPI_TYPE_DEF_TMP_FOLDER: typeDefDir },
    })
    child.stderr?.on('data', (data) => {
      console.error(data.toString())
    })
    child.on('exit', (code) => {
      if (code === 0) {
        resolve()
      } else {
        reject(new Error(`cargo build exited with code ${code ?? 'null'}`))
      }
    })
    child.on('error', reject)
  })

  const files = await readdir(typeDefDir)
  t.true(files.length > 0, 'type definition files should be generated')

  const { exports, dts } = await generateTypeDef({
    typeDefDir,
    cwd: projectDir,
  })

  t.true(exports.includes('sum'), 'generateTypeDef should expose napi exports')

  const jsBinding = await writeJsBinding({
    platform: true,
    idents: exports,
    binaryName,
    packageName,
    version,
    outputDir: projectDir,
  })

  t.truthy(jsBinding)
  t.is(jsBinding?.path, join(projectDir, 'index.js'))

  const libName = crateName.replace(/-/g, '_')
  const srcName =
    target.platform === 'darwin'
      ? `lib${libName}.dylib`
      : target.platform === 'win32'
        ? `${libName}.dll`
        : `lib${libName}.so`
  const profile = 'debug'
  const srcPath = join(projectDir, 'target', target.triple, profile, srcName)
  t.true(existsSync(srcPath), 'compiled artifact should exist')

  const destName = `${binaryName}.${target.platformArchABI}.${srcName.endsWith('.wasm') ? 'wasm' : 'node'}`
  const destPath = join(projectDir, destName)
  await copyFile(srcPath, destPath)
  t.true(existsSync(destPath), 'artifact should be copied to output directory')

  const nodeStat = await stat(destPath)
  t.true(nodeStat.size > 0)

  t.regex(dts, /export declare function sum\(a: number, b: number\): number/)

  const jsPath = join(projectDir, 'index.js')
  t.true(existsSync(jsPath))
  const jsContent = await readFile(jsPath, 'utf-8')
  t.regex(jsContent, /module\.exports\.sum = nativeBinding\.sum/)
})

test('generateTypeDef preserves deterministic file order', async (t) => {
  const { projectDir, typeDefDir } = t.context

  await mkdir(join(typeDefDir, 'nested'), { recursive: true })
  await writeFile(
    join(typeDefDir, 'b.type'),
    '{"kind":"fn","name":"zeta","def":"function zeta(): void"}\n',
  )
  await writeFile(
    join(typeDefDir, 'a.type'),
    '{"kind":"fn","name":"alpha","def":"function alpha(): void"}\n',
  )

  const { exports, dts } = await generateTypeDef({
    typeDefDir,
    cwd: projectDir,
  })

  t.deepEqual(exports, ['alpha', 'zeta'])
  t.true(
    dts.indexOf('function alpha(): void') <
      dts.indexOf('function zeta(): void'),
  )
})

test('should throw on emnapi version mismatch in wasm build', async (t) => {
  const { projectDir } = t.context
  const crateName = 'wasm_version_check'
  const binaryName = 'wasm-version-check'
  const packageName = 'wasm-version-check'
  const version = '0.1.0'

  const napiPath = posixJoin(repoRoot, 'crates', 'napi').replaceAll(
    win32Sep,
    posixSep,
  )
  const napiDerivePath = posixJoin(repoRoot, 'crates', 'macro').replaceAll(
    win32Sep,
    posixSep,
  )
  const napiBuildPath = posixJoin(repoRoot, 'crates', 'build').replaceAll(
    win32Sep,
    posixSep,
  )

  await mkdir(join(projectDir, 'src'), { recursive: true })

  const cargoToml = `[package]
name = "${crateName}"
version = "${version}"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { path = "${napiPath}", features = ["noop"] }
napi-derive = { path = "${napiDerivePath}", features = ["noop"] }

[build-dependencies]
napi-build = { path = "${napiBuildPath}" }
`

  await writeFile(join(projectDir, 'Cargo.toml'), cargoToml)
  await writeFile(
    join(projectDir, 'package.json'),
    `${JSON.stringify(
      {
        name: packageName,
        version,
        napi: {
          binaryName,
          targets: ['wasm32-wasi-preview1-threads'],
        },
      },
      null,
      2,
    )}\n`,
  )
  await writeFile(
    join(projectDir, 'build.rs'),
    'fn main() {\n    napi_build::setup();\n}\n',
  )
  await writeFile(
    join(projectDir, 'src', 'lib.rs'),
    'use napi_derive::napi;\n\n#[napi]\npub fn sum(a: i32, b: i32) -> i32 {\n    a + b\n}\n',
  )

  // Create fake @emnapi/core and @emnapi/runtime with mismatched versions
  const fakeVersion = '0.0.0-fake'
  for (const pkg of ['@emnapi/core', '@emnapi/runtime']) {
    const pkgDir = join(projectDir, 'node_modules', pkg)
    await mkdir(pkgDir, { recursive: true })
    await writeFile(
      join(pkgDir, 'package.json'),
      JSON.stringify({ name: pkg, version: fakeVersion, main: 'index.js' }),
    )
    await writeFile(
      join(pkgDir, 'index.js'),
      `module.exports = { version: "${fakeVersion}" }`,
    )
  }

  const error = await t.throwsAsync(() =>
    buildProject({
      target: 'wasm32-wasi-preview1-threads',
      cwd: projectDir,
    }),
  )

  t.truthy(error)
  t.regex(error!.message, /emnapi version mismatch/)
})

test('validateCrossCompileFlags rejects combining two cross-compilation mechanisms', (t) => {
  t.throws(
    () => validateCrossCompileFlags({ useCross: true, crossCompile: true }),
    { message: /`--use-cross`.+`--cross-compile`.+cannot be used together/ },
  )
  t.throws(
    () => validateCrossCompileFlags({ useCross: true, useNapiCross: true }),
    { message: /`--use-cross`.+`--use-napi-cross`.+cannot be used together/ },
  )
  t.throws(
    () => validateCrossCompileFlags({ useNapiCross: true, crossCompile: true }),
    {
      message: /`--use-napi-cross`.+`--cross-compile`.+cannot be used together/,
    },
  )
  t.throws(
    () =>
      validateCrossCompileFlags({
        useCross: true,
        useNapiCross: true,
        crossCompile: true,
      }),
    {
      message:
        /`--use-cross`.+`--use-napi-cross`.+`--cross-compile`.+cannot be used together/,
    },
  )
})

test('validateCrossCompileFlags allows a single cross-compilation mechanism', (t) => {
  t.notThrows(() => validateCrossCompileFlags({}))
  t.notThrows(() => validateCrossCompileFlags({ useCross: true }))
  t.notThrows(() => validateCrossCompileFlags({ crossCompile: true }))
  t.notThrows(() => validateCrossCompileFlags({ useNapiCross: true }))
})

test('validateCrossCompileFlags rejects windows-gnu targets with `--cross-compile`', (t) => {
  const windowsGnuError =
    /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnu/
  // `cargo-xwin` is only used on non-Windows hosts, where it silently
  // no-ops for `windows-gnu` targets, so the combination must be rejected.
  const gnuError = t.throws(
    () =>
      validateCrossCompileFlags(
        { crossCompile: true, target: 'x86_64-pc-windows-gnu' },
        'linux',
      ),
    { message: windowsGnuError },
  )
  // `windows-gnu` links with a mingw-w64 GCC toolchain.
  t.regex(gnuError!.message, /x86_64-w64-mingw32-gcc/)
  t.regex(gnuError!.message, /LIBNODE_PATH/)
  t.regex(gnuError!.message, /x86_64-pc-windows-msvc/)
  t.throws(
    () =>
      validateCrossCompileFlags(
        { crossCompile: true, target: 'x86_64-pc-windows-gnu' },
        'darwin',
      ),
    { message: windowsGnuError },
  )
  // `gnullvm`-flavored triples take the same broken `cargo-xwin` route, but
  // link with an LLVM toolchain (llvm-mingw), not the mingw-w64 GCC one.
  const gnullvmError = t.throws(
    () =>
      validateCrossCompileFlags(
        { crossCompile: true, target: 'x86_64-pc-windows-gnullvm' },
        'linux',
      ),
    {
      message:
        /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnullvm/,
    },
  )
  t.regex(gnullvmError!.message, /llvm-mingw/)
  t.notRegex(gnullvmError!.message, /mingw32-gcc/)
  t.regex(gnullvmError!.message, /LIBNODE_PATH/)
  t.regex(gnullvmError!.message, /x86_64-pc-windows-msvc/)
  // The target can also come from `CARGO_BUILD_TARGET`; the check is fully
  // synchronous, so the environment is mutated and restored without any
  // interleaving point another concurrently running test could observe.
  const originalCargoBuildTarget = process.env.CARGO_BUILD_TARGET
  try {
    process.env.CARGO_BUILD_TARGET = 'x86_64-pc-windows-gnu'
    t.throws(() => validateCrossCompileFlags({ crossCompile: true }, 'linux'), {
      message: windowsGnuError,
    })
  } finally {
    if (originalCargoBuildTarget === undefined) {
      delete process.env.CARGO_BUILD_TARGET
    } else {
      process.env.CARGO_BUILD_TARGET = originalCargoBuildTarget
    }
  }
})

test('validateCrossCompileFlags allows `--cross-compile` for non windows-gnu targets', (t) => {
  // MSVC targets are exactly what `cargo-xwin` supports.
  for (const target of [
    'x86_64-pc-windows-msvc',
    'aarch64-pc-windows-msvc',
    'i686-pc-windows-msvc',
    'x86_64-unknown-linux-gnu',
    'aarch64-apple-darwin',
  ]) {
    t.notThrows(() =>
      validateCrossCompileFlags({ crossCompile: true, target }, 'linux'),
    )
  }
  // On a Windows host `--cross-compile` never routes through `cargo-xwin`
  // (it falls back to a plain `cargo build`), so windows-gnu stays allowed.
  t.notThrows(() =>
    validateCrossCompileFlags(
      { crossCompile: true, target: 'x86_64-pc-windows-gnu' },
      'win32',
    ),
  )
  // Without `--cross-compile` the target is none of this check's business.
  t.notThrows(() =>
    validateCrossCompileFlags({ target: 'x86_64-pc-windows-gnu' }, 'linux'),
  )
})

test('validateCrossCompileFlags rejects watch mode combined with cross builds', (t) => {
  t.throws(() => validateCrossCompileFlags({ watch: true, useCross: true }), {
    message: /`--watch` cannot be used with `--use-cross`/,
  })
  t.throws(
    () => validateCrossCompileFlags({ watch: true, crossCompile: true }),
    { message: /`--watch` cannot be used with `--cross-compile`/ },
  )
  t.notThrows(() => validateCrossCompileFlags({ watch: true }))
  t.notThrows(() =>
    validateCrossCompileFlags({ watch: true, useNapiCross: true }),
  )
})

test('validateNapiCrossSupport rejects unsupported hosts', (t) => {
  t.throws(
    () =>
      validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'darwin', 'arm64'),
    { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ },
  )
  t.throws(
    () => validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'win32', 'x64'),
    { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ },
  )
  t.throws(
    () =>
      validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'linux', 'ia32'),
    { message: /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/ },
  )
  t.notThrows(() =>
    validateNapiCrossSupport('aarch64-unknown-linux-gnu', 'linux', 'x64'),
  )
  t.notThrows(() =>
    validateNapiCrossSupport('x86_64-unknown-linux-gnu', 'linux', 'arm64'),
  )
})

test('validateNapiCrossSupport rejects unsupported target triples', (t) => {
  t.throws(
    () => validateNapiCrossSupport('x86_64-unknown-linux-musl', 'linux', 'x64'),
    {
      message:
        /`--use-napi-cross` does not support the target x86_64-unknown-linux-musl/,
    },
  )
  t.throws(
    () =>
      validateNapiCrossSupport('riscv64gc-unknown-linux-gnu', 'linux', 'arm64'),
    {
      message:
        /`--use-napi-cross` does not support the target riscv64gc-unknown-linux-gnu/,
    },
  )
  for (const triple of [
    'x86_64-unknown-linux-gnu',
    'aarch64-unknown-linux-gnu',
    'armv7-unknown-linux-gnueabihf',
    's390x-unknown-linux-gnu',
    'powerpc64le-unknown-linux-gnu',
  ]) {
    t.notThrows(() => validateNapiCrossSupport(triple, 'linux', 'x64'))
    t.notThrows(() => validateNapiCrossSupport(triple, 'linux', 'arm64'))
  }
})

const napiCrossToolchainPath = posixJoin(
  '/home/user/.napi-rs/cross-toolchain/1.0.0',
  'aarch64-unknown-linux-gnu',
)
const napiCrossDownloadedSysroot = join(
  napiCrossToolchainPath,
  'aarch64-unknown-linux-gnu',
  'sysroot',
)

test('napiCrossToolchainEnvs points the build at the downloaded toolchain', (t) => {
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin' },
  )

  t.is(
    envs.CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER,
    join(napiCrossToolchainPath, 'bin', 'aarch64-unknown-linux-gnu-gcc'),
  )
  t.is(envs.TARGET_SYSROOT, napiCrossDownloadedSysroot)
  t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, `--sysroot=${napiCrossDownloadedSysroot}`)
  t.is(envs.PATH, `${napiCrossToolchainPath}/bin:/usr/bin`)
  // gcc is the default compiler, so no clang-specific flags are set.
  t.is(envs.TARGET_CFLAGS, undefined)
  t.is(envs.TARGET_CXXFLAGS, undefined)
})

test('napiCrossToolchainEnvs respects a user-provided TARGET_SYSROOT', (t) => {
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', TARGET_SYSROOT: '/opt/custom-sysroot' },
  )

  // The user's value wins: it is not overridden...
  t.is(envs.TARGET_SYSROOT, undefined)
  // ...and the derived flags use it.
  t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, '--sysroot=/opt/custom-sysroot')
})

test('napiCrossToolchainEnvs treats an empty TARGET_SYSROOT as unset', (t) => {
  // `setEnvIfNotExists` uses falsy semantics (`!process.env[env]`), so a
  // present-but-empty `TARGET_SYSROOT` still gets the downloaded sysroot
  // written to the build environment — the flags derived from the effective
  // sysroot must follow the same rule instead of producing `--sysroot=`.
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', TARGET_SYSROOT: '' },
  )

  t.is(envs.TARGET_SYSROOT, napiCrossDownloadedSysroot)
  t.is(envs.BINDGEN_EXTRA_CLANG_ARGS, `--sysroot=${napiCrossDownloadedSysroot}`)
})

test('napiCrossToolchainEnvs derives clang flags from the effective sysroot', (t) => {
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    {
      PATH: '/usr/bin',
      // Present-but-empty values must fall back to the downloaded sysroot.
      TARGET_SYSROOT: '',
      TARGET_CC: 'clang',
      TARGET_CXX: 'clang++',
      TARGET_CFLAGS: '-O2',
    },
  )

  t.is(
    envs.TARGET_CFLAGS,
    `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} -O2`,
  )
  t.is(
    envs.TARGET_CXXFLAGS,
    `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `,
  )
})

test('napiCrossToolchainEnvs sets a bare toolchain PATH when the env has none', (t) => {
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    {},
  )

  // No `:undefined` tail when the provided env has no PATH at all.
  t.is(envs.PATH, `${napiCrossToolchainPath}/bin`)
})

test('napiCrossToolchainEnvs recognizes path-qualified, prefixed and versioned clang', (t) => {
  const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `

  for (const [cc, cxx] of [
    ['/usr/bin/clang', '/opt/llvm/bin/clang++'],
    ['aarch64-linux-gnu-clang', 'aarch64-linux-gnu-clang++'],
    ['clang-18', 'clang++-18'],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`)
  }
})

test('napiCrossToolchainEnvs ignores CC/CXX when the toolchain compiler is effective', (t) => {
  // With TARGET_CC/TARGET_CXX unset, the function itself exports the
  // toolchain gcc/g++ as TARGET_CC/TARGET_CXX — and cc-rs prefers TARGET_CC
  // over CC for cross builds, so a clang in CC/CXX never actually runs.
  // Injecting the clang-only `--gcc-toolchain=` flag here would hard-error
  // the gcc that does run.
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', CC: '/usr/bin/clang', CXX: '/opt/llvm/bin/clang++' },
  )

  t.true(envs.TARGET_CC.endsWith('-gcc'))
  t.true(envs.TARGET_CXX.endsWith('-g++'))
  t.is(envs.TARGET_CFLAGS, undefined)
  t.is(envs.TARGET_CXXFLAGS, undefined)
})

test('napiCrossToolchainEnvs treats an empty TARGET_CC as unset for clang detection', (t) => {
  // Falsy semantics: a present-but-empty TARGET_CC still gets the toolchain
  // gcc written to the build environment, so the CC fallback must not
  // resurrect clang detection for a compiler that will not run.
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', TARGET_CC: '', CC: '/usr/bin/clang' },
  )

  t.is(
    envs.TARGET_CC,
    join(napiCrossToolchainPath, 'bin', 'aarch64-unknown-linux-gnu-gcc'),
  )
  t.is(envs.TARGET_CFLAGS, undefined)
})

test('napiCrossToolchainEnvs lets a user TARGET_CC=clang win over CC=gcc', (t) => {
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', TARGET_CC: 'clang', CC: 'gcc' },
  )

  t.is(
    envs.TARGET_CFLAGS,
    `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `,
  )
  // The CXX side is untouched, so it stays on the toolchain g++ without
  // clang flags — each language is detected independently.
  t.is(envs.TARGET_CXXFLAGS, undefined)
})

test('napiCrossToolchainEnvs injects no flags on either side when only CC=clang is set', (t) => {
  // Both languages default to the toolchain gcc/g++; neither effective
  // compiler is clang, so neither flag set is injected.
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', CC: 'clang' },
  )

  t.true(envs.TARGET_CC.endsWith('-gcc'))
  t.is(envs.TARGET_CFLAGS, undefined)
  t.is(envs.TARGET_CXXFLAGS, undefined)
})

test('napiCrossToolchainEnvs does not mistake non-clang tools for clang', (t) => {
  for (const [cc, cxx] of [
    ['gcc', 'g++'],
    ['x86_64-unknown-linux-gnu-gcc', 'x86_64-unknown-linux-gnu-g++'],
    // `clang-format` is a clang-family tool but not a compiler.
    ['clang-format', 'clang-format'],
    ['someclangthing', 'someclangthing'],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`)
  }
})

test('napiCrossToolchainEnvs detects clang behind cc-rs wrapper and argument forms', (t) => {
  const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `

  // cc-rs parses the env value before executing it (`env_tool`): the value
  // is split on whitespace, a known wrapper prefix (`sccache clang`) runs
  // the second token, and an argument form (`clang -target …`) runs the
  // first token with the rest as arguments — clang runs in both cases.
  for (const [cc, cxx] of [
    ['sccache clang', 'sccache clang++'],
    ['ccache clang-18', 'ccache clang++-18'],
    ['distcc /usr/bin/clang', 'distcc /usr/bin/clang++'],
    [
      'clang -target aarch64-unknown-linux-gnu',
      'clang++ -target aarch64-unknown-linux-gnu',
    ],
    ['/usr/bin/clang --sysroot=/x', '/usr/bin/clang++ --sysroot=/x'],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`)
  }
})

// Writes empty files at `relativePaths` under a fresh temp directory whose
// subdirectories contain spaces, mirroring installs like `/opt/LLVM 18`.
// Returns the temp directory root; removal is registered on `t.teardown`.
const makeCompilerFixture = (
  t: ExecutionContext,
  relativePaths: Array<string>,
): string => {
  const fixtureRoot = mkdtempSync(join(tmpdir(), 'napi-clang-detect-'))
  t.teardown(() => rmSync(fixtureRoot, { recursive: true, force: true }))
  for (const relativePath of relativePaths) {
    const absolutePath = join(fixtureRoot, relativePath)
    mkdirSync(dirname(absolutePath), { recursive: true })
    writeFileSync(absolutePath, '')
  }
  return fixtureRoot
}

test('napiCrossToolchainEnvs matches clang in space-containing paths that exist on disk', (t) => {
  const clangFlags = `--sysroot=${napiCrossDownloadedSysroot} --gcc-toolchain=${napiCrossToolchainPath} `

  // cc-rs's `env_tool` treats the WHOLE env value as the compiler when it
  // exists on the filesystem (`check_exe`) before any whitespace splitting,
  // so `TARGET_CC="<tmp>/LLVM 18/bin/clang"` runs clang — splitting it into
  // `<tmp>/LLVM` + `18/bin/clang` would miss the clang detection entirely.
  const fixtureRoot = makeCompilerFixture(t, [
    'LLVM 18/bin/clang',
    'LLVM 18/bin/clang++-17',
  ])
  const cc = join(fixtureRoot, 'LLVM 18', 'bin', 'clang')
  const cxx = join(fixtureRoot, 'LLVM 18', 'bin', 'clang++-17')
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
  )

  t.is(envs.TARGET_CFLAGS, clangFlags, `TARGET_CC=${cc}`)
  t.is(envs.TARGET_CXXFLAGS, clangFlags, `TARGET_CXX=${cxx}`)
})

test('napiCrossToolchainEnvs does not mistake existing space-containing non-clang paths for clang', (t) => {
  const fixtureRoot = makeCompilerFixture(t, [
    'app dir/bin/gcc',
    'app dir/bin/g++',
    // A clang-family tool that is not a compiler, in a space-containing path.
    'LLVM 18/bin/clang-format',
  ])
  for (const [cc, cxx] of [
    [
      join(fixtureRoot, 'app dir', 'bin', 'gcc'),
      join(fixtureRoot, 'app dir', 'bin', 'g++'),
    ],
    [
      join(fixtureRoot, 'LLVM 18', 'bin', 'clang-format'),
      join(fixtureRoot, 'LLVM 18', 'bin', 'clang-format'),
    ],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`)
  }
})

test('napiCrossToolchainEnvs splits space-containing paths that do not exist on disk', (t) => {
  // cc-rs only takes the whole value as a compiler path when it exists on
  // the filesystem; otherwise it splits on whitespace, so this value runs
  // `/nonexistent` with `dir/bin/clang` as an argument — never clang.
  const envs = napiCrossToolchainEnvs(
    napiCrossToolchainPath,
    'aarch64-unknown-linux-gnu',
    {
      PATH: '/usr/bin',
      TARGET_CC: '/nonexistent dir/bin/clang',
      TARGET_CXX: '/nonexistent dir/bin/clang++',
    },
  )

  t.is(envs.TARGET_CFLAGS, undefined)
  t.is(envs.TARGET_CXXFLAGS, undefined)
})

test('napiCrossToolchainEnvs does not mistake gcc with clang-ending arguments for clang', (t) => {
  // The basename of each WHOLE value below is `clang`, but none of them
  // exists as a file, so cc-rs splits on whitespace and runs gcc — clang
  // flags injected here would hard-error the gcc compile.
  for (const [cc, cxx] of [
    ['gcc --sysroot=/opt/clang', 'g++ --sysroot=/opt/clang'],
    ['gcc -B/opt/LLVM 18/bin/clang', 'g++ -B/opt/LLVM 18/bin/clang'],
    ['sccache gcc --sysroot=/opt/clang', 'sccache g++ --sysroot=/opt/clang'],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`)
  }
})

test('napiCrossToolchainEnvs does not mistake wrapped or argument-form non-clang for clang', (t) => {
  for (const [cc, cxx] of [
    ['sccache gcc', 'sccache g++'],
    ['ccache gcc', 'ccache g++'],
    ['gcc -B/foo', 'g++ -B/foo'],
    // The compiler token behind the wrapper is still not a compiler.
    ['sccache clang-format', 'sccache clang-format'],
  ]) {
    const envs = napiCrossToolchainEnvs(
      napiCrossToolchainPath,
      'aarch64-unknown-linux-gnu',
      { PATH: '/usr/bin', TARGET_CC: cc, TARGET_CXX: cxx },
    )

    t.is(envs.TARGET_CFLAGS, undefined, `TARGET_CC=${cc}`)
    t.is(envs.TARGET_CXXFLAGS, undefined, `TARGET_CXX=${cxx}`)
  }
})

test('buildProject rejects invalid cross flag combinations upfront', async (t) => {
  const { projectDir } = t.context

  await mkdir(join(projectDir, 'src'), { recursive: true })
  await writeFile(
    join(projectDir, 'Cargo.toml'),
    `[package]
name = "cross_flags_check"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
`,
  )
  await writeFile(join(projectDir, 'src', 'lib.rs'), '')
  await writeFile(
    join(projectDir, 'package.json'),
    `${JSON.stringify(
      {
        name: 'cross-flags-check',
        version: '0.1.0',
        napi: { binaryName: 'cross-flags-check' },
      },
      null,
      2,
    )}\n`,
  )

  const comboError = await t.throwsAsync(() =>
    buildProject({ cwd: projectDir, useCross: true, crossCompile: true }),
  )
  t.regex(comboError!.message, /cannot be used together/)

  const watchError = await t.throwsAsync(() =>
    buildProject({ cwd: projectDir, watch: true, crossCompile: true }),
  )
  t.regex(
    watchError!.message,
    /`--watch` cannot be used with `--cross-compile`/,
  )

  const napiCrossError = await t.throwsAsync(() =>
    buildProject({
      cwd: projectDir,
      useNapiCross: true,
      target: 'riscv64gc-unknown-linux-gnu',
    }),
  )
  t.regex(napiCrossError!.message, /`--use-napi-cross`/)
})

test('buildProject validates cross flags before resolving the crate manifest', async (t) => {
  // `projectDir` deliberately contains no `Cargo.toml`: if `buildProject`
  // resolved the manifest (and spawned `cargo metadata`) before validating
  // the cross-compilation flags, these calls would fail with
  // "No crate found in manifest" instead of the validation errors below.
  const { projectDir } = t.context

  const comboError = await t.throwsAsync(() =>
    buildProject({ cwd: projectDir, useCross: true, crossCompile: true }),
  )
  t.regex(
    comboError!.message,
    /`--use-cross`.+`--cross-compile`.+cannot be used together/,
  )

  const watchError = await t.throwsAsync(() =>
    buildProject({ cwd: projectDir, watch: true, useCross: true }),
  )
  t.regex(watchError!.message, /`--watch` cannot be used with `--use-cross`/)

  const watchCrossCompileError = await t.throwsAsync(() =>
    buildProject({ cwd: projectDir, watch: true, crossCompile: true }),
  )
  t.regex(
    watchCrossCompileError!.message,
    /`--watch` cannot be used with `--cross-compile`/,
  )

  // Rejected either for the unsupported host (non Linux x64/arm64) or for
  // the unsupported target triple (on Linux x64/arm64 hosts) — both are
  // `--use-napi-cross` validation errors, keeping this assertion
  // host-platform independent.
  const napiCrossError = await t.throwsAsync(() =>
    buildProject({
      cwd: projectDir,
      useNapiCross: true,
      target: 'riscv64gc-unknown-linux-gnu',
    }),
  )
  t.regex(napiCrossError!.message, /`--use-napi-cross`/)
})

// On a Windows host `--cross-compile` never routes through `cargo-xwin`,
// so the windows-gnu rejection only exists on non-Windows hosts.
;(process.platform === 'win32' ? test.skip : test)(
  'buildProject rejects `--cross-compile` with a windows-gnu target before any side effect',
  async (t) => {
    // `projectDir` deliberately contains no `Cargo.toml`: if `buildProject`
    // resolved the manifest (and spawned `cargo metadata`) before validating
    // the target, this call would fail with a manifest error instead of the
    // windows-gnu validation error below.
    const { projectDir } = t.context

    const error = await t.throwsAsync(() =>
      buildProject({
        cwd: projectDir,
        crossCompile: true,
        target: 'x86_64-pc-windows-gnu',
      }),
    )
    t.regex(
      error!.message,
      /`--cross-compile` \(`-x`\) does not support the target x86_64-pc-windows-gnu/,
    )
    t.regex(error!.message, /cargo-xwin/)
  },
)

// The scenario below only exists on hosts `--use-napi-cross` does not
// support (anything but Linux x64 / Linux arm64), so skip it elsewhere.
const isNapiCrossUnsupportedHost =
  process.platform !== 'linux' ||
  (process.arch !== 'x64' && process.arch !== 'arm64')

;(isNapiCrossUnsupportedHost ? test : test.skip)(
  'buildProject reports the `--use-napi-cross` host error before resolving the target',
  async (t) => {
    const { projectDir } = t.context

    // Without an explicit `--target` (or `CARGO_BUILD_TARGET`), resolving
    // the target spawns `rustc -vV`. Point `PATH` at an empty directory so
    // that spawn is guaranteed to fail — whether or not Rust is installed on
    // this machine: if `buildProject` resolved the target before validating
    // the host, the error below would be the `rustc` spawn failure instead
    // of the host validation error.
    const emptyPathDir = join(projectDir, 'empty-path')
    await mkdir(emptyPathDir, { recursive: true })

    const originalPath = process.env.PATH
    const originalCargoBuildTarget = process.env.CARGO_BUILD_TARGET
    process.env.PATH = emptyPathDir
    delete process.env.CARGO_BUILD_TARGET
    // The cross-flag validation runs synchronously at the top of
    // `buildProject`, so the promise below is already settled (rejected)
    // when the environment is restored right after — no other concurrently
    // running test can observe the modified `PATH`.
    let buildPromise: Promise<unknown>
    try {
      buildPromise = buildProject({ cwd: projectDir, useNapiCross: true })
    } finally {
      if (originalPath === undefined) {
        delete process.env.PATH
      } else {
        process.env.PATH = originalPath
      }
      if (originalCargoBuildTarget === undefined) {
        delete process.env.CARGO_BUILD_TARGET
      } else {
        process.env.CARGO_BUILD_TARGET = originalCargoBuildTarget
      }
    }

    const error = await t.throwsAsync(() => buildPromise)
    t.regex(
      error!.message,
      /`--use-napi-cross` requires a Linux x64 or Linux arm64 host/,
    )
  },
)
