/**
 * FreeCAD 1.1.1 StringHasher.Table.txt codec.
 *
 * IDs and related IDs are persisted as a delta-coded hexadecimal stream.  The
 * table is naming evidence, not a hash that can be reconstructed from a
 * final shape; callers must preserve it as an opaque native resource.
 */

export const STRING_HASHER_NATIVE_VERSION = 1 as const

export const enum StringHasherFlag {
  Binary = 1 << 0,
  Hashed = 1 << 1,
  PostfixEncoded = 1 << 2,
  Postfixed = 1 << 3,
  Indexed = 1 << 4,
  PrefixId = 1 << 5,
  PrefixIdIndex = 1 << 6,
  Persistent = 1 << 7,
  /** Internal mark is stripped from persisted tables but accepted by writer input. */
  Marked = 1 << 8,
}

export type StringHasherEntry = {
  id: number
  flags: number
  relatedIds: number[]
  /** Stored data for non-postfixed entries or explicit postfixed prefixes. */
  data: string
  /** Stored postfix when PostfixEncoded is not set. */
  postfix: string
  raw?: string
}

export type StringHasherTableV1 = {
  schemaVersion: 1
  nativeVersion: 1
  entries: StringHasherEntry[]
}

export type StringHasherTable = {
  schemaVersion: 2
  nativeVersion: 1
  entries: StringHasherEntry[]
}

export type AnyStringHasherTable = StringHasherTableV1 | StringHasherTable

export type StringHasherValidationIssue = {
  code: 'HEADER' | 'COUNT' | 'SCHEMA_VERSION' | 'NATIVE_VERSION' | 'ID' | 'FLAGS' | 'RELATED_ID' | 'PAYLOAD'
  path: string
  message: string
}

export type StringHasherValidationReport = {
  valid: boolean
  entryCount: number
  issues: StringHasherValidationIssue[]
}

export type ElementMap2StringHasherEvidenceIssue = Omit<StringHasherValidationIssue, 'code'> & { code: StringHasherValidationIssue['code'] | 'TOKEN_ID' | 'PREFIX_ID' }

const MAX_ENTRIES = 2_000_000
const MAX_ID = 0x7fffffff
const KNOWN_FLAGS = StringHasherFlag.Binary | StringHasherFlag.Hashed | StringHasherFlag.PostfixEncoded | StringHasherFlag.Postfixed | StringHasherFlag.Indexed | StringHasherFlag.PrefixId | StringHasherFlag.PrefixIdIndex | StringHasherFlag.Persistent
const WRITABLE_FLAGS = KNOWN_FLAGS | StringHasherFlag.Marked
const hasFlag = (flags: number, flag: StringHasherFlag) => (flags & flag) !== 0

const fail = (message: string): never => { throw new Error(`StringHasher parse error: ${message}`) }
const parseHex = (value: string, label: string): number => {
  if (!/^(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value)) fail(`${label} is not hexadecimal: ${value}`)
  const result = Number.parseInt(value, 16)
  if (!Number.isSafeInteger(result) || result < 0 || result > MAX_ID) fail(`${label} is out of range`)
  return result
}
const parseSignedHex = (value: string, label: string): number => value.startsWith('-') ? -parseHex(value.slice(1), label) : parseHex(value, label)
const checkedHex = (value: number, label: string): string => {
  if (!Number.isSafeInteger(value) || value < 0 || value > MAX_ID) throw new RangeError(`StringHasher ${label} is out of range`)
  return value.toString(16)
}

const migrateEntry = (entry: StringHasherEntry): StringHasherEntry => ({ ...entry, relatedIds: [...entry.relatedIds] })

export const migrateStringHasherSchema = (table: AnyStringHasherTable): StringHasherTable => ({
  schemaVersion: 2,
  nativeVersion: table.nativeVersion,
  entries: table.entries.map(migrateEntry),
})

export const cloneStringHasherTable = (table: AnyStringHasherTable): StringHasherTable => migrateStringHasherSchema(table)

const readToken = (text: string, state: { offset: number }): string => {
  while (state.offset < text.length && /\s/.test(text[state.offset])) state.offset += 1
  const start = state.offset
  while (state.offset < text.length && !/\s/.test(text[state.offset])) state.offset += 1
  if (start === state.offset) fail('unexpected end of table')
  return text.slice(start, state.offset)
}

const readLinePayload = (text: string, state: { offset: number }): string => {
  while (state.offset < text.length && /[ \t]/.test(text[state.offset])) state.offset += 1
  const start = state.offset
  while (state.offset < text.length && /[0-9a-fA-F]/.test(text[state.offset])) state.offset += 1
  if (state.offset === start || text[state.offset] !== ':') fail('invalid TextOutputStream payload prefix')
  const lineCount = Number.parseInt(text.slice(start, state.offset), 16)
  state.offset += 1
  let value = ''
  for (let line = 0; line < lineCount; line += 1) {
    const newline = text.indexOf('\n', state.offset)
    if (newline < 0) fail('truncated multiline StringHasher payload')
    value += text.slice(state.offset, newline + 1)
    state.offset = newline + 1
  }
  const newline = text.indexOf('\n', state.offset)
  if (newline < 0) fail('truncated StringHasher payload delimiter')
  value += text.slice(state.offset, newline)
  state.offset = newline + 1
  return value
}

const readRawPayload = (text: string, state: { offset: number }): string => {
  while (state.offset < text.length && /\s/.test(text[state.offset])) state.offset += 1
  const start = state.offset
  while (state.offset < text.length && !/\s/.test(text[state.offset])) state.offset += 1
  if (start === state.offset) fail('missing postfixed StringHasher payload')
  return text.slice(start, state.offset)
}

const decodeRelatedIds = (id: number, parts: string[], previous: StringHasherEntry | undefined, relative: boolean): number[] => {
  const result: number[] = []
  let position = 0
  if (relative && previous) {
    for (; position < parts.length && position < previous.relatedIds.length; position += 1) {
      const value = previous.relatedIds[position] + parseSignedHex(parts[position], `related ID ${position}`)
      if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_ID) fail(`related ID ${value} is out of range`)
      result.push(value)
    }
  }
  for (; position < parts.length; position += 1) {
    const value = relative ? id - parseHex(parts[position], `relative related ID ${position}`) : parseHex(parts[position], `related ID ${position}`)
    if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_ID) fail(`related ID ${value} is out of range`)
    result.push(value)
  }
  return result
}

export const parseStringHasherTable = (text: string): StringHasherTableV1 => {
  const normalized = text.replace(/\r\n/g, '\n')
  const headerEnd = normalized.indexOf('\n')
  if (headerEnd < 0) fail('missing StringTableStart header')
  const header = normalized.slice(0, headerEnd).trim().match(/^StringTableStart\s+v(\d+)\s+(\d+)$/)
  if (!header) throw new Error('StringHasher parse error: expected StringTableStart v1 <count>')
  if (Number(header[1]) !== 1) fail('expected StringTableStart v1 <count>')
  const count = Number(header[2])
  if (!Number.isSafeInteger(count) || count < 0 || count > MAX_ENTRIES) fail('entry count is out of range')
  const state = { offset: headerEnd + 1 }
  const entries: StringHasherEntry[] = []
  const entriesById = new Map<number, StringHasherEntry>()
  let lastId = 0
  let previous: StringHasherEntry | undefined
  for (let ordinal = 0; ordinal < count; ordinal += 1) {
    while (state.offset < normalized.length && /\s/.test(normalized[state.offset])) state.offset += 1
    const rawStart = state.offset
    const meta = readToken(normalized, state)
    const parts = meta.split('.')
    if (parts.length < 2) fail(`entry ${ordinal} has no flags`)
    const relative = parts[0].startsWith('-')
    const id = relative ? lastId + parseHex(parts[0].slice(1), `entry ${ordinal} relative id`) : parseHex(parts[0], `entry ${ordinal} id`)
    if (id <= lastId || id <= 0) fail(`entry ${ordinal} ID ${id} is not strictly increasing`)
    const flags = parseHex(parts[1], `entry ${ordinal} flags`)
    if ((flags & ~KNOWN_FLAGS) !== 0) fail(`entry ${ordinal} has unknown flags 0x${flags.toString(16)}`)
    const relatedIds = decodeRelatedIds(id, parts.slice(2), previous, relative)
    let data = ''
    let postfix = ''
    if (!hasFlag(flags, StringHasherFlag.Postfixed)) {
      data = readLinePayload(normalized, state)
    } else {
      if (!hasFlag(flags, StringHasherFlag.Indexed) && !hasFlag(flags, StringHasherFlag.PrefixId) && !hasFlag(flags, StringHasherFlag.PrefixIdIndex)) data = readRawPayload(normalized, state)
      if (!hasFlag(flags, StringHasherFlag.PostfixEncoded)) postfix = readRawPayload(normalized, state)
      while (state.offset < normalized.length && /\s/.test(normalized[state.offset])) state.offset += 1
      const relatedOffset = hasFlag(flags, StringHasherFlag.PostfixEncoded) ? 1 : 0
      if (hasFlag(flags, StringHasherFlag.PostfixEncoded)) {
        const related = entriesById.get(relatedIds[0])
        if (!related) throw new Error(`StringHasher parse error: entry ${ordinal} has no resolvable postfix StringID`)
        postfix = related.data
      }
      if (hasFlag(flags, StringHasherFlag.Indexed)) {
        const related = entriesById.get(relatedIds[relatedOffset])
        if (!related) throw new Error(`StringHasher parse error: entry ${ordinal} has no resolvable indexed prefix StringID`)
        data = related.data
      } else if (hasFlag(flags, StringHasherFlag.PrefixId) || hasFlag(flags, StringHasherFlag.PrefixIdIndex)) {
        const prefixId = relatedIds[relatedOffset]
        if (!entriesById.has(prefixId)) fail(`entry ${ordinal} has no resolvable prefix StringID`)
        data = `#${prefixId.toString(16)}${hasFlag(flags, StringHasherFlag.PrefixIdIndex) ? ':' : ''}`
      }
    }
    const entry: StringHasherEntry = { id, flags, relatedIds, data, postfix, raw: normalized.slice(rawStart, state.offset).trim() }
    entries.push(entry)
    entriesById.set(id, entry)
    lastId = id
    previous = entry
  }
  const trailing = normalized.slice(state.offset).trim()
  if (trailing) fail(`unexpected trailing table content: ${trailing.slice(0, 40)}`)
  return { schemaVersion: 1, nativeVersion: 1, entries }
}

const encodedText = (value: string): string => {
  const lineCount = [...value].filter((character) => character === '\n').length
  return `${lineCount.toString(16)}:${value.replace(/\r\n/g, '\n')}\n`
}

export const writeStringHasherTable = (table: AnyStringHasherTable): string => {
  const value = migrateStringHasherSchema(table)
  const validation = validateStringHasherTable(value)
  if (!validation.valid) throw new RangeError(`StringHasher validation error: ${validation.issues[0].path}: ${validation.issues[0].message}`)
  let output = `StringTableStart v1 ${value.entries.length}\n`
  let anchor = 0
  let lastId = 0
  let previous: StringHasherEntry | undefined
  for (const entry of value.entries) {
    const relative = entry.id - anchor < 1000
    const idToken = relative ? `-${checkedHex(entry.id - lastId, 'relative ID')}` : checkedHex(entry.id, 'ID')
    if (!relative) anchor = entry.id
    const flags = entry.flags & ~0x100
    if (!Number.isSafeInteger(flags) || flags < 0 || (flags & ~KNOWN_FLAGS) !== 0) throw new RangeError(`StringHasher flags out of range for ID ${entry.id}`)
    const related: string[] = []
    if (relative && previous) {
      let position = 0
      for (; position < entry.relatedIds.length && position < previous.relatedIds.length; position += 1) {
        const delta = entry.relatedIds[position] - previous.relatedIds[position]
        related.push(delta < 0 ? `-${checkedHex(-delta, 'related ID delta')}` : checkedHex(delta, 'related ID delta'))
      }
      for (; position < entry.relatedIds.length; position += 1) related.push(checkedHex(entry.id - entry.relatedIds[position], 'relative related ID'))
    } else related.push(...entry.relatedIds.map((id) => checkedHex(id, 'related ID')))
    let line = [idToken, checkedHex(flags, 'flags'), ...related].join('.')
    if (!hasFlag(flags, StringHasherFlag.Postfixed)) {
      output += `${line} ${encodedText(entry.data)}`
    } else {
      if (!hasFlag(flags, StringHasherFlag.Indexed) && !hasFlag(flags, StringHasherFlag.PrefixId) && !hasFlag(flags, StringHasherFlag.PrefixIdIndex)) line += ` ${entry.data}`
      if (!hasFlag(flags, StringHasherFlag.PostfixEncoded)) line += ` ${entry.postfix}`
      output += `${line}\n`
    }
    lastId = entry.id
    previous = entry
  }
  return output
}

export const validateStringHasherTable = (table: AnyStringHasherTable): StringHasherValidationReport => {
  const value = migrateStringHasherSchema(table)
  const issues: StringHasherValidationIssue[] = []
  const ids = new Set<number>()
  let previous = 0
  for (const [index, entry] of value.entries.entries()) {
    const path = `entries[${index}]`
    if (!Number.isSafeInteger(entry.id) || entry.id <= previous || entry.id > MAX_ID || ids.has(entry.id)) issues.push({ code: 'ID', path: `${path}.id`, message: `ID ${entry.id} must be strictly increasing and unique` })
    ids.add(entry.id); previous = entry.id
    if ((entry.flags & ~WRITABLE_FLAGS) !== 0) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: `unknown flags 0x${entry.flags.toString(16)}` })
    const prefixFlags = [StringHasherFlag.Indexed, StringHasherFlag.PrefixId, StringHasherFlag.PrefixIdIndex].filter((flag) => hasFlag(entry.flags, flag))
    if (prefixFlags.length > 1) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: 'Indexed, PrefixId and PrefixIdIndex are mutually exclusive' })
    if (!hasFlag(entry.flags, StringHasherFlag.Postfixed) && (hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) || prefixFlags.length > 0)) issues.push({ code: 'FLAGS', path: `${path}.flags`, message: 'postfix/prefix encoding flags require Postfixed' })
    const relatedOffset = hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) ? 1 : 0
    if (hasFlag(entry.flags, StringHasherFlag.Postfixed) && hasFlag(entry.flags, StringHasherFlag.PostfixEncoded) && entry.relatedIds.length < 1) issues.push({ code: 'PAYLOAD', path, message: 'PostfixEncoded entry has no related postfix ID' })
    if (hasFlag(entry.flags, StringHasherFlag.Postfixed) && prefixFlags.length > 0 && entry.relatedIds.length <= relatedOffset) issues.push({ code: 'PAYLOAD', path, message: 'encoded prefix entry has no related prefix ID' })
    for (const [sidIndex, sid] of entry.relatedIds.entries()) if (!ids.has(sid)) issues.push({ code: 'RELATED_ID', path: `${path}.relatedIds[${sidIndex}]`, message: `missing prior StringID ${sid}` })
  }
  if (value.schemaVersion !== 2) issues.unshift({ code: 'SCHEMA_VERSION', path: 'schemaVersion', message: `unsupported schema version ${value.schemaVersion}` })
  if (value.nativeVersion !== 1) issues.unshift({ code: 'NATIVE_VERSION', path: 'nativeVersion', message: `unsupported native version ${value.nativeVersion}` })
  return { valid: issues.length === 0, entryCount: value.entries.length, issues }
}

export const validateElementMap2StringHasherEvidence = (document: import('./elementMap2').AnyElementMap2Document, table: AnyStringHasherTable): ElementMap2StringHasherEvidenceIssue[] => {
  const ids = new Set(migrateStringHasherSchema(table).entries.map((entry) => entry.id))
  const issues: ElementMap2StringHasherEvidenceIssue[] = []
  for (const [mapIndex, map] of document.maps.entries()) for (const [sectionIndex, section] of map.sections.entries()) {
    for (const [childIndex, child] of section.children.entries()) for (const [sidIndex, sid] of child.stringIds.entries()) if (!ids.has(sid)) issues.push({ code: 'TOKEN_ID', path: `maps[${mapIndex}].sections[${sectionIndex}].children[${childIndex}].stringIds[${sidIndex}]`, message: `missing StringHasher ID ${sid}` })
    for (const [nameIndex, name] of section.names.entries()) for (const [tokenIndex, token] of name.tokens.entries()) {
      const tokenPath = `maps[${mapIndex}].sections[${sectionIndex}].names[${nameIndex}].tokens[${tokenIndex}]`
      // `$#<id>[:<index>]` stores the prefix StringID in the mapped name, not
      // in the ElementMap suffix.  It is still required native evidence.
      if (token.marker === '$' && token.name) {
        const prefix = token.name.match(/^#([0-9a-fA-F]+)(?::[0-9a-fA-F]+)?$/)
        if (!prefix) issues.push({ code: 'PREFIX_ID', path: `${tokenPath}.name`, message: `invalid mapped-name prefix ${token.name}` })
        else {
          const sid = Number.parseInt(prefix[1], 16)
          if (!ids.has(sid)) issues.push({ code: 'PREFIX_ID', path: `${tokenPath}.name`, message: `missing prefix StringHasher ID ${sid}` })
        }
      }
      for (const [sidIndex, part] of token.suffix.slice(1).entries()) {
        const sid = Number.parseInt(part, 16)
        if (!ids.has(sid)) issues.push({ code: 'TOKEN_ID', path: `${tokenPath}.suffix[${sidIndex + 1}]`, message: `missing StringHasher ID ${sid}` })
      }
    }
  }
  return issues
}
