import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'

const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
const finite = (value) => typeof value === 'number' && Number.isFinite(value)
const positive = (value) => finite(value) && value > 0

function validatePlacement(placement, label) {
  if (placement === undefined) return
  if (!placement || typeof placement !== 'object' || Array.isArray(placement)) throw new TypeError(`${label}.placement must be an object.`)
  if (placement.translation !== undefined && (!Array.isArray(placement.translation) || placement.translation.length !== 3 || !placement.translation.every(finite))) throw new TypeError(`${label}.placement.translation must contain three finite numbers.`)
  if (placement.rotation !== undefined) {
    if (!placement.rotation || typeof placement.rotation !== 'object' || Array.isArray(placement.rotation)) throw new TypeError(`${label}.placement.rotation must be an object.`)
    if (!Array.isArray(placement.rotation.axis) || placement.rotation.axis.length !== 3 || !placement.rotation.axis.every(finite) || Math.hypot(...placement.rotation.axis) === 0) throw new TypeError(`${label}.placement.rotation.axis must be a non-zero vector.`)
    if (!finite(placement.rotation.angle)) throw new TypeError(`${label}.placement.rotation.angle must be finite.`)
  }
}

export function validateGoldenOperation(operation, label = 'operation') {
  if (!operation || typeof operation !== 'object' || Array.isArray(operation) || typeof operation.type !== 'string') throw new TypeError(`${label} must have a type.`)
  validatePlacement(operation.placement, label)
  if (operation.type === 'box') {
    if (![operation.length, operation.width, operation.height].every(positive)) throw new RangeError(`${label} box dimensions must be positive.`)
  } else if (operation.type === 'cylinder') {
    if (!positive(operation.radius) || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cylinder parameters are invalid.`)
  } else if (operation.type === 'sphere') {
    if (!positive(operation.radius)) throw new RangeError(`${label} sphere radius must be positive.`)
  } else if (operation.type === 'cone') {
    if (!finite(operation.radius1) || operation.radius1 < 0 || !finite(operation.radius2) || operation.radius2 < 0 || operation.radius1 + operation.radius2 <= 0 || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cone parameters are invalid.`)
  } else if (operation.type === 'ellipsoid') {
    if (!positive(operation.radius1) || !positive(operation.radius2) || !finite(operation.radius3) || operation.radius3 < 0 || !finite(operation.angle1) || !finite(operation.angle2) || operation.angle1 < -90 || operation.angle2 > 90 || operation.angle1 >= operation.angle2 || !positive(operation.angle3) || operation.angle3 > 360) throw new RangeError(`${label} ellipsoid parameters are invalid.`)
  } else if (operation.type === 'torus') {
    if (!positive(operation.radius1) || !positive(operation.radius2) || operation.radius2 >= operation.radius1 || !finite(operation.angle1) || !finite(operation.angle2) || operation.angle1 < -180 || operation.angle2 > 180 || operation.angle1 >= operation.angle2 || !positive(operation.angle3) || operation.angle3 > 360) throw new RangeError(`${label} torus parameters are invalid.`)
  } else if (operation.type === 'prism') {
    if (!Number.isInteger(operation.polygon) || operation.polygon < 3 || operation.polygon > 10000 || !positive(operation.circumradius) || !positive(operation.height) || !finite(operation.firstAngle) || !finite(operation.secondAngle) || Math.abs(operation.firstAngle) >= 90 || Math.abs(operation.secondAngle) >= 90) throw new RangeError(`${label} prism parameters are invalid.`)
  } else if (operation.type === 'wedge') {
    const values = ['xmin', 'ymin', 'zmin', 'z2min', 'x2min', 'xmax', 'ymax', 'zmax', 'z2max', 'x2max'].map((key) => operation[key])
    if (!values.every(finite) || operation.xmax <= operation.xmin || operation.ymax <= operation.ymin || operation.zmax <= operation.zmin || operation.z2max < operation.z2min || operation.x2max < operation.x2min) throw new RangeError(`${label} wedge parameters are invalid.`)
  } else if (operation.type === 'helix') {
    if (!positive(operation.pitch) || !positive(operation.height) || !positive(operation.radius) || !finite(operation.angle) || Math.abs(operation.angle) >= 90 || !finite(operation.segmentLength) || operation.segmentLength < 0) throw new RangeError(`${label} helix parameters are invalid.`)
  } else if (operation.type === 'cut' || operation.type === 'fuse' || operation.type === 'common') {
    validateGoldenOperation(operation.base, `${label}.base`)
    validateGoldenOperation(operation.tool, `${label}.tool`)
  } else throw new RangeError(`${label} uses unsupported operation type ${operation.type}.`)
}

export function validateGoldenScenario(scenario, expectedId) {
  if (!scenario || scenario.schemaVersion !== 1) throw new TypeError(`${expectedId || 'Scenario'} must use schemaVersion 1.`)
  if (typeof scenario.id !== 'string' || !scenario.id || (expectedId && scenario.id !== expectedId)) throw new RangeError(`Scenario ID does not match ${expectedId}.`)
  if (typeof scenario.category !== 'string' || !scenario.category || typeof scenario.description !== 'string' || !scenario.description) throw new TypeError(`${scenario.id} requires category and description.`)
  validateGoldenOperation(scenario.operation, scenario.id)
  if (!scenario.tolerance || !positive(scenario.tolerance.linear) || !positive(scenario.tolerance.scalar)) throw new RangeError(`${scenario.id} requires positive linear and scalar tolerances.`)
  if (!scenario.expected || typeof scenario.expected !== 'object' || Array.isArray(scenario.expected)) throw new TypeError(`${scenario.id} requires expected output.`)
  const allowedShapeTypes = new Set(['Solid', 'Compound', 'Shell', 'Face', 'Wire', 'Edge'])
  const solidExpected = scenario.expected.shapeType === 'Solid' || scenario.expected.shapeType === 'Compound'
  if (scenario.expected.isNull !== false || scenario.expected.isValid !== true || !allowedShapeTypes.has(scenario.expected.shapeType) || !Number.isInteger(scenario.expected.solids) || (solidExpected ? scenario.expected.solids < 1 : scenario.expected.solids !== 0)) {
    throw new RangeError(`${scenario.id} must declare a valid non-null oracle with a shape-compatible Solid count.`)
  }
}

export async function loadGoldenManifest(manifestFile) {
  const absoluteManifest = resolve(manifestFile)
  const manifest = await readJson(absoluteManifest)
  if (manifest.schemaVersion !== 1 || typeof manifest.baselineId !== 'string' || !manifest.baselineId) throw new TypeError('Golden manifest must use schemaVersion 1 and declare baselineId.')
  if (!Array.isArray(manifest.scenarios) || manifest.scenarios.length === 0) throw new RangeError('Golden manifest requires at least one scenario.')
  const ids = new Set()
  const scenarios = []
  for (const entry of manifest.scenarios) {
    if (!entry || typeof entry.id !== 'string' || !entry.id || ids.has(entry.id) || typeof entry.file !== 'string' || !entry.file) throw new RangeError('Golden manifest scenario IDs must be non-empty and unique.')
    ids.add(entry.id)
    const file = resolve(dirname(absoluteManifest), entry.file)
    const scenario = await readJson(file)
    validateGoldenScenario(scenario, entry.id)
    scenarios.push({ ...entry, file, scenario })
  }
  return { ...manifest, file: absoluteManifest, scenarios }
}

const toleranceForPath = (scenario, path) => path.includes('boundingBox') ? scenario.tolerance.linear : scenario.tolerance.scalar

export function compareGoldenResult(scenario, actual) {
  const differences = []
  const compare = (expected, received, path) => {
    if (typeof expected === 'number') {
      if (!finite(received)) differences.push(`${path}: expected finite ${expected}, received ${String(received)}`)
      else {
        const tolerance = toleranceForPath(scenario, path)
        if (Math.abs(expected - received) > tolerance) differences.push(`${path}: expected ${expected} +/- ${tolerance}, received ${received}`)
      }
      return
    }
    if (Array.isArray(expected)) {
      if (!Array.isArray(received) || received.length !== expected.length) differences.push(`${path}: expected an array of length ${expected.length}`)
      else expected.forEach((value, index) => compare(value, received[index], `${path}[${index}]`))
      return
    }
    if (expected && typeof expected === 'object') {
      if (!received || typeof received !== 'object' || Array.isArray(received)) differences.push(`${path}: expected an object`)
      else for (const [key, value] of Object.entries(expected)) compare(value, received[key], `${path}.${key}`)
      return
    }
    if (received !== expected) differences.push(`${path}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`)
  }
  compare(scenario.expected, actual, scenario.id)
  return differences
}
