#!/usr/bin/env node

import { createHash } from 'node:crypto'
import { lstatSync, opendirSync, readFileSync, realpathSync } from 'node:fs'
import { basename, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'

export const PACKET_PROFILE = 'bulla.complete-effect-mediation-packet/0.1-experimental'
const HASH = /^sha256:[0-9a-f]{64}$/u
const PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u
const MAX_MEMBERS = 128
const MAX_MEMBER_BYTES = 1024 * 1024
const MAX_TOTAL_BYTES = 8 * 1024 * 1024
const MAX_DEPTH = 12

function assertNoLoneSurrogates(value) {
  for (let index = 0; index < value.length; index += 1) {
    const code = value.charCodeAt(index)
    if (code >= 0xd800 && code <= 0xdbff) {
      const next = value.charCodeAt(index + 1)
      if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('canonical JSON rejects lone high surrogates')
      index += 1
    } else if (code >= 0xdc00 && code <= 0xdfff) throw new Error('canonical JSON rejects lone low surrogates')
  }
}

export function canonical(value) {
  if (value === null || typeof value === 'boolean') return JSON.stringify(value)
  if (typeof value === 'string') { assertNoLoneSurrogates(value); return JSON.stringify(value) }
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value)) throw new Error('canonical JSON permits safe integers only')
    return JSON.stringify(value)
  }
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
  if (typeof value !== 'object') throw new Error('canonical JSON received an unsupported value')
  const keys = Object.keys(value).sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
  return `{${keys.map((key) => { assertNoLoneSurrogates(key); return `${JSON.stringify(key)}:${canonical(value[key])}` }).join(',')}}`
}

export function sha256(raw) {
  return `sha256:${createHash('sha256').update(raw).digest('hex')}`
}

export function exact(value, fields, label) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
  const actual = Object.keys(value).sort()
  const expected = [...fields].sort()
  if (canonical(actual) !== canonical(expected)) throw new Error(`${label} does not have its exact fields`)
  return value
}

export function parseJsonStrict(raw, label = 'JSON input') {
  let source
  try { source = new TextDecoder('utf-8', { fatal: true }).decode(raw) } catch { throw new Error(`${label} is not UTF-8`) }
  let offset = 0
  let nodes = 0
  const whitespace = () => { while (' \n\r\t'.includes(source[offset] ?? '\0')) offset += 1 }
  const fail = (message) => { throw new Error(`${label}: ${message} at byte ${offset}`) }
  const parseString = () => {
    if (source[offset] !== '"') fail('expected string')
    const start = offset++
    while (offset < source.length) {
      const character = source[offset++]
      if (character === '"') {
        let value
        try { value = JSON.parse(source.slice(start, offset)) } catch { fail('invalid string') }
        if (value.length > 16_384) fail('string exceeds limit')
        assertNoLoneSurrogates(value)
        return value
      }
      if (character === '\\') {
        const escape = source[offset++]
        if (escape === 'u') {
          if (!/^[0-9a-fA-F]{4}$/u.test(source.slice(offset, offset + 4))) fail('invalid Unicode escape')
          offset += 4
        } else if (!'"\\/bfnrt'.includes(escape ?? '')) fail('invalid escape')
      } else if (character === undefined || character.charCodeAt(0) < 0x20) fail('unescaped control')
    }
    fail('unterminated string')
  }
  const value = (depth = 0) => {
    whitespace()
    nodes += 1
    if (nodes > 20_000 || depth > 32) fail('resource limit exceeded')
    if (source[offset] === '{') {
      offset += 1
      whitespace()
      const result = Object.create(null)
      const keys = new Set()
      if (source[offset] === '}') { offset += 1; return result }
      while (true) {
        const key = parseString()
        if (keys.has(key)) fail(`duplicate member ${JSON.stringify(key)}`)
        keys.add(key)
        whitespace()
        if (source[offset++] !== ':') fail("expected ':'")
        result[key] = value(depth + 1)
        whitespace()
        if (source[offset] === '}') { offset += 1; return result }
        if (source[offset++] !== ',') fail("expected ','")
        whitespace()
      }
    }
    if (source[offset] === '[') {
      offset += 1
      whitespace()
      const result = []
      if (source[offset] === ']') { offset += 1; return result }
      while (true) {
        result.push(value(depth + 1))
        whitespace()
        if (source[offset] === ']') { offset += 1; return result }
        if (source[offset++] !== ',') fail("expected ','")
      }
    }
    if (source[offset] === '"') return parseString()
    for (const [token, parsed] of [['true', true], ['false', false], ['null', null]]) {
      if (source.slice(offset, offset + token.length) === token) { offset += token.length; return parsed }
    }
    const token = /^-?(?:0|[1-9]\d*)/u.exec(source.slice(offset))?.[0]
    if (token === undefined) fail('invalid value')
    const next = source[offset + token.length]
    if (next === '.' || next === 'e' || next === 'E') fail('floats are not permitted')
    offset += token.length
    const number = Number(token)
    if (!Number.isSafeInteger(number)) fail('unsafe integer')
    return number
  }
  const parsed = value()
  whitespace()
  if (offset !== source.length) fail('trailing data')
  return parsed
}

function safePath(path) {
  if (typeof path !== 'string' || path.startsWith('/') || path.includes('\\') || !PATH.test(path)) return false
  const parts = path.split('/')
  return parts.length <= MAX_DEPTH && parts.every((part) => part !== '' && part !== '.' && part !== '..')
}

function walk(root, expected) {
  const rootReal = realpathSync(root)
  const rootStat = lstatSync(root)
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('packet root must be a real directory')
  const expectedDirectories = new Set()
  for (const member of expected) {
    const parts = member.path.split('/')
    for (let index = 1; index < parts.length; index += 1) expectedDirectories.add(parts.slice(0, index).join('/'))
  }
  const members = []
  let total = 0
  let entries = 0
  const visit = (directory, prefix, depth) => {
    if (depth > MAX_DEPTH) throw new Error('packet depth exceeds limit')
    const handle = opendirSync(directory)
    for (let entry = handle.readSync(); entry !== null; entry = handle.readSync()) {
      entries += 1
      if (entries > MAX_MEMBERS * (MAX_DEPTH + 1)) throw new Error('packet filesystem entry count exceeds limit')
      const absolute = join(directory, entry.name)
      const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`
      const stat = lstatSync(absolute)
      if (stat.isSymbolicLink()) throw new Error('packet contains a symlink')
      const found = realpathSync(absolute)
      if (found !== rootReal && !found.startsWith(`${rootReal}${sep}`)) throw new Error('packet member escapes root')
      if (stat.isDirectory()) {
        if (!expectedDirectories.has(path)) throw new Error(`packet contains an undeclared directory: ${path}`)
        visit(absolute, path, depth + 1)
      }
      else if (stat.isFile()) {
        if (path === 'manifest.json') continue
        if (!safePath(path)) throw new Error(`unsafe packet path: ${path}`)
        if (stat.size > MAX_MEMBER_BYTES) throw new Error(`oversized packet member: ${path}`)
        total += stat.size
        if (total > MAX_TOTAL_BYTES) throw new Error('packet aggregate exceeds limit')
        members.push({ path, absolute, byte_length: stat.size })
        if (members.length > MAX_MEMBERS) throw new Error('packet member count exceeds limit')
      } else throw new Error('packet contains a non-regular member')
    }
    handle.closeSync()
  }
  visit(root, '', 0)
  return members.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
}

export function verifyPacket(packetRoot) {
  const root = resolve(packetRoot)
  const manifestPath = join(root, 'manifest.json')
  const stat = lstatSync(manifestPath)
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_MEMBER_BYTES) throw new Error('manifest is not a bounded regular file')
  const raw = readFileSync(manifestPath)
  const manifest = exact(parseJsonStrict(raw, 'manifest'), ['profile', 'root', 'members'], 'manifest')
  if (manifest.profile !== PACKET_PROFILE || !HASH.test(manifest.root) || !Array.isArray(manifest.members)) throw new Error('unsupported manifest')
  const expected = manifest.members.map((member, index) => {
    exact(member, ['path', 'byte_length', 'sha256'], `manifest member ${index}`)
    if (!safePath(member.path) || !Number.isSafeInteger(member.byte_length) || member.byte_length < 0 || member.byte_length > MAX_MEMBER_BYTES || !HASH.test(member.sha256)) throw new Error(`invalid manifest member ${index}`)
    if (index > 0 && manifest.members[index - 1].path >= member.path) throw new Error('manifest members are not ASCII ordered and unique')
    return member
  })
  if (expected.length > MAX_MEMBERS) throw new Error('manifest member count exceeds limit')
  const presentation = { profile: manifest.profile, root: manifest.root, members: expected.map(({ path, byte_length, sha256 }) => ({ path, byte_length, sha256 })) }
  if (!raw.equals(Buffer.from(`${JSON.stringify(presentation, null, 2)}\n`))) throw new Error('manifest is not in exact presentation')
  const actual = walk(root, expected).map((member) => {
    const bytes = readFileSync(member.absolute)
    if (member.path.endsWith('.json')) parseJsonStrict(bytes, member.path)
    return { path: member.path, byte_length: bytes.length, sha256: sha256(bytes) }
  })
  if (canonical(actual) !== canonical(expected)) throw new Error('packet members do not match manifest')
  const computed = sha256(Buffer.from(canonical({ profile: manifest.profile, members: expected })))
  if (computed !== manifest.root) throw new Error('packet root does not recompute')
  if (basename(root) !== computed.slice(7)) throw new Error('packet directory is not named by its root')
  return { root, manifest }
}

export async function main(argv = process.argv.slice(2)) {
  if (argv.length !== 1) throw new Error('usage: node verify-packet.mjs PACKET_ROOT')
  const result = verifyPacket(argv[0])
  process.stdout.write(`${result.manifest.root}\n`)
}

if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]))) {
  main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1 })
}
