import { constants } from 'node:fs'
import {
  access,
  copyFile,
  cp,
  lstat,
  mkdir,
  mkdtemp,
  open,
  readdir,
  readFile,
  readlink,
  realpath,
  rename,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parseStrictSemVer } from './release-metadata.mjs'

const ARCHITECTURES = {
  amd64: {
    electronArch: 'x64',
    elfMachine: 62,
  },
  arm64: {
    electronArch: 'arm64',
    elfMachine: 183,
  },
}

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

function assertSupportedArchitecture(arch) {
  const architecture = ARCHITECTURES[arch]
  if (!architecture) {
    throw new Error(
      `Unsupported Snap architecture "${arch}"; expected amd64 or arm64`
    )
  }
  return architecture
}

function assertVersion(version) {
  parseStrictSemVer(version, 'Snap version')
  if (version.length > 32) throw new Error('Snap version exceeds 32 characters')
}

async function assertRegularFile(filePath, label) {
  const info = await stat(filePath).catch(() => null)
  if (!info?.isFile()) {
    throw new Error(`${label} is missing or is not a regular file: ${filePath}`)
  }
  return info
}

async function readElfArchitecture(filePath) {
  const file = await open(filePath, 'r')
  try {
    const header = Buffer.alloc(20)
    const { bytesRead } = await file.read(header, 0, header.length, 0)
    if (
      bytesRead !== header.length ||
      !header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
      header[4] !== 2
    ) {
      return null
    }

    if (header[5] === 1) return header.readUInt16LE(18)
    if (header[5] === 2) return header.readUInt16BE(18)
    return null
  } finally {
    await file.close()
  }
}

async function verifyElfExecutable(filePath, label, elfMachine) {
  const info = await assertRegularFile(filePath, label)
  if ((info.mode & 0o111) === 0) {
    throw new Error(`${label} is not executable: ${filePath}`)
  }
  if ((await readElfArchitecture(filePath)) !== elfMachine) {
    throw new Error(`${label} is not an ELF64 binary for the requested arch`)
  }
  await access(filePath, constants.X_OK)
}

async function assertContainedSymlinks(root) {
  const canonicalRoot = await realpath(root)

  async function walk(directory) {
    const entries = await readdir(directory, { withFileTypes: true })
    for (const entry of entries) {
      const entryPath = path.join(directory, entry.name)
      if (entry.isSymbolicLink()) {
        const link = await readlink(entryPath)
        const target = await realpath(entryPath).catch(() => null)
        if (
          path.isAbsolute(link) ||
          !target ||
          (target !== canonicalRoot && !isInside(canonicalRoot, target))
        ) {
          throw new Error(
            `App directory contains an unsafe symlink: ${entryPath}`
          )
        }
      } else if (entry.isDirectory()) {
        await walk(entryPath)
      }
    }
  }

  await walk(canonicalRoot)
}

function renderTemplate(template, { arch, version }) {
  const rendered = template
    .replaceAll('@MOTRIX_VERSION@', version)
    .replaceAll('@SNAP_ARCH@', arch)
  if (/@[A-Z][A-Z0-9_]*@/.test(rendered)) {
    throw new Error('Snapcraft template contains an unresolved placeholder')
  }
  return rendered
}

export async function prepareSnapProject({
  projectDir,
  appDir,
  outputDir,
  arch,
  version,
}) {
  const architecture = assertSupportedArchitecture(arch)
  assertVersion(version)

  const canonicalProject = await realpath(projectDir)
  const releaseRoot = path.join(canonicalProject, 'release')
  const requestedOutputDir = path.resolve(outputDir)
  const resolvedAppDir = await realpath(path.resolve(appDir)).catch(() => null)
  const outputParent = await realpath(path.dirname(requestedOutputDir)).catch(
    () => null
  )
  const resolvedOutputDir = outputParent
    ? path.join(outputParent, path.basename(requestedOutputDir))
    : null

  if (!resolvedAppDir || !isInside(releaseRoot, resolvedAppDir)) {
    throw new Error(`App directory must be inside ${releaseRoot}`)
  }
  if (!resolvedOutputDir || !isInside(releaseRoot, resolvedOutputDir)) {
    throw new Error(`Output directory must be inside ${releaseRoot}`)
  }
  if (
    resolvedAppDir === resolvedOutputDir ||
    isInside(resolvedAppDir, resolvedOutputDir) ||
    isInside(resolvedOutputDir, resolvedAppDir)
  ) {
    throw new Error('App and output directories must not overlap')
  }

  const canonicalAppDir = resolvedAppDir
  const appInfo = await stat(canonicalAppDir).catch(() => null)
  if (!appInfo?.isDirectory()) {
    throw new Error(`App directory is missing or unsafe: ${resolvedAppDir}`)
  }

  await assertContainedSymlinks(canonicalAppDir)

  const binaries = [
    {
      path: path.join(canonicalAppDir, 'motrix'),
      label: 'Motrix executable',
    },
    {
      path: path.join(
        canonicalAppDir,
        'resources',
        'bin',
        'motrix-native-host'
      ),
      label: 'native-host executable',
    },
    {
      path: path.join(
        canonicalAppDir,
        'resources',
        'extra',
        'linux',
        architecture.electronArch,
        'aria2c'
      ),
      label: 'aria2 executable',
    },
  ]
  for (const binary of binaries) {
    await verifyElfExecutable(
      binary.path,
      binary.label,
      architecture.elfMachine
    )
  }

  const templatePath = path.join(
    canonicalProject,
    'build',
    'snap',
    'snapcraft.yaml.in'
  )
  const desktopPath = path.join(
    canonicalProject,
    'build',
    'snap',
    'gui',
    'motrix.desktop'
  )
  const iconPath = path.join(canonicalProject, 'build', '256x256.png')
  await Promise.all([
    assertRegularFile(templatePath, 'Snapcraft template'),
    assertRegularFile(desktopPath, 'Snap desktop file'),
    assertRegularFile(iconPath, 'Snap icon'),
  ])

  await mkdir(releaseRoot, { recursive: true })
  const stagingDir = await mkdtemp(path.join(releaseRoot, '.snap-project-'))
  try {
    const stagedApp = path.join(stagingDir, 'app')
    const stagedGui = path.join(stagingDir, 'snap', 'gui')
    await cp(canonicalAppDir, stagedApp, {
      recursive: true,
      preserveTimestamps: true,
      verbatimSymlinks: true,
    })
    await Promise.all([
      rm(path.join(stagedApp, 'chrome-sandbox'), { force: true }),
      rm(path.join(stagedApp, 'resources', 'app-update.yml'), { force: true }),
    ])

    await mkdir(stagedGui, { recursive: true })
    const template = await readFile(templatePath, 'utf8')
    await Promise.all([
      writeFile(
        path.join(stagingDir, 'snap', 'snapcraft.yaml'),
        renderTemplate(template, { arch, version }),
        { encoding: 'utf8', mode: 0o644 }
      ),
      copyFile(desktopPath, path.join(stagedGui, 'motrix.desktop')),
      copyFile(iconPath, path.join(stagedGui, 'icon.png')),
    ])

    for (const binary of binaries) {
      const relative = path.relative(canonicalAppDir, binary.path)
      await verifyElfExecutable(
        path.join(stagedApp, relative),
        `staged ${binary.label}`,
        architecture.elfMachine
      )
    }
    if (await lstat(path.join(stagedApp, 'chrome-sandbox')).catch(() => null)) {
      throw new Error('chrome-sandbox must not be staged in the Snap project')
    }

    await rm(resolvedOutputDir, { recursive: true, force: true })
    await rename(stagingDir, resolvedOutputDir)
  } catch (error) {
    await rm(stagingDir, { recursive: true, force: true })
    throw error
  }

  return {
    arch,
    electronArch: architecture.electronArch,
    outputDir: requestedOutputDir,
    version,
  }
}

function parseArguments(argv) {
  const values = new Map()
  for (let index = 0; index < argv.length; index += 2) {
    const option = argv[index]
    const value = argv[index + 1]
    if (
      !['--arch', '--version', '--app-dir', '--output-dir'].includes(option) ||
      value === undefined ||
      value.startsWith('--')
    ) {
      throw new Error(`Invalid argument near "${option ?? ''}"`)
    }
    if (values.has(option)) throw new Error(`Duplicate argument: ${option}`)
    values.set(option, value)
  }
  return values
}

async function main() {
  const args = parseArguments(process.argv.slice(2))
  const projectDir = fileURLToPath(new URL('..', import.meta.url))
  for (const option of ['--arch', '--version', '--app-dir', '--output-dir']) {
    if (!args.has(option))
      throw new Error(`Missing required argument: ${option}`)
  }
  const result = await prepareSnapProject({
    projectDir,
    appDir: args.get('--app-dir'),
    outputDir: args.get('--output-dir'),
    arch: args.get('--arch'),
    version: args.get('--version'),
  })
  console.log(
    `Prepared ${result.arch} Snapcraft project at ${result.outputDir}`
  )
}

if (
  process.argv[1] &&
  import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
  await main()
}