import { migrateElementMap2Schema, validateElementMap2 } from './elementMap2'
import { assertNativeNamingEvidence, cloneNativeNamingEvidence, type NativeMappedNameRef, type NativeStageNamingEvidence } from './nativeNamingEvidence'
import { migrateStringHasherSchema, validateElementMap2StringHasherEvidence, validateStringHasherTable } from './stringHasher'
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from './nativeHistoryProvider'

export const FREECAD_PRIVATE_NAMING_ABI_VERSION = 1 as const
export const FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES = 16 * 1024 * 1024
export const FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES = 32 * 1024 * 1024

export type FreeCadPrivateNamingAbiDescriptor = {
  schemaVersion: 1
  freecadVersion: string
  sourceCommit: string
  mappedNameRef: true
  stringHasher: true
  elementMap2: true
  operations: NativeOcctHistoryOperation[]
  maxRequestBytes?: number
  maxResponseBytes?: number
}

export type FreeCadPrivateNamingAbiProbe = {
  availability: 'available' | 'unavailable'
  abiVersion: number | null
  descriptor?: FreeCadPrivateNamingAbiDescriptor
  reason?: string
}

/** Embind surface implemented only by a FreeCAD-linked native module. */
export type NativeFreeCadNamingAbiModule = {
  freecadNamingAbiVersion?(): number
  freecadNamingCapabilitiesJson?(): string
  freecadNamingEvidenceJson?(requestJson: string): string
}

type FreeCadPrivateNamingAbiError = {
  schemaVersion: 1
  status: 'error'
  error: string
}

export type FreeCadPrivateNamingAbiRequest = {
  schemaVersion: 1
  requestId: string
  documentId: string
  documentVersion: number
  operationId: string
  operation: NativeOcctHistoryOperation
  stageId: string
  resultObjectId: string
  resultObjectTag?: number
  inputs: Array<{ inputId: string; objectId?: string; role?: string; stageId?: string; step: string; objectTag?: number; namingEvidence?: NativeStageNamingEvidence }>
  stages: Array<{ stageId: string; operation?: NativeOcctHistoryOperation; inputIds: string[]; resultStageId?: string; ordinal: number }>
  resultStep?: string
  resultBrep?: string
  resultStepByStage?: Record<string, string>
  /** Results with more than one native source stay explicit and never enter the private-name builder. */
  historyAmbiguities?: FreeCadPrivateNamingHistoryAmbiguity[]
  history: Pick<NativeOcctHistoryResponse, 'provider' | 'occtVersion' | 'records' | 'hasModified' | 'hasGenerated' | 'hasDeleted' | 'resultStep' | 'resultBrep'>
}

export type FreeCadPrivateNamingHistoryCandidate = {
  inputId: string
  objectId: string
  persistentId: string
  stageId?: string
  relation: 'modified' | 'generated'
  sourceKind: 'face' | 'edge' | 'vertex'
  sourceIndex: number
}

export type FreeCadPrivateNamingHistoryAmbiguity = {
  resultKind: 'face' | 'edge' | 'vertex'
  resultIndex: number
  candidates: FreeCadPrivateNamingHistoryCandidate[]
}

export type FreeCadPrivateNamingHistoryClassification = {
  records: NativeOcctHistoryResponse['records']
  ambiguities: FreeCadPrivateNamingHistoryAmbiguity[]
  externalRecordCount: number
  internalRecordCount: number
  duplicateRecordCount: number
  relationConflictCount: number
}

const encoder = new TextEncoder()
const lockedCommit = '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d'
const supportedOperations = new Set<NativeOcctHistoryOperation>(['fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe', 'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness', 'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform'])

const boundedLimit = (value: unknown, maximum: number) => Number.isSafeInteger(value) && (value as number) > 0 ? Math.min(value as number, maximum) : maximum

type FreeCadPrivateNamingInputIdentity = Pick<FreeCadPrivateNamingAbiRequest['inputs'][number], 'inputId' | 'role'>
type FreeCadPrivateNamingInput = FreeCadPrivateNamingAbiRequest['inputs'][number]

const namingKinds = new Set(['face', 'edge', 'vertex'])
const titleKind = (kind: FreeCadPrivateNamingHistoryAmbiguity['resultKind']) => `${kind[0].toUpperCase()}${kind.slice(1)}`

const effectiveSource = (record: NativeOcctHistoryResponse['records'][number]) => record.sourceId ?? record.source

const resolveNamingInput = (record: NativeOcctHistoryResponse['records'][number], inputs: FreeCadPrivateNamingInput[]): FreeCadPrivateNamingInput | undefined => {
  const source = effectiveSource(record)
  return inputs.find((input) => input.inputId === source)
    ?? inputs.find((input) => input.role === source)
    ?? (source === 'object' ? inputs[0] : undefined)
    ?? (source === 'tool' ? inputs[1] : undefined)
}

const sourceCandidates = (record: NativeOcctHistoryResponse['records'][number], input: FreeCadPrivateNamingInput, kind: FreeCadPrivateNamingHistoryCandidate['sourceKind']): FreeCadPrivateNamingHistoryCandidate[] => {
  const prior = input.namingEvidence?.mappedNames?.find((mapped) => mapped.kind === kind && mapped.resultIndex === record.sourceIndex)
  if (prior?.candidates && prior.candidates.length > 1) return prior.candidates.map((candidate) => ({
    inputId: input.inputId,
    objectId: candidate.objectId,
    persistentId: candidate.persistentId,
    ...(candidate.stageId ? { stageId: candidate.stageId } : {}),
    relation: record.relation as 'modified' | 'generated',
    sourceKind: kind,
    sourceIndex: record.sourceIndex,
  }))
  return [{
    inputId: input.inputId,
    objectId: input.objectId ?? input.inputId,
    persistentId: prior?.resultPersistentId ?? `${titleKind(kind)}${record.sourceIndex + 1}`,
    ...(input.stageId ? { stageId: input.stageId } : {}),
    relation: record.relation as 'modified' | 'generated',
    sourceKind: kind,
    sourceIndex: record.sourceIndex,
  }]
}

export const selectFreeCadPrivateNamingHistoryRecords = (
  records: NativeOcctHistoryResponse['records'],
  inputs: FreeCadPrivateNamingInputIdentity[],
): NativeOcctHistoryResponse['records'] => {
  const sources = new Set<string>()
  for (const input of inputs) {
    if (input.inputId.trim()) sources.add(input.inputId)
    if (input.role?.trim()) sources.add(input.role)
  }
  if (inputs.length > 0) sources.add('object')
  if (inputs.length > 1) sources.add('tool')
  return records.filter((record) => sources.has(record.sourceId ?? record.source))
}

/**
 * Classifies the native graph before the FreeCAD private-name ABI is called.
 * A result is stable only when every contributing native relation resolves to
 * one source object/subshape/stage. Isomorphic multi-source results retain all
 * candidates and are deliberately excluded from private token generation.
 */
export const classifyFreeCadPrivateNamingHistory = (
  records: NativeOcctHistoryResponse['records'],
  inputs: FreeCadPrivateNamingInput[],
): FreeCadPrivateNamingHistoryClassification => {
  const externalRecords = selectFreeCadPrivateNamingHistoryRecords(records, inputs)
  const deletedRecords = externalRecords.filter((record) => record.relation === 'deleted').map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined }))
  const grouped = new Map<string, Array<{ record: NativeOcctHistoryResponse['records'][number]; input: FreeCadPrivateNamingInput; resultKind: FreeCadPrivateNamingHistoryAmbiguity['resultKind']; resultIndex: number; candidate: FreeCadPrivateNamingHistoryCandidate }>>()

  for (const record of externalRecords) {
    if (record.relation === 'deleted') continue
    const resultKind = record.resultKind ?? record.kind
    if (!namingKinds.has(record.kind) || !namingKinds.has(resultKind)) throw new TypeError(`FreeCAD naming history contains unsupported kind '${record.kind}' -> '${resultKind}'.`)
    if (!Number.isSafeInteger(record.sourceIndex) || record.sourceIndex < 0) throw new RangeError('FreeCAD naming history sourceIndex must be a non-negative safe integer.')
    const input = resolveNamingInput(record, inputs)
    if (!input) throw new Error(`FreeCAD naming history source '${effectiveSource(record)}' has no transport input.`)
    const indexes = record.resultIndexes ?? (record.resultIndex === undefined ? [] : [record.resultIndex])
    for (const resultIndex of indexes) {
      if (!Number.isSafeInteger(resultIndex) || resultIndex < 0) throw new RangeError('FreeCAD naming history resultIndex must be a non-negative safe integer.')
      const sourceKind = record.kind as FreeCadPrivateNamingHistoryCandidate['sourceKind']
      const kind = resultKind as FreeCadPrivateNamingHistoryAmbiguity['resultKind']
      const key = `${kind}:${resultIndex}`
      const entries = grouped.get(key) ?? []
      for (const candidate of sourceCandidates(record, input, sourceKind)) entries.push({ record, input, resultKind: kind, resultIndex, candidate })
      grouped.set(key, entries)
    }
  }

  const selected: NativeOcctHistoryResponse['records'] = [...deletedRecords]
  const ambiguities: FreeCadPrivateNamingHistoryAmbiguity[] = []
  let duplicateRecordCount = 0
  let relationConflictCount = 0
  for (const entries of grouped.values()) {
    const unique = new Map<string, typeof entries[number]>()
    for (const entry of entries) {
      const candidate = entry.candidate
      const key = `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId ?? ''}\u0000${candidate.relation}`
      if (!unique.has(key)) unique.set(key, entry)
    }
    const distinctSources = new Set([...unique.values()].map(({ candidate }) => `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId ?? ''}`))
    if (distinctSources.size > 1) {
      const first = entries[0]
      ambiguities.push({ resultKind: first.resultKind, resultIndex: first.resultIndex, candidates: [...unique.values()].map(({ candidate }) => ({ ...candidate })) })
      continue
    }
    const relations = new Set([...unique.values()].map(({ candidate }) => candidate.relation))
    if (relations.size > 1) relationConflictCount += 1
    // OCCT may expose one unchanged result through both Modified() and Generated().
    // Modified preserves identity and therefore has deterministic precedence.
    const stable = [...unique.values()].find(({ candidate }) => candidate.relation === 'modified') ?? unique.values().next().value as typeof entries[number]
    selected.push({ ...stable.record, resultKind: stable.resultKind, resultIndex: stable.resultIndex, resultIndexes: undefined })
    duplicateRecordCount += entries.length - unique.size
  }

  return {
    records: selected,
    ambiguities,
    externalRecordCount: externalRecords.length,
    internalRecordCount: records.length - externalRecords.length,
    duplicateRecordCount,
    relationConflictCount,
  }
}

const ambiguousMappedNames = (ambiguities: FreeCadPrivateNamingHistoryAmbiguity[]): NativeMappedNameRef[] => ambiguities.map((ambiguity) => {
  const name = `${titleKind(ambiguity.resultKind)}${ambiguity.resultIndex + 1}`
  return {
    kind: ambiguity.resultKind,
    resultIndex: ambiguity.resultIndex,
    resultPersistentId: name,
    reference: { name },
    relation: 'ambiguous',
    candidates: ambiguity.candidates.map(({ objectId, persistentId, stageId }) => ({ objectId, persistentId, ...(stageId ? { stageId } : {}) })),
  }
})

const withIndexedSourceFallbacks = (inputs: FreeCadPrivateNamingInput[], records: NativeOcctHistoryResponse['records']): FreeCadPrivateNamingInput[] => {
  const cloned = inputs.map((input) => ({ ...input, ...(input.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(input.namingEvidence) } : {}) }))
  for (const record of records) {
    if (record.relation === 'deleted' || !namingKinds.has(record.kind)) continue
    const input = resolveNamingInput(record, cloned)
    const evidence = input?.namingEvidence
    if (!input || !evidence || evidence.status === 'final-shape-only' || evidence.status === 'missing' || evidence.mappedNames?.some((mapped) => mapped.kind === record.kind && mapped.resultIndex === record.sourceIndex)) continue
    const kind = record.kind as NativeMappedNameRef['kind']
    const name = `${titleKind(kind)}${record.sourceIndex + 1}`
    evidence.mappedNames = [...(evidence.mappedNames ?? []), {
      kind,
      resultIndex: record.sourceIndex,
      resultPersistentId: name,
      reference: { name },
      relation: 'preserved',
      sourceRefs: [{ objectId: input.objectId ?? input.inputId, persistentId: name, ...(input.stageId ? { stageId: input.stageId } : {}) }],
    }]
    assertNativeNamingEvidence(evidence)
  }
  return cloned
}

export const probeFreeCadPrivateNamingAbi = (module: NativeFreeCadNamingAbiModule): FreeCadPrivateNamingAbiProbe => {
  if (typeof module.freecadNamingAbiVersion !== 'function' || typeof module.freecadNamingCapabilitiesJson !== 'function' || typeof module.freecadNamingEvidenceJson !== 'function') return {
    availability: 'unavailable',
    abiVersion: null,
    reason: 'Native module does not export the versioned FreeCAD naming callbacks.',
  }
  let abiVersion: number
  try { abiVersion = module.freecadNamingAbiVersion() } catch (error) {
    return { availability: 'unavailable', abiVersion: null, reason: `FreeCAD naming ABI version probe failed: ${error instanceof Error ? error.message : String(error)}` }
  }
  if (abiVersion !== FREECAD_PRIVATE_NAMING_ABI_VERSION) return { availability: 'unavailable', abiVersion, reason: `Unsupported FreeCAD naming ABI version ${abiVersion}.` }
  try {
    const raw = module.freecadNamingCapabilitiesJson()
    if (encoder.encode(raw).byteLength > 64 * 1024) throw new RangeError('FreeCAD naming capability payload exceeds 64 KiB.')
    const descriptor = JSON.parse(raw) as FreeCadPrivateNamingAbiDescriptor
    if (descriptor.schemaVersion !== 1 || descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== lockedCommit || descriptor.mappedNameRef !== true || descriptor.stringHasher !== true || descriptor.elementMap2 !== true) throw new TypeError('FreeCAD naming capabilities are not locked to the required 1.1.1 private ABI.')
    if (!Array.isArray(descriptor.operations) || descriptor.operations.length === 0 || new Set(descriptor.operations).size !== descriptor.operations.length || descriptor.operations.some((operation) => !supportedOperations.has(operation))) throw new TypeError('FreeCAD naming capabilities require a unique non-empty supported operation list.')
    return {
      availability: 'available',
      abiVersion,
      descriptor: {
        ...descriptor,
        operations: [...descriptor.operations],
        maxRequestBytes: boundedLimit(descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES),
        maxResponseBytes: boundedLimit(descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES),
      },
    }
  } catch (error) {
    return { availability: 'unavailable', abiVersion, reason: `FreeCAD naming capability probe failed: ${error instanceof Error ? error.message : String(error)}` }
  }
}

export const createFreeCadPrivateNamingAbiRequest = (input: Omit<FreeCadPrivateNamingAbiRequest, 'schemaVersion'>): FreeCadPrivateNamingAbiRequest => {
  if (!input.requestId.trim() || !input.documentId.trim() || !input.operationId.trim() || !input.stageId.trim() || !input.resultObjectId.trim()) throw new TypeError('FreeCAD naming ABI request IDs must be non-empty.')
  if (!Number.isSafeInteger(input.documentVersion) || input.documentVersion < 0) throw new RangeError('FreeCAD naming ABI documentVersion must be a non-negative integer.')
  if (input.resultObjectTag !== undefined && (!Number.isSafeInteger(input.resultObjectTag) || input.resultObjectTag <= 0)) throw new RangeError('FreeCAD naming ABI resultObjectTag must be a positive safe integer.')
  const inputs = input.inputs.map((entry) => {
    if (!entry.inputId.trim() || !entry.step.trim()) throw new TypeError('FreeCAD naming ABI inputs require non-empty inputId and STEP text.')
    if (entry.objectId !== undefined && !entry.objectId.trim()) throw new TypeError(`FreeCAD naming ABI input ${entry.inputId} objectId must be non-empty when provided.`)
    if (entry.objectTag !== undefined && (!Number.isSafeInteger(entry.objectTag) || entry.objectTag <= 0)) throw new RangeError(`FreeCAD naming ABI input ${entry.inputId} objectTag must be a positive safe integer.`)
    return { ...entry, ...(entry.namingEvidence ? { namingEvidence: cloneNativeNamingEvidence(assertNativeNamingEvidence(entry.namingEvidence)) } : {}) }
  })
  const classification = classifyFreeCadPrivateNamingHistory(input.history.records, inputs)
  const abiInputs = withIndexedSourceFallbacks(inputs, classification.records)
  return {
    schemaVersion: 1,
    ...input,
    inputs: abiInputs,
    stages: input.stages.map((entry) => ({ ...entry, inputIds: [...entry.inputIds] })),
    ...(input.resultStepByStage ? { resultStepByStage: { ...input.resultStepByStage } } : {}),
    ...(classification.ambiguities.length > 0 ? { historyAmbiguities: classification.ambiguities.map((ambiguity) => ({ ...ambiguity, candidates: ambiguity.candidates.map((candidate) => ({ ...candidate })) })) } : {}),
    history: { ...input.history, records: classification.records.map((record) => ({ ...record, resultIndexes: record.resultIndexes ? [...record.resultIndexes] : undefined })) },
  }
}

export const captureFreeCadPrivateNamingEvidence = (
  module: NativeFreeCadNamingAbiModule,
  request: FreeCadPrivateNamingAbiRequest,
  probe = probeFreeCadPrivateNamingAbi(module),
): NativeStageNamingEvidence | undefined => {
  if (probe.availability !== 'available' || !probe.descriptor || typeof module.freecadNamingEvidenceJson !== 'function') return undefined
  if (!probe.descriptor.operations.includes(request.operation)) return undefined
  const ambiguities = request.historyAmbiguities ?? []
  const stableResultCount = request.history.records.filter((record) => record.relation !== 'deleted').reduce((count, record) => count + (record.resultIndexes?.length ?? (record.resultIndex === undefined ? 0 : 1)), 0)
  if (stableResultCount === 0 && ambiguities.length > 0) return assertNativeNamingEvidence({
    schemaVersion: 1,
    stageId: request.stageId,
    resultObjectId: request.resultObjectId,
    status: 'ambiguous',
    mappedNames: ambiguousMappedNames(ambiguities),
    reason: 'Native topology has more than one source provenance; private FreeCAD names were not minted.',
  })
  const requestJson = JSON.stringify(request)
  const requestBytes = encoder.encode(requestJson).byteLength
  const requestLimit = boundedLimit(probe.descriptor.maxRequestBytes, FREECAD_PRIVATE_NAMING_MAX_REQUEST_BYTES)
  if (requestBytes > requestLimit) throw new RangeError(`FreeCAD naming ABI request exceeds ${requestLimit} bytes.`)
  const responseJson = module.freecadNamingEvidenceJson(requestJson)
  if (typeof responseJson !== 'string') throw new TypeError('FreeCAD naming ABI must return a JSON string.')
  const responseBytes = encoder.encode(responseJson).byteLength
  const responseLimit = boundedLimit(probe.descriptor.maxResponseBytes, FREECAD_PRIVATE_NAMING_MAX_RESPONSE_BYTES)
  if (responseBytes > responseLimit) throw new RangeError(`FreeCAD naming ABI response exceeds ${responseLimit} bytes.`)
  const parsed = JSON.parse(responseJson) as NativeStageNamingEvidence | FreeCadPrivateNamingAbiError
  if (parsed && parsed.status === 'error') {
    if (parsed.schemaVersion !== FREECAD_PRIVATE_NAMING_ABI_VERSION || typeof parsed.error !== 'string' || !parsed.error.trim()) throw new TypeError('FreeCAD naming ABI returned a malformed error response.')
    throw new Error(`FreeCAD naming ABI failed: ${parsed.error}`)
  }
  const evidence = assertNativeNamingEvidence(parsed)
  if (evidence.stageId !== request.stageId || evidence.resultObjectId !== request.resultObjectId) throw new RangeError('FreeCAD naming ABI response does not match its stage and result object context.')
  if (evidence.status !== 'native-evidence' && evidence.status !== 'ambiguous') throw new TypeError(`FreeCAD naming ABI cannot return non-native status '${evidence.status}'.`)
  if (!evidence.stringHasher || !evidence.elementMap2) throw new TypeError('FreeCAD naming ABI response requires both StringHasher and ElementMap2 evidence.')
  const stringHasher = migrateStringHasherSchema(evidence.stringHasher)
  const stringHasherReport = validateStringHasherTable(stringHasher)
  if (!stringHasherReport.valid) throw new Error(`FreeCAD naming ABI StringHasher is invalid: ${stringHasherReport.issues[0].path}: ${stringHasherReport.issues[0].message}`)
  const elementMap2 = migrateElementMap2Schema(evidence.elementMap2)
  const elementMap2Report = validateElementMap2(elementMap2)
  if (!elementMap2Report.valid) throw new Error(`FreeCAD naming ABI ElementMap2 is invalid: ${elementMap2Report.issues[0].path}: ${elementMap2Report.issues[0].message}`)
  const closureIssues = validateElementMap2StringHasherEvidence(elementMap2, stringHasher)
  if (closureIssues.length > 0) throw new Error(`FreeCAD naming ABI StringHasher closure is invalid: ${closureIssues[0].path}: ${closureIssues[0].message}`)
  const normalized = { ...evidence, stringHasher, elementMap2 }
  if (ambiguities.length === 0) return normalized
  return assertNativeNamingEvidence({
    ...normalized,
    status: 'ambiguous',
    mappedNames: [...(normalized.mappedNames ?? []), ...ambiguousMappedNames(ambiguities)],
    reason: 'Unique native sources use FreeCAD private names; non-unique isomorphic sources retain explicit candidates.',
  })
}
