import { assertNativeNamingEvidence, type NativeStageNamingEvidence } from './nativeNamingEvidence'

/**
 * FreeCAD 1.x native ElementMap resource codec.
 *
 * The on-disk resource is intentionally kept lossless at token level.  This
 * lets the facade inspect and migrate maps without inventing WebCAD names or
 * discarding FreeCAD's opaque postfix/string-id tokens.
 */

export type ElementMap2SectionName = 'Edge' | 'Face' | 'Vertex' | string;

export interface ElementMap2NameToken {
  raw: string;
  marker: ':' | '$' | ';';
  postfixIndex?: number;
  elementIndex?: number;
  name?: string;
  suffix: string[];
}

export interface ElementMap2NameEntry {
  tokens: ElementMap2NameToken[];
  trailing: '0';
  raw?: string;
}

/**
 * The information FreeCAD's ElementMap::save() receives from MappedNameRef.
 * It is deliberately provenance based: a token cannot be manufactured from
 * final geometry alone, because its postfix and StringIDs come from naming
 * history and the document StringHasher.
 */
export interface ElementMap2MappedNameReference {
  name: string;
  postfix?: string;
  stringIds?: number[];
  prefixStringId?: number;
  indexedName?: {
    type: string;
    index: number;
  };
}

export interface ElementMap2ChildRecord {
  childIndex: number;
  offset: number;
  count: number;
  tag: number;
  mapIndex: number;
  postfix: string;
  stringIds: number[];
  raw?: string;
}

export interface ElementMap2Section {
  name: ElementMap2SectionName;
  children: ElementMap2ChildRecord[];
  names: ElementMap2NameEntry[];
}

export interface ElementMap2Map {
  index: number;
  id: number;
  typeCount: number;
  sections: ElementMap2Section[];
}

export interface ElementMap2DocumentV1 {
  schemaVersion: 1;
  nativeVersion: 1;
  rootId: number;
  postfixes: string[];
  maps: ElementMap2Map[];
  rootMapIndex: number;
}

export interface ElementMap2Document {
  schemaVersion: 2;
  nativeVersion: 1;
  rootId: number;
  postfixes: string[];
  maps: ElementMap2Map[];
  rootMapIndex: number;
}

export interface ElementMap2StageNameEntry {
  kind: 'face' | 'edge' | 'vertex'
  resultPersistentId: string
  token: ElementMap2NameToken
  status: 'stable' | 'ambiguous' | 'generated' | 'deleted'
  sourceRefs: Array<{ objectId: string; persistentId: string; stageId?: string }>
  candidates?: Array<{ objectId: string; persistentId: string; stageId?: string }>
}

export interface ElementMap2StageNameMap {
  stageId: string
  resultObjectId: string
  inputStageIds: string[]
  entries: ElementMap2StageNameEntry[]
}

export interface ElementMap2MultiStageNameMapping {
  schemaVersion: 1
  stages: ElementMap2StageNameMap[]
}

export type AnyElementMap2Document = ElementMap2DocumentV1 | ElementMap2Document;

export interface ElementMap2ValidationIssue {
  code: 'NATIVE_VERSION' | 'ROOT_MAP' | 'MAP_INDEX' | 'TYPE_COUNT' | 'SECTION_NAME' | 'CHILD_MAP' | 'CHILD_POSTFIX' | 'NAME_TOKEN';
  path: string;
  message: string;
}

export interface ElementMap2ValidationReport {
  valid: boolean;
  mapCount: number;
  childCount: number;
  nameCount: number;
  tokenCount: number;
  issues: ElementMap2ValidationIssue[];
}

/** Converts native stage evidence into a lossless, multi-stage ElementMap2 view. */
export function createElementMap2MultiStageNameMapping(stages: readonly NativeStageNamingEvidence[], postfixes: readonly string[]): ElementMap2MultiStageNameMapping {
  const seen = new Set<string>()
  const output: ElementMap2StageNameMap[] = []
  for (const rawEvidence of stages) {
    const evidence = assertNativeNamingEvidence(rawEvidence)
    if (seen.has(evidence.stageId)) throw new RangeError(`Duplicate ElementMap2 naming stage ${evidence.stageId}.`)
    seen.add(evidence.stageId)
    const entries = (evidence.mappedNames ?? []).map((mapped) => {
      const status = mapped.relation === 'deleted' ? 'deleted' as const : mapped.relation === 'ambiguous' || (mapped.candidates?.length ?? 0) > 1 ? 'ambiguous' as const : mapped.relation === 'generated' ? 'generated' as const : 'stable' as const
      return {
        kind: mapped.kind,
        resultPersistentId: mapped.resultPersistentId,
        token: createElementMap2NameToken(mapped.reference, postfixes),
        status,
        sourceRefs: mapped.sourceRefs?.map((source) => ({ ...source })) ?? [],
        ...(mapped.candidates && mapped.candidates.length > 0 ? { candidates: mapped.candidates.map((candidate) => ({ ...candidate })) } : {}),
      }
    })
    output.push({ stageId: evidence.stageId, resultObjectId: evidence.resultObjectId, inputStageIds: [...new Set(entries.flatMap((entry) => entry.sourceRefs.map((source) => source.stageId).filter((stageId): stageId is string => Boolean(stageId))))], entries })
  }
  return { schemaVersion: 1, stages: output }
}

export type ElementMap2MultiStageNameMappingIssue = {
  code: 'SCHEMA_VERSION' | 'STAGE_ID' | 'RESULT_OBJECT_ID' | 'DUPLICATE_STAGE' | 'SOURCE_STAGE' | 'ENTRY' | 'TOKEN'
  path: string
  message: string
}

export function validateElementMap2MultiStageNameMapping(mapping: ElementMap2MultiStageNameMapping): ElementMap2MultiStageNameMappingIssue[] {
  const issues: ElementMap2MultiStageNameMappingIssue[] = []
  if (mapping.schemaVersion !== 1) issues.push({ code: 'SCHEMA_VERSION', path: 'schemaVersion', message: `unsupported schema version ${mapping.schemaVersion}` })
  const stages = new Map<string, number>()
  for (const [index, stage] of mapping.stages.entries()) {
    if (!stage.stageId.trim()) issues.push({ code: 'STAGE_ID', path: `stages[${index}].stageId`, message: 'stageId must be non-empty' })
    if (stages.has(stage.stageId)) issues.push({ code: 'DUPLICATE_STAGE', path: `stages[${index}].stageId`, message: `duplicate stage ${stage.stageId}` })
    stages.set(stage.stageId, index)
    if (!stage.resultObjectId.trim()) issues.push({ code: 'RESULT_OBJECT_ID', path: `stages[${index}].resultObjectId`, message: 'resultObjectId must be non-empty' })
  }
  for (const [stageIndex, stage] of mapping.stages.entries()) {
    for (const [sourceIndex, sourceStageId] of stage.inputStageIds.entries()) {
      const sourceOrdinal = stages.get(sourceStageId)
      if (sourceOrdinal === undefined || sourceOrdinal >= stageIndex) issues.push({ code: 'SOURCE_STAGE', path: `stages[${stageIndex}].inputStageIds[${sourceIndex}]`, message: `source stage ${sourceStageId} must exist earlier than ${stage.stageId}` })
    }
    const keys = new Set<string>()
    for (const [entryIndex, entry] of stage.entries.entries()) {
      const key = `${entry.kind}:${entry.resultPersistentId}`
      if (keys.has(key)) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}]`, message: `duplicate result mapping ${key}` })
      keys.add(key)
      if (!entry.resultPersistentId.trim() || !['stable', 'ambiguous', 'generated', 'deleted'].includes(entry.status)) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}]`, message: 'invalid result mapping status or persistent ID' })
      try {
        if (formatElementMap2NameToken(entry.token) !== entry.token.raw) issues.push({ code: 'TOKEN', path: `stages[${stageIndex}].entries[${entryIndex}].token`, message: 'token writer is not lossless' })
      } catch (error) {
        issues.push({ code: 'TOKEN', path: `stages[${stageIndex}].entries[${entryIndex}].token`, message: error instanceof Error ? error.message : String(error) })
      }
      if (entry.status === 'ambiguous' && (entry.candidates?.length ?? 0) < 2) issues.push({ code: 'ENTRY', path: `stages[${stageIndex}].entries[${entryIndex}].candidates`, message: 'ambiguous mappings require at least two candidates' })
    }
  }
  return issues
}

export function writeElementMap2MultiStageNameMapping(mapping: ElementMap2MultiStageNameMapping): string {
  const issues = validateElementMap2MultiStageNameMapping(mapping)
  if (issues.length > 0) throw new Error(`ElementMap2 multi-stage mapping is invalid: ${issues[0].path}: ${issues[0].message}`)
  return `${JSON.stringify(mapping)}\n`
}

export function parseElementMap2MultiStageNameMapping(text: string): ElementMap2MultiStageNameMapping {
  let value: unknown
  try { value = JSON.parse(text) } catch (error) { throw new Error(`ElementMap2 multi-stage mapping JSON is invalid: ${error instanceof Error ? error.message : String(error)}`) }
  if (!value || typeof value !== 'object') throw new Error('ElementMap2 multi-stage mapping must be an object.')
  const mapping = value as ElementMap2MultiStageNameMapping
  const issues = validateElementMap2MultiStageNameMapping(mapping)
  if (issues.length > 0) throw new Error(`ElementMap2 multi-stage mapping is invalid: ${issues[0].path}: ${issues[0].message}`)
  return mapping
}

const MAX_LINES = 2_000_000;
const MAX_COUNT = 1_000_000;

function fail(message: string): never {
  throw new Error(`ElementMap2 parse error: ${message}`);
}

function integer(value: string, label: string): number {
  if (!/^(?:0|[1-9]\d*)$/.test(value)) fail(`${label} must be a decimal integer`);
  const result = Number(value);
  if (!Number.isSafeInteger(result) || result < 0 || result > MAX_COUNT) {
    fail(`${label} is out of range`);
  }
  return result;
}

function hex(value: string, label: string): number {
  if (!/^[0-9a-fA-F]+$/.test(value)) fail(`${label} must be hexadecimal`);
  const result = Number.parseInt(value, 16);
  if (!Number.isSafeInteger(result) || result < 0 || result > MAX_COUNT) {
    fail(`${label} is out of range`);
  }
  return result;
}

function checkedHex(value: number, label: string): string {
  if (!Number.isSafeInteger(value) || value < 0 || value > MAX_COUNT) {
    throw new RangeError(`ElementMap2 ${label} is out of range`);
  }
  return value.toString(16);
}

function postfixIndex(postfixes: readonly string[], postfix: string, label: string): number {
  if (!postfix) return 0;
  const index = postfixes.indexOf(postfix);
  if (index < 0) throw new RangeError(`ElementMap2 ${label} ${JSON.stringify(postfix)} is absent from the postfix table`);
  return index + 1;
}

/** Format the structured fields using the std::hex rules in ElementMap::save(). */
export function formatElementMap2NameToken(token: ElementMap2NameToken): string {
  if (token.marker === ':') {
    if (token.postfixIndex === undefined || token.elementIndex === undefined) return token.raw;
    const head = `:${checkedHex(token.postfixIndex, 'indexed-name postfix index')}.${checkedHex(token.elementIndex, 'element index')}`;
    return token.suffix.length ? `${head}.${token.suffix.join('.')}` : head;
  }
  if (!token.name) return token.raw;
  const head = `${token.marker}${token.name}`;
  return token.suffix.length ? `${head}.${token.suffix.join('.')}` : head;
}

/**
 * Port of the name-token branch in FreeCAD 1.1.1 ElementMap::save(). The
 * caller must provide the MappedName/StringHasher evidence produced while the
 * feature builders execute; no geometric inference is performed here.
 */
export function createElementMap2NameToken(reference: ElementMap2MappedNameReference, postfixes: readonly string[]): ElementMap2NameToken {
  if (!reference.name || /[.\s]/.test(reference.name)) throw new TypeError('ElementMap2 mapped name must be non-empty and contain neither dots nor whitespace.');
  const markedIds = [...(reference.stringIds ?? [])];
  for (const [index, id] of markedIds.entries()) checkedHex(id, `StringID ${index}`);
  const mappedPostfixIndex = postfixIndex(postfixes, reference.postfix ?? '', 'mapped-name postfix');
  let token: ElementMap2NameToken;
  if (reference.indexedName) {
    if (!reference.indexedName.type || /[.\s]/.test(reference.indexedName.type)) throw new TypeError('ElementMap2 indexed-name type must be a non-empty token.');
    const typePostfixIndex = postfixIndex(postfixes, reference.indexedName.type, 'indexed-name type');
    if (typePostfixIndex === 0) throw new RangeError('ElementMap2 indexed-name type cannot use postfix index zero.');
    token = {
      raw: '',
      marker: ':',
      postfixIndex: typePostfixIndex,
      elementIndex: reference.indexedName.index,
      suffix: [checkedHex(mappedPostfixIndex, 'mapped-name postfix index'), ...markedIds.map((id) => checkedHex(id, 'StringID'))],
    };
  } else {
    const prefixIsMarked = reference.prefixStringId !== undefined && markedIds.includes(reference.prefixStringId);
    const marker: '$' | ';' = prefixIsMarked ? '$' : ';';
    const suffixIds = prefixIsMarked ? markedIds.filter((id) => id !== reference.prefixStringId) : markedIds;
    token = {
      raw: '',
      marker,
      name: reference.name,
      suffix: [checkedHex(mappedPostfixIndex, 'mapped-name postfix index'), ...suffixIds.map((id) => checkedHex(id, 'StringID'))],
    };
  }
  token.raw = formatElementMap2NameToken(token);
  return token;
}

export function createElementMap2NameEntry(references: readonly ElementMap2MappedNameReference[], postfixes: readonly string[]): ElementMap2NameEntry {
  return { tokens: references.map((reference) => createElementMap2NameToken(reference, postfixes)), trailing: '0' };
}

/** Recover the persisted MappedNameRef evidence needed to regenerate a token. */
export function elementMap2NameTokenToReference(token: ElementMap2NameToken, postfixes: readonly string[]): ElementMap2MappedNameReference {
  if (token.suffix.length < 1 || token.suffix.some((part) => !/^[0-9a-fA-F]+$/.test(part))) {
    throw new TypeError(`ElementMap2 token has no valid postfix/StringID evidence: ${JSON.stringify(token.raw)}`);
  }
  const mappedPostfixIndex = hex(token.suffix[0], 'mapped-name postfix index');
  if (mappedPostfixIndex > postfixes.length) throw new RangeError(`ElementMap2 token references unavailable postfix ${mappedPostfixIndex}`);
  const postfix = mappedPostfixIndex === 0 ? '' : postfixes[mappedPostfixIndex - 1];
  const suffixStringIds = token.suffix.slice(1).map((value, index) => hex(value, `StringID ${index}`));
  if (token.marker === ':') {
    if (token.postfixIndex === undefined || token.postfixIndex <= 0 || token.postfixIndex > postfixes.length || token.elementIndex === undefined) {
      throw new RangeError(`ElementMap2 indexed token has unavailable type evidence: ${JSON.stringify(token.raw)}`);
    }
    const type = postfixes[token.postfixIndex - 1];
    return { name: `${type}${token.elementIndex}`, postfix, stringIds: suffixStringIds, indexedName: { type, index: token.elementIndex } };
  }
  if (!token.name) throw new TypeError(`ElementMap2 named token has no mapped name: ${JSON.stringify(token.raw)}`);
  if (token.marker === '$') {
    const prefix = token.name.match(/^#([0-9a-fA-F]+)(?::[0-9a-fA-F]+)?$/);
    if (!prefix) throw new TypeError(`ElementMap2 StringID token has an invalid prefix: ${JSON.stringify(token.raw)}`);
    const prefixStringId = hex(prefix[1], 'prefix StringID');
    return { name: token.name, postfix, prefixStringId, stringIds: [prefixStringId, ...suffixStringIds] };
  }
  return { name: token.name, postfix, stringIds: suffixStringIds };
}

function parseNameToken(raw: string): ElementMap2NameToken {
  if (!raw || !/^[;:$]/.test(raw)) fail(`invalid name token ${JSON.stringify(raw)}`);
  const marker = raw[0] as ':' | '$' | ';';
  const parts = raw.split('.');
  const first = parts[0].slice(1);
  const suffix = parts.slice(1);
  if (marker === ':') {
    // Native indexed tokens are :<postfix>.<element>[.<suffix>...].
    if (parts.length >= 2 && /^[0-9a-fA-F]+$/.test(first) && /^[0-9a-fA-F]+$/.test(parts[1])) {
      return {
        raw,
        marker,
        postfixIndex: hex(first, 'postfix index'),
        elementIndex: hex(parts[1], 'element index'),
        suffix: parts.slice(2),
      };
    }
    if (!first) fail('indexed token is empty');
    return { raw, marker, suffix: [first, ...suffix] };
  }
  if (!first) fail(`named token ${JSON.stringify(raw)} is empty`);
  return { raw, marker, name: first, suffix };
}

function readNonEmpty(lines: string[], cursor: { value: number }): string {
  while (cursor.value < lines.length && lines[cursor.value].trim() === '') cursor.value += 1;
  if (cursor.value >= lines.length) fail('unexpected end of resource');
  return lines[cursor.value++].trim();
}

function peekNonEmpty(lines: string[], cursor: { value: number }): string {
  let index = cursor.value;
  while (index < lines.length && lines[index].trim() === '') index += 1;
  return index < lines.length ? lines[index].trim() : '';
}

function parseCountLine(lines: string[], cursor: { value: number }, label: string): number {
  const line = readNonEmpty(lines, cursor);
  const match = line.match(new RegExp(`^${label}\\s+(\\d+)$`));
  if (!match) fail(`expected "${label} <count>", got ${JSON.stringify(line)}`);
  return integer(match[1], label);
}

export function parseElementMap2(text: string): ElementMap2DocumentV1 {
  const lines = text.replace(/\r\n?/g, '\n').split('\n');
  if (lines.length > MAX_LINES) fail('resource is too large');
  const cursor = { value: 0 };
  const begin = readNonEmpty(lines, cursor).match(/^BeginElementMap\s+v(\d+)$/);
  if (!begin || Number(begin[1]) !== 1) fail('expected BeginElementMap v1');

  const header = readNonEmpty(lines, cursor).match(/^(\d+)\s+PostfixCount\s+(\d+)$/);
  if (!header) fail('expected root id and PostfixCount');
  const rootId = integer(header[1], 'root id');
  const postfixCount = integer(header[2], 'postfix count');
  const postfixes: string[] = [];
  for (let i = 0; i < postfixCount; i += 1) {
    if (cursor.value >= lines.length) fail('postfix list is truncated');
    postfixes.push(lines[cursor.value++]);
  }

  const mapCount = parseCountLine(lines, cursor, 'MapCount');
  if (mapCount === 0) fail('MapCount must be positive');
  const maps: ElementMap2Map[] = [];
  for (let mapOrdinal = 0; mapOrdinal < mapCount; mapOrdinal += 1) {
    const mapHeader = readNonEmpty(lines, cursor).match(/^ElementMap\s+(\d+)\s+(\d+)\s+(\d+)$/);
    if (!mapHeader) fail('expected ElementMap header');
    const index = integer(mapHeader[1], 'map index');
    const id = integer(mapHeader[2], 'map id');
    const typeCount = integer(mapHeader[3], 'type count');
    if (maps.some((map) => map.index === index)) fail(`duplicate map index ${index}`);
    const sections: ElementMap2Section[] = [];
    // Older WebCAD fixtures emitted a zero type count followed by one
    // section.  Accept that representation while writing the canonical count.
    for (let typeOrdinal = 0; typeOrdinal < typeCount || (typeCount === 0 && /^[A-Za-z][A-Za-z0-9_]*$/.test(peekNonEmpty(lines, cursor))); typeOrdinal += 1) {
      const name = readNonEmpty(lines, cursor);
      if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) fail(`invalid section name ${name}`);
      const childCount = parseCountLine(lines, cursor, 'ChildCount');
      const children: ElementMap2ChildRecord[] = [];
      for (let childOrdinal = 0; childOrdinal < childCount; childOrdinal += 1) {
        const raw = readNonEmpty(lines, cursor);
        const fields = raw.split(/\s+/);
        if (fields.length < 6) fail(`invalid child record ${JSON.stringify(raw)}`);
        if (fields.length < 7) fail(`child record has no StringID sentinel: ${JSON.stringify(raw)}`);
        const childStringIdToken = fields.slice(6).join('.');
        const childStringIdParts = childStringIdToken.split('.');
        if (childStringIdParts[0] !== '0' || childStringIdParts.slice(1).some((value) => !/^(?:0|[1-9]\d*)$/.test(value))) {
          fail(`invalid child StringID token ${JSON.stringify(childStringIdToken)}`);
        }
        const child: ElementMap2ChildRecord = {
          childIndex: integer(fields[0], 'child index'),
          offset: integer(fields[1], 'child offset'),
          count: integer(fields[2], 'child count'),
          tag: integer(fields[3], 'child tag'),
          mapIndex: integer(fields[4], 'child map index'),
          postfix: fields[5],
          // FreeCAD intentionally restores child StringIDs as decimal for
          // backward compatibility with the original writer accident.
          stringIds: childStringIdParts.slice(1).map((value, i) => integer(value, `child string id ${i}`)),
          raw,
        };
        if (child.mapIndex >= index) fail(`child map index ${child.mapIndex} must precede map ${index}`);
        children.push(child);
      }
      const nameCount = parseCountLine(lines, cursor, 'NameCount');
      const names: ElementMap2NameEntry[] = [];
      for (let nameOrdinal = 0; nameOrdinal < nameCount; nameOrdinal += 1) {
        if (cursor.value >= lines.length) fail('name list is truncated');
        const raw = lines[cursor.value++].trim();
        const fields = raw ? raw.split(/\s+/) : [];
        if (fields.length === 0) fail(`name entry is empty: ${JSON.stringify(raw)}`);
        const hasTerminator = fields[fields.length - 1] === '0';
        if (!hasTerminator && !fields[0].startsWith(';') && !fields[0].startsWith('$') && !fields[0].startsWith(':')) fail(`name entry must end in 0: ${JSON.stringify(raw)}`);
        names.push({
          tokens: fields.slice(0, hasTerminator ? -1 : undefined).map(parseNameToken),
          trailing: '0',
          raw,
        });
      }
      sections.push({ name, children, names });
    }
    // A pre-v1 WebCAD fixture may contain an orphaned opaque name token after
    // its declared names.  Keep parsing deterministic, but consume only
    // marker-prefixed tokens before the map terminator.
    while (peekNonEmpty(lines, cursor) !== 'EndMap') {
      const orphan = readNonEmpty(lines, cursor);
      if (!/^[;:$]/.test(orphan)) fail(`unexpected map content ${JSON.stringify(orphan)}`);
    }
    const end = readNonEmpty(lines, cursor);
    if (end !== 'EndMap') fail(`expected EndMap, got ${JSON.stringify(end)}`);
    maps.push({ index, id, typeCount, sections });
  }
  const trailing = lines.slice(cursor.value).filter((line) => line.trim() !== '');
  if (trailing.length > 0 || readLastEnd(lines) !== 'EndMap') {
    // The native writer terminates with EndMap.  readLastEnd() is separate so
    // resources with a final newline are accepted without ambiguity.
    fail('resource must terminate with EndMap');
  }
  const rootMapIndex = maps.reduce((max, map) => Math.max(max, map.index), 0);
  return { schemaVersion: 1, nativeVersion: 1, rootId, postfixes, maps, rootMapIndex };
}

function readLastEnd(lines: string[]): string {
  for (let index = lines.length - 1; index >= 0; index -= 1) {
    const value = lines[index].trim();
    if (value) return value;
  }
  return '';
}

export function migrateElementMap2Schema(document: AnyElementMap2Document): ElementMap2Document {
  return {
    schemaVersion: 2,
    nativeVersion: document.nativeVersion,
    rootId: document.rootId,
    postfixes: [...document.postfixes],
    maps: document.maps.map((map) => ({
      index: map.index,
      id: map.id,
      typeCount: map.typeCount,
      sections: map.sections.map((section) => ({
        name: section.name,
        children: section.children.map((child) => ({ ...child, stringIds: [...child.stringIds] })),
        names: section.names.map((entry) => ({ ...entry, tokens: entry.tokens.map((token) => ({ ...token, suffix: [...token.suffix] })) })),
      })),
    })),
    rootMapIndex: document.rootMapIndex,
  };
}

export function cloneElementMap2(document: ElementMap2Document): ElementMap2Document {
  return migrateElementMap2Schema(document);
}

export function validateElementMap2(document: AnyElementMap2Document): ElementMap2ValidationReport {
  const value = migrateElementMap2Schema(document);
  const issues: ElementMap2ValidationIssue[] = [];
  const add = (issue: ElementMap2ValidationIssue) => issues.push(issue);
  if (value.nativeVersion !== 1) add({ code: 'NATIVE_VERSION', path: 'nativeVersion', message: `unsupported native version ${value.nativeVersion}` });
  const mapIndexes = new Set<number>();
  let childCount = 0;
  let nameCount = 0;
  let tokenCount = 0;
  for (const [mapOrdinal, map] of value.maps.entries()) {
    const mapPath = `maps[${mapOrdinal}]`;
    if (!Number.isSafeInteger(map.index) || map.index < 0 || mapIndexes.has(map.index)) add({ code: 'MAP_INDEX', path: `${mapPath}.index`, message: `invalid or duplicate map index ${map.index}` });
    mapIndexes.add(map.index);
    if (map.typeCount !== 0 && map.typeCount !== map.sections.length) add({ code: 'TYPE_COUNT', path: `${mapPath}.typeCount`, message: `declared ${map.typeCount} sections but parsed ${map.sections.length}` });
    const sectionNames = new Set<string>();
    for (const [sectionOrdinal, section] of map.sections.entries()) {
      const sectionPath = `${mapPath}.sections[${sectionOrdinal}]`;
      if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(section.name) || sectionNames.has(section.name)) add({ code: 'SECTION_NAME', path: `${sectionPath}.name`, message: `invalid or duplicate section name ${section.name}` });
      sectionNames.add(section.name);
      childCount += section.children.length;
      nameCount += section.names.length;
      for (const [childOrdinal, child] of section.children.entries()) {
        const childPath = `${sectionPath}.children[${childOrdinal}]`;
        if (!Number.isSafeInteger(child.mapIndex) || child.mapIndex < 0 || child.mapIndex >= map.index) add({ code: 'CHILD_MAP', path: `${childPath}.mapIndex`, message: `child map ${child.mapIndex} must precede map ${map.index}` });
        if (!child.postfix || /\s/.test(child.postfix)) add({ code: 'CHILD_POSTFIX', path: `${childPath}.postfix`, message: 'child postfix must be one non-empty token' });
      }
      for (const [nameOrdinal, entry] of section.names.entries()) {
        tokenCount += entry.tokens.length;
        for (const [tokenOrdinal, token] of entry.tokens.entries()) {
          const tokenPath = `${sectionPath}.names[${nameOrdinal}].tokens[${tokenOrdinal}]`;
          if (!token.raw || token.raw[0] !== token.marker || !/^[;:$]/.test(token.raw) || formatElementMap2NameToken(token) !== token.raw) {
            add({ code: 'NAME_TOKEN', path: tokenPath, message: `invalid native name token ${JSON.stringify(token.raw)}` });
            continue;
          }
          if (token.marker === ':' && (token.postfixIndex === undefined || token.postfixIndex <= 0 || token.postfixIndex > value.postfixes.length || token.elementIndex === undefined)) {
            add({ code: 'NAME_TOKEN', path: tokenPath, message: 'indexed token references an unavailable indexed-name type' });
          }
          if (token.suffix.length < 1 || token.suffix.some((part) => !/^[0-9a-fA-F]+$/.test(part))) {
            add({ code: 'NAME_TOKEN', path: tokenPath, message: 'token postfix/StringID suffix is missing or is not hexadecimal' });
            continue;
          }
          const mappedPostfix = Number.parseInt(token.suffix[0], 16);
          if (!Number.isSafeInteger(mappedPostfix) || mappedPostfix < 0 || mappedPostfix > value.postfixes.length) {
            add({ code: 'NAME_TOKEN', path: tokenPath, message: `token references unavailable mapped-name postfix ${token.suffix[0]}` });
          }
        }
      }
    }
  }
  if (value.maps.length === 0 || !mapIndexes.has(value.rootMapIndex)) add({ code: 'ROOT_MAP', path: 'rootMapIndex', message: `root map ${value.rootMapIndex} does not exist` });
  for (const [mapOrdinal, map] of value.maps.entries()) {
    for (const [sectionOrdinal, section] of map.sections.entries()) {
      for (const [childOrdinal, child] of section.children.entries()) {
        if (child.mapIndex !== 0 && !mapIndexes.has(child.mapIndex)) add({ code: 'CHILD_MAP', path: `maps[${mapOrdinal}].sections[${sectionOrdinal}].children[${childOrdinal}].mapIndex`, message: `child map ${child.mapIndex} does not exist` });
      }
    }
  }
  return { valid: issues.length === 0, mapCount: value.maps.length, childCount, nameCount, tokenCount, issues };
}

export function canonicalizeElementMap2(document: AnyElementMap2Document): ElementMap2Document {
  const value = migrateElementMap2Schema(document);
  const canonical: ElementMap2Document = {
    ...value,
    postfixes: [...value.postfixes],
    maps: [...value.maps]
      .sort((left, right) => left.index - right.index)
      .map((map) => ({
        ...map,
        typeCount: map.sections.length,
        sections: map.sections.map((section) => ({
          name: section.name,
          children: section.children.map((child) => ({ ...child, stringIds: [...child.stringIds] })),
          names: section.names.map((entry) => ({ ...entry, tokens: entry.tokens.map((token) => ({ ...token, suffix: [...token.suffix] })) })),
        })),
      })),
  };
  const report = validateElementMap2(canonical);
  if (!report.valid) throw new Error(`ElementMap2 validation error: ${report.issues[0].path}: ${report.issues[0].message}`);
  return canonical;
}

/** Stable semantic identity used to verify that native naming history survives a codec round trip. */
export function elementMap2SemanticDigest(document: AnyElementMap2Document): string {
  const value = canonicalizeElementMap2(document);
  return JSON.stringify({
    nativeVersion: value.nativeVersion,
    rootId: value.rootId,
    rootMapIndex: value.rootMapIndex,
    postfixes: value.postfixes,
    maps: value.maps.map((map) => ({
      index: map.index,
      id: map.id,
      sections: map.sections.map((section) => ({
        name: section.name,
        children: section.children.map(({ childIndex, offset, count, tag, mapIndex, postfix, stringIds }) => ({ childIndex, offset, count, tag, mapIndex, postfix, stringIds })),
        names: section.names.map((entry) => entry.tokens.map((token) => token.raw)),
      })),
    })),
  });
}

/**
 * Adds a new native name entry while preserving all existing postfix indexes.
 * Missing section/indexed types are appended before mapped-name postfixes,
 * matching the discovery categories used by ElementMap::collectChildMaps().
 */
export function appendElementMap2NameEntry(
  document: AnyElementMap2Document,
  mapIndex: number,
  sectionName: ElementMap2SectionName,
  references: readonly ElementMap2MappedNameReference[],
): ElementMap2Document {
  const value = migrateElementMap2Schema(document)
  const map = value.maps.find((candidate) => candidate.index === mapIndex)
  if (!map) throw new RangeError(`ElementMap2 map ${mapIndex} does not exist`)
  const section = map.sections.find((candidate) => candidate.name === sectionName)
  if (!section) throw new RangeError(`ElementMap2 section ${JSON.stringify(sectionName)} does not exist in map ${mapIndex}`)
  const appendPostfix = (postfix: string) => {
    if (postfix && !value.postfixes.includes(postfix)) value.postfixes.push(postfix)
  }
  for (const currentMap of [...value.maps].sort((left, right) => left.index - right.index)) {
    for (const currentSection of currentMap.sections) appendPostfix(currentSection.name)
  }
  for (const reference of references) if (reference.indexedName) appendPostfix(reference.indexedName.type)
  for (const reference of references) appendPostfix(reference.postfix ?? '')
  section.names.push(createElementMap2NameEntry(references, value.postfixes))
  return canonicalizeElementMap2(value)
}

export function writeElementMap2(document: AnyElementMap2Document): string {
  const value = canonicalizeElementMap2(document);
  const lines: string[] = ['BeginElementMap v1', `${value.rootId} PostfixCount ${value.postfixes.length}`];
  lines.push(...value.postfixes);
  lines.push(`MapCount ${value.maps.length}`, '');
  for (const map of value.maps) {
    lines.push(`ElementMap ${map.index} ${map.id} ${map.sections.length}`);
    for (const section of map.sections) {
      lines.push(section.name, `ChildCount ${section.children.length}`);
      for (const child of section.children) {
        // Child StringIDs are the one numeric field that FreeCAD 1.1.1 keeps
        // decimal; the leading zero belongs to the native token grammar.
        const ids = `0${child.stringIds.map((id) => `.${id}`).join('')}`;
        lines.push([child.childIndex, child.offset, child.count, child.tag, child.mapIndex, child.postfix, ids].join(' '));
      }
      lines.push(`NameCount ${section.names.length}`);
      for (const entry of section.names) {
        lines.push(entry.tokens.length ? `${entry.tokens.map((token) => token.raw).join(' ')} 0` : '0');
      }
    }
    lines.push('EndMap', '');
  }
  return `${lines.join('\n')}\n`;
}
