import { createHash } from 'node:crypto'
import { spawn, spawnSync } from 'node:child_process'
import {
  access,
  cp,
  lstat,
  mkdir,
  mkdtemp,
  open,
  readFile,
  readdir,
  readlink,
  rename,
  rm,
  stat,
  symlink,
  writeFile,
} from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const configPath = resolve(root, 'config/offline-resources.json')
const config = JSON.parse(await readFile(configPath, 'utf8'))
const command = process.argv[2] || 'help'
const values = new Map(process.argv.slice(3).filter((argument) => argument.startsWith('--') && argument.includes('=')).map((argument) => {
  const offset = argument.indexOf('=')
  return [argument.slice(2, offset), argument.slice(offset + 1)]
}))
const flags = new Set(process.argv.slice(3).filter((argument) => argument.startsWith('--') && !argument.includes('=')).map((argument) => argument.slice(2)))
const defaultLibrary = resolve(homedir(), 'resource-library', config.libraryDirectory)
const library = resolve(values.get('library') || process.env.WEB_FREECAD_RESOURCE_LIBRARY || defaultLibrary)
const manifestPath = resolve(library, 'manifest.json')
const selectedIds = new Set((values.get('only') || '').split(',').filter(Boolean))
const isSelected = (resource) => selectedIds.size === 0 || selectedIds.has(resource.id)
const zstdLevel = values.get('compression-level') || '3'
const knownIds = new Set(['node-runtime', 'npm-cache', ...config.archives.map((entry) => entry.id), ...config.mirrors.map((entry) => entry.id)])
for (const id of selectedIds) if (!knownIds.has(id)) fail(`Unknown offline resource id: ${id}`)

function print(value) {
  process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`)
}

function fail(message) {
  throw new Error(message)
}

async function exists(path) {
  return access(path).then(() => true, () => false)
}

function expandHome(path) {
  return path === '~' ? homedir() : path.startsWith(`~${sep}`) ? resolve(homedir(), path.slice(2)) : path
}

function resolveWorkspacePath(path) {
  const expanded = expandHome(path)
  return isAbsolute(expanded) ? expanded : resolve(root, expanded)
}

async function resourceSource(resource) {
  const candidates = []
  if (resource.sourceEnvironment && process.env[resource.sourceEnvironment]) candidates.push(process.env[resource.sourceEnvironment])
  if (resource.source) candidates.push(resource.source)
  candidates.push(...(resource.sourceCandidates || []))
  for (const candidate of candidates) {
    const path = resolveWorkspacePath(candidate)
    if (await exists(path)) return path
  }
  return null
}

function resourceLibraryPath(resource) {
  return resolve(library, resource.libraryPath)
}

function resourceRestorePath(resource, targetRoot = root) {
  return resolve(targetRoot, resource.restore)
}

async function run(program, args, options = {}) {
  await new Promise((resolveRun, rejectRun) => {
    const child = spawn(program, args, {
      cwd: options.cwd || root,
      env: options.env || process.env,
      stdio: options.stdio || 'inherit',
    })
    child.once('error', rejectRun)
    child.once('exit', (code, signal) => {
      if (code === 0) resolveRun()
      else rejectRun(new Error(`${program} exited with ${code ?? signal}`))
    })
  })
}

async function sha256(path) {
  const digest = createHash('sha256')
  const handle = await open(path, 'r')
  try {
    for await (const chunk of handle.createReadStream()) digest.update(chunk)
  } finally {
    await handle.close().catch(() => {})
  }
  return digest.digest('hex')
}

async function describeFile(path) {
  const metadata = await stat(path)
  return { bytes: metadata.size, sha256: await sha256(path) }
}

async function walkFiles(path, options = {}, prefix = '') {
  const files = []
  for (const entry of await readdir(path, { withFileTypes: true })) {
    const relativePath = prefix ? join(prefix, entry.name) : entry.name
    const absolutePath = resolve(path, entry.name)
    if (entry.isDirectory() && options.recursive !== false) files.push(...await walkFiles(absolutePath, options, relativePath))
    else if ((entry.isFile() || entry.isSymbolicLink()) && (!options.matcher || options.matcher.test(relativePath))) files.push(relativePath)
  }
  return files
}

async function describeTree(path) {
  const paths = (await walkFiles(path)).sort()
  const digest = createHash('sha256')
  let bytes = 0
  for (const relativePath of paths) {
    const absolutePath = resolve(path, relativePath)
    const metadata = await lstat(absolutePath)
    digest.update(`${relativePath}\0${metadata.mode & 0o777}\0`)
    if (metadata.isSymbolicLink()) {
      digest.update(`link:${await readlink(absolutePath)}\0`)
      continue
    }
    bytes += metadata.size
    const handle = await open(absolutePath, 'r')
    try {
      for await (const chunk of handle.createReadStream()) digest.update(chunk)
    } finally {
      await handle.close().catch(() => {})
    }
    digest.update('\0')
  }
  return { files: paths.length, bytes, sha256: digest.digest('hex') }
}

async function gitMetadata(path) {
  const top = spawnSync('git', ['-C', path, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
  if (top.status !== 0) return null
  const topLevel = resolve(top.stdout.trim())
  const revision = spawnSync('git', ['-C', path, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim()
  const status = spawnSync('git', ['-C', path, 'status', '--short', '--untracked-files=all'], {
    encoding: 'utf8',
    maxBuffer: 64 * 1024 * 1024,
  }).stdout
  return {
    revision,
    worktreeRoot: relative(root, topLevel),
    sourceWithinWorktree: relative(topLevel, path) || '.',
    dirty: status.length > 0,
    statusSha256: createHash('sha256').update(status).digest('hex'),
    statusEntries: status.split(/\r?\n/).filter(Boolean).length,
  }
}

async function replacePath(temporary, destination) {
  await mkdir(dirname(destination), { recursive: true })
  await rm(destination, { recursive: true, force: true })
  await rename(temporary, destination)
}

async function createArchive(resource) {
  const source = await resourceSource(resource)
  if (!source) {
    if (resource.required) fail(`Required source is missing for ${resource.id}`)
    print(`[offline:sync] skip optional ${resource.id}: source is missing`)
    return null
  }
  const sourceGit = await gitMetadata(source)
  if (resource.revision && sourceGit?.revision !== resource.revision) {
    fail(`${resource.id} revision is ${sourceGit?.revision || 'unknown'}, expected ${resource.revision}`)
  }
  const destination = resourceLibraryPath(resource)
  const temporary = `${destination}.part-${process.pid}`
  await mkdir(dirname(destination), { recursive: true })
  await rm(temporary, { force: true })
  print(`[offline:sync] archive ${resource.id} <- ${source}`)
  await run('tar', [
    '--create',
    `--file=${temporary}`,
    '--use-compress-program',
    `zstd -T0 -${zstdLevel}`,
    '--numeric-owner',
    '--owner=0',
    '--group=0',
    '--directory',
    source,
    '.',
  ])
  await replacePath(temporary, destination)
  return {
    id: resource.id,
    kind: 'archive',
    path: relative(library, destination),
    ...await describeFile(destination),
    source: sourceGit,
  }
}

async function syncMirror(resource) {
  const source = await resourceSource(resource)
  if (!source) {
    if (resource.required) fail(`Required source is missing for ${resource.id}`)
    print(`[offline:sync] skip optional ${resource.id}: source is missing`)
    return null
  }
  const destination = resourceLibraryPath(resource)
  const temporary = `${destination}.part-${process.pid}`
  await rm(temporary, { recursive: true, force: true })
  await mkdir(temporary, { recursive: true })
  const files = await walkFiles(source, {
    matcher: resource.includePattern ? new RegExp(resource.includePattern) : null,
    recursive: resource.recursive,
  })
  print(`[offline:sync] mirror ${resource.id} (${files.length} files) <- ${source}`)
  for (const relativePath of files) {
    const from = resolve(source, relativePath)
    const to = resolve(temporary, relativePath)
    const metadata = await lstat(from)
    await mkdir(dirname(to), { recursive: true })
    if (metadata.isSymbolicLink()) await symlink(await readlink(from), to)
    else await cp(from, to, { preserveTimestamps: true })
  }
  if (resource.id === 'debian-package-cache' || resource.id === 'freecad-package-cache') {
    const index = spawnSync('dpkg-scanpackages', ['.', '/dev/null'], {
      cwd: temporary,
      encoding: 'utf8',
      maxBuffer: 64 * 1024 * 1024,
    })
    if (index.status !== 0) fail(`dpkg-scanpackages failed for ${resource.id}: ${index.stderr.trim()}`)
    await writeFile(resolve(temporary, 'Packages'), index.stdout)
    await writeFile(resolve(temporary, 'Packages.gz'), gzipSync(index.stdout, { level: 9 }))
  }
  await replacePath(temporary, destination)
  return {
    id: resource.id,
    kind: 'mirror',
    path: relative(library, destination),
    ...await describeTree(destination),
  }
}

function cacheIndexPath(cacheRoot, url) {
  const key = `make-fetch-happen:request-cache:${url}`
  const digest = createHash('sha256').update(key).digest('hex')
  return resolve(cacheRoot, '_cacache/index-v5', digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
}

function cacheContentPath(cacheRoot, integrity) {
  const separator = integrity.indexOf('-')
  const algorithm = integrity.slice(0, separator)
  const digest = Buffer.from(integrity.slice(separator + 1), 'base64').toString('hex')
  return resolve(cacheRoot, '_cacache/content-v2', algorithm, digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
}

function platformMatches(constraints, value) {
  if (!constraints?.length) return true
  const includes = constraints.filter((entry) => !entry.startsWith('!'))
  const excludes = constraints.filter((entry) => entry.startsWith('!')).map((entry) => entry.slice(1))
  return !excludes.includes(value) && (includes.length === 0 || includes.includes(value))
}

function packageMatchesHost(value) {
  return platformMatches(value.os, 'linux')
    && platformMatches(value.cpu, process.arch)
    && platformMatches(value.libc, 'glibc')
}

async function syncNpmCache() {
  const sourceCache = resolve(process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE || resolve(homedir(), '.npm'))
  const destination = resolve(library, 'npm/cache')
  const temporary = `${destination}.part-${process.pid}`
  await rm(temporary, { recursive: true, force: true })
  const urls = new Set()
  for (const lock of config.npmLocks) {
    const path = resolve(root, lock)
    if (!await exists(path)) fail(`npm lock is missing: ${lock}`)
    const document = JSON.parse(await readFile(path, 'utf8'))
    for (const value of Object.values(document.packages || {})) {
      if (value?.resolved?.startsWith('http') && packageMatchesHost(value)) urls.add(value.resolved)
    }
  }
  let copied = 0
  for (const url of [...urls].sort()) {
    const indexSource = cacheIndexPath(sourceCache, url)
    if (!await exists(indexSource)) fail(`npm cache index is missing ${url}`)
    const lines = (await readFile(indexSource, 'utf8')).trim().split(/\r?\n/).filter(Boolean)
    const line = lines.at(-1)
    const entry = JSON.parse(line.slice(line.indexOf('\t') + 1))
    const contentSource = cacheContentPath(sourceCache, entry.integrity)
    if (!await exists(contentSource)) fail(`npm cache content is missing ${url}`)
    for (const source of [indexSource, contentSource]) {
      const path = relative(sourceCache, source)
      const target = resolve(temporary, path)
      await mkdir(dirname(target), { recursive: true })
      await cp(source, target, { preserveTimestamps: true })
    }
    copied += 1
  }
  await replacePath(temporary, destination)
  print(`[offline:sync] npm cache ${copied} locked tarballs`)
  return {
    id: 'npm-cache',
    kind: 'npm-cache',
    path: relative(library, destination),
    packages: copied,
    ...await describeTree(destination),
  }
}

async function syncNodeArchive() {
  const source = resolve(root, config.node.archive)
  if (!await exists(source)) fail(`Pinned Node archive is missing: ${source}`)
  const destination = resolve(library, config.node.libraryPath)
  await mkdir(dirname(destination), { recursive: true })
  await cp(source, destination, { preserveTimestamps: true })
  print(`[offline:sync] node ${config.node.version}`)
  return { id: 'node-runtime', kind: 'file', path: relative(library, destination), ...await describeFile(destination) }
}

async function hostToolVersions() {
  const probes = {
    node: [resolve(root, 'nodew'), ['--version']],
    npm: [resolve(root, 'npmw'), ['--version']],
    cmake: ['cmake', ['--version']],
    ninja: ['ninja', ['--version']],
    emscripten: ['emcc', ['--version']],
    chrome: [process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'), ['--version']],
    firefox: [process.env.FIREFOX_BIN || 'firefox', ['--version']],
  }
  const result = {}
  for (const [name, [program, args]] of Object.entries(probes)) {
    const probe = spawnSync(program, args, { encoding: 'utf8' })
    result[name] = probe.status === 0 ? `${probe.stdout}${probe.stderr}`.trim().split(/\r?\n/)[0] : null
  }
  return result
}

async function browserExecutables() {
  const browserRoot = resolve(library, 'browsers/ms-playwright')
  const directories = (await readdir(browserRoot, { withFileTypes: true }))
    .filter((entry) => entry.isDirectory())
    .map((entry) => entry.name)
    .sort()
    .reverse()
  const select = (prefix) => directories.find((name) => name.startsWith(`${prefix}-`))
  const chromiumDirectory = select('chromium')
  const firefoxDirectory = select('firefox')
  const webkitDirectory = select('webkit')
  const paths = {
    chromium: chromiumDirectory && resolve(browserRoot, chromiumDirectory, 'chrome-linux64/chrome'),
    firefox: firefoxDirectory && resolve(browserRoot, firefoxDirectory, 'firefox/firefox'),
    webkit: webkitDirectory && resolve(browserRoot, webkitDirectory, 'pw_run.sh'),
  }
  for (const [name, path] of Object.entries(paths)) {
    if (!path || !await exists(path)) fail(`Offline ${name} executable is missing from ${browserRoot}`)
  }
  return Object.fromEntries(Object.entries(paths).map(([name, path]) => [name, relative(library, path)]))
}

async function sync() {
  if (platform() !== 'linux') fail(`This resource profile is Linux-specific; received ${platform()}`)
  if (selectedIds.size > 0) fail('Partial sync is intentionally unsupported; synchronize one complete, internally consistent manifest')
  await mkdir(library, { recursive: true })
  const entries = [await syncNodeArchive(), await syncNpmCache()]
  for (const resource of config.archives) entries.push(await createArchive(resource))
  for (const resource of config.mirrors) entries.push(await syncMirror(resource))
  const manifest = {
    schemaVersion: 1,
    project: config.project,
    generatedAt: new Date().toISOString(),
    library,
    configSha256: await sha256(configPath),
    platform: config.platform,
    hostTools: await hostToolVersions(),
    browserExecutables: await browserExecutables(),
    resources: entries.filter(Boolean),
  }
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
  print(`[offline:sync] wrote ${manifestPath}`)
  print({ status: 'pass', library, resources: manifest.resources.length })
}

async function readManifest() {
  if (!await exists(manifestPath)) fail(`Offline manifest is missing: ${manifestPath}. Run offline:sync first.`)
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
  if (manifest.schemaVersion !== 1 || manifest.project !== config.project) fail('Offline manifest identity is invalid')
  return manifest
}

async function check() {
  const manifest = await readManifest()
  if (manifest.configSha256 !== await sha256(configPath)) fail('Offline resource config changed after the library was synchronized')
  const expectedIds = new Set([
    'node-runtime',
    'npm-cache',
    ...config.archives.filter((entry) => entry.required).map((entry) => entry.id),
    ...config.mirrors.filter((entry) => entry.required).map((entry) => entry.id),
  ])
  const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
  for (const id of expectedIds) if (!records.has(id)) fail(`Offline manifest is missing required resource ${id}`)
  for (const record of manifest.resources.filter(isSelected)) {
    const path = resolve(library, record.path)
    if (!await exists(path)) fail(`Offline resource is missing: ${record.id}`)
    const actual = record.kind === 'archive' || record.kind === 'file' ? await describeFile(path) : await describeTree(path)
    if (actual.bytes !== record.bytes || actual.sha256 !== record.sha256 || (record.files != null && actual.files !== record.files)) {
      fail(`Offline resource checksum mismatch: ${record.id}`)
    }
    print(`[offline:check] ${record.id} ${record.sha256.slice(0, 12)}`)
  }
  print({ status: 'pass', library, resources: manifest.resources.length })
}

async function prepareNpmWorkCache(manifest) {
  const npmRecord = manifest.resources.find((entry) => entry.id === 'npm-cache')
  if (!npmRecord) fail('Offline manifest is missing npm-cache')
  const destination = resolve(root, '.cache/offline-npm')
  const marker = resolve(destination, '.resource-sha256')
  const current = await readFile(marker, 'utf8').then((value) => value.trim(), () => '')
  if (current === npmRecord.sha256) return destination
  const temporary = `${destination}.part-${process.pid}`
  await rm(temporary, { recursive: true, force: true })
  await cp(resolve(library, npmRecord.path), temporary, { recursive: true, preserveTimestamps: true })
  await writeFile(resolve(temporary, '.resource-sha256'), `${npmRecord.sha256}\n`)
  await replacePath(temporary, destination)
  return destination
}

async function assertRestoreTarget(targetRoot, destination) {
  const relativePath = relative(targetRoot, destination)
  if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
    fail(`Unsafe restore target: ${destination}`)
  }
  if (await exists(destination) && !flags.has('force')) fail(`Restore target already exists: ${destination}. Use --force to replace it.`)
}

async function extractArchive(resource, record, targetRoot) {
  const destination = resourceRestorePath(resource, targetRoot)
  await assertRestoreTarget(targetRoot, destination)
  const temporary = `${destination}.offline-part-${process.pid}`
  await rm(temporary, { recursive: true, force: true })
  await mkdir(temporary, { recursive: true })
  print(`[offline:restore] ${resource.id} -> ${destination}`)
  await run('tar', [
    '--extract',
    `--file=${resolve(library, record.path)}`,
    '--use-compress-program',
    'zstd -d',
    '--directory',
    temporary,
  ])
  await replacePath(temporary, destination)
}

async function restoreMirror(resource, record, targetRoot) {
  if (!resource.restore) return
  const destination = resourceRestorePath(resource, targetRoot)
  await assertRestoreTarget(targetRoot, destination)
  const temporary = `${destination}.offline-part-${process.pid}`
  await rm(temporary, { recursive: true, force: true })
  await mkdir(dirname(temporary), { recursive: true })
  print(`[offline:restore] ${resource.id} -> ${destination}`)
  await cp(resolve(library, record.path), temporary, { recursive: true, preserveTimestamps: true })
  await replacePath(temporary, destination)
}

async function restore() {
  const manifest = await readManifest()
  const targetRoot = resolve(values.get('target') || root)
  await mkdir(targetRoot, { recursive: true })
  const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
  const resources = [...config.archives, ...config.mirrors.filter((entry) => entry.restore)].filter(isSelected)
  if (selectedIds.size === 0) fail('Restore requires --only=ID,... so replacement scope is explicit')
  for (const resource of resources) {
    const record = records.get(resource.id)
    if (!record) {
      if (resource.required) fail(`Offline manifest is missing ${resource.id}`)
      continue
    }
    if (record.kind === 'archive') await extractArchive(resource, record, targetRoot)
    else await restoreMirror(resource, record, targetRoot)
  }
  if (selectedIds.has('node-runtime')) {
    const record = records.get('node-runtime')
    const destination = resolve(targetRoot, config.node.archive)
    await assertRestoreTarget(targetRoot, destination)
    await mkdir(dirname(destination), { recursive: true })
    await cp(resolve(library, record.path), destination, { preserveTimestamps: true })
  }
  print({ status: 'pass', library, target: targetRoot, restored: [...selectedIds] })
}

async function npmOfflineSmoke() {
  const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-npm-'))
  try {
    for (const file of ['package.json', 'package-lock.json', '.npmrc']) await cp(resolve(root, file), resolve(directory, file))
    const npmCache = resolve(directory, '.npm-cache')
    await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
    const runtimeArchive = resolve(library, config.node.libraryPath)
    await run('tar', ['--extract', `--file=${runtimeArchive}`, '--directory', directory])
    const runtime = resolve(directory, `node-v${config.node.version}-linux-x64`)
    print('[offline:smoke] npm ci --offline in an isolated directory')
    await run(resolve(runtime, 'bin/npm'), [
      'ci',
      '--offline',
      '--ignore-scripts',
      `--cache=${npmCache}`,
    ], {
      cwd: directory,
      env: {
        ...process.env,
        PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
        npm_config_offline: 'true',
        npm_config_audit: 'false',
        npm_config_fund: 'false',
      },
    })
    const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/@bitbybit-dev/occt/package.json'), 'utf8'))
    if (installed.version !== '1.1.1') fail(`Offline npm smoke installed unexpected OCCT ${installed.version}`)
  } finally {
    await rm(directory, { recursive: true, force: true })
  }
}

async function linuxCncNpmOfflineSmoke() {
  const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-linuxcnc-npm-'))
  const source = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web')
  try {
    for (const file of ['package.json', 'package-lock.json']) await cp(resolve(source, file), resolve(directory, file))
    const npmCache = resolve(directory, '.npm-cache')
    await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
    const runtimeDirectory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-node-'))
    try {
      await run('tar', ['--extract', `--file=${resolve(library, config.node.libraryPath)}`, '--directory', runtimeDirectory])
      const runtime = resolve(runtimeDirectory, `node-v${config.node.version}-linux-x64`)
      print('[offline:smoke] LinuxCNC Web npm ci --offline in an isolated directory')
      await run(resolve(runtime, 'bin/npm'), [
        'ci',
        '--offline',
        '--ignore-scripts',
        `--cache=${npmCache}`,
      ], {
        cwd: directory,
        env: {
          ...process.env,
          PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
          npm_config_offline: 'true',
          npm_config_audit: 'false',
          npm_config_fund: 'false',
        },
      })
      const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/playwright/package.json'), 'utf8'))
      if (installed.version !== '1.61.1') fail(`Offline LinuxCNC npm smoke installed unexpected Playwright ${installed.version}`)
    } finally {
      await rm(runtimeDirectory, { recursive: true, force: true })
    }
  } finally {
    await rm(directory, { recursive: true, force: true })
  }
}

async function playwrightOfflineSmoke() {
  const playwrightPath = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web/node_modules/playwright/index.mjs')
  const executables = await browserExecutables()
  const absoluteExecutables = Object.fromEntries(Object.entries(executables).map(([name, path]) => [name, resolve(library, path)]))
  const script = [
    `import { chromium, firefox, webkit } from ${JSON.stringify(new URL(`file://${playwrightPath}`).href)}`,
    `for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {`,
    `  const executablePath = ${JSON.stringify(absoluteExecutables)}[name]`,
    `  const browser = await type.launch({ headless: true, executablePath })`,
    `  const page = await browser.newPage()`,
    `  await page.setContent('<title>offline</title>')`,
    `  if (await page.title() !== 'offline') throw new Error(name + ' page smoke failed')`,
    `  console.log('[offline:smoke] browser ' + name + ' pass')`,
    `  await browser.close()`,
    `}`,
  ].join('\n')
  await run(resolve(root, 'nodew'), ['--input-type=module', '--eval', script], {
    env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright') },
  })
}

async function smoke() {
  await check()
  await npmOfflineSmoke()
  await linuxCncNpmOfflineSmoke()
  await playwrightOfflineSmoke()
  const environment = {
    ...process.env,
    WEB_FREECAD_RESOURCE_LIBRARY: library,
    WEB_FREECAD_OFFLINE: '1',
    npm_config_cache: await prepareNpmWorkCache(await readManifest()),
    npm_config_offline: 'true',
    PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright'),
    FREECAD_SOURCE_OFFLINE: '1',
    OCCT_SOURCE_DIR: resolve(root, '.cache/occt/occt'),
  }
  for (const script of ['check:runtime', 'check:freecad-source', 'build:freecad-naming-source-probe', 'test:freecad-naming-source-probe', 'check:freecad-naming-sdk-readiness', 'check:freecad-wasm-sdk-build-plan', 'check:freecad-naming-next-tasks', 'publish:occt-history', 'check:freecad-naming-production', 'check:freecad-isomorphic-provenance', 'check:freecad-native-property-semantics', 'check:freecad-private-naming-boundary', 'check:occt-history-artifact', 'test:occt-history', 'test:planegcs', 'build']) {
    print(`[offline:smoke] npm run ${script}`)
    await run(resolve(root, 'npmw'), ['run', script], { env: environment })
  }
  print({ status: 'pass', library, networkFallback: false })
}

function shellQuote(value) {
  return `'${String(value).replaceAll("'", "'\\''")}'`
}

async function environment() {
  const manifest = await readManifest()
  const npmCache = await prepareNpmWorkCache(manifest)
  const firefox = resolve(library, manifest.browserExecutables.firefox)
  const webkit = resolve(library, manifest.browserExecutables.webkit)
  const lines = [
    `export WEB_FREECAD_RESOURCE_LIBRARY=${shellQuote(library)}`,
    'export WEB_FREECAD_OFFLINE=1',
    `export npm_config_cache=${shellQuote(npmCache)}`,
    'export npm_config_offline=true',
    `export PLAYWRIGHT_BROWSERS_PATH=${shellQuote(resolve(library, 'browsers/ms-playwright'))}`,
    'export FREECAD_SOURCE_OFFLINE=1',
    `export OCCT_SOURCE_DIR=${shellQuote(resolve(root, '.cache/occt/occt'))}`,
    `export CHROME_BIN=${shellQuote(process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'))}`,
    `export FIREFOX_BIN=${shellQuote(firefox)}`,
    `export WEBKIT_BIN=${shellQuote(webkit)}`,
    'export CI_REAL_OFFLINE=1',
  ]
  print(lines.join('\n'))
}

async function inventory() {
  const resources = []
  for (const resource of [...config.archives, ...config.mirrors]) {
    const source = await resourceSource(resource)
    resources.push({ id: resource.id, source, present: Boolean(source), required: resource.required })
  }
  print({ library, config: relative(root, configPath), resources, hostTools: await hostToolVersions() })
}

if (command === 'sync') await sync()
else if (command === 'check') await check()
else if (command === 'restore') await restore()
else if (command === 'smoke') await smoke()
else if (command === 'env') await environment()
else if (command === 'inventory') await inventory()
else {
  print('Usage: node scripts/offline-resource-lib.mjs <inventory|sync|check|restore|smoke|env> [--library=PATH] [--only=ID,...] [--target=PATH] [--force]')
  if (command !== 'help') process.exitCode = 2
}
