const { test } = require('node:test')
const assert = require('node:assert/strict')

const {
  ClilocFormatError,
  parseCliloc,
  parseClilocBinary,
  parseClilocText,
  isCompressedCliloc,
  displayText,
  isPlaceholderOnly,
} = require('../src/utils/clilocParse')

// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no UO client and no converted cliloc file. Every fixture below
// is built from the real layout, and the strings are verbatim entries from a
// real Cliloc.enu (123,490 entries) rather than invented ones.

// ── Fixture builders ───────────────────────────────────────────────────────

/** Build a plain-format cliloc buffer: 6-byte header, then records. */
function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) {
  const parts = [Buffer.alloc(6)]
  parts[0].writeInt32LE(header1, 0)
  parts[0].writeUInt16LE(header2, 4)
  for (const e of entries) {
    const text = Buffer.from(e.text, 'utf8')
    const head = Buffer.alloc(7)
    head.writeInt32LE(e.number, 0)
    head.writeUInt8(e.flag ?? 0, 4)
    head.writeUInt16LE(text.length, 5)
    parts.push(head, text)
  }
  return Buffer.concat(parts)
}

// ── Binary ─────────────────────────────────────────────────────────────────

test('parseClilocBinary: reads a plain-format table', () => {
  const buf = buildBinary([
    { number: 1015012, text: 'Greater Heal' },
    { number: 1023721, text: 'quarter staff' },
    { number: 1025913, flag: 1, text: 'bonnet' },
  ])
  assert.deepEqual(parseClilocBinary(buf), [
    { number: 1015012, flag: 0, text: 'Greater Heal' },
    { number: 1023721, flag: 0, text: 'quarter staff' },
    { number: 1025913, flag: 1, text: 'bonnet' },
  ])
})

test('parseClilocBinary: length is UNSIGNED 16-bit', () => {
  // ServUO's own SDK reads this field into a signed short, which turns any
  // string over 32 KB into a negative length. Real tables top out around 12 KB
  // so nothing is broken today, but the field is written unsigned and reading it
  // that way costs nothing.
  const text = 'x'.repeat(40000)
  const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }]))
  assert.equal(entry.text.length, 40000)
})

test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => {
  const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }]))
  assert.equal(entry.text, 'Ilshenar — Ver Lor Reg')
})

test('parseClilocBinary: a truncated record body throws rather than importing short', () => {
  // The realistic corruption is a half-copied file. It must fail loudly: a
  // silently short table renders as "some items named, some not", which is
  // indistinguishable from having no table at all.
  const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }])
  const truncated = buf.subarray(0, buf.length - 4)
  assert.throws(() => parseClilocBinary(truncated), (err) => {
    assert.ok(err instanceof ClilocFormatError)
    assert.equal(err.code, 'TRUNCATED')
    return true
  })
})

test('parseClilocBinary: a truncated record HEADER throws too', () => {
  const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)])
  assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED')
})

test('parseClilocBinary: an empty table (header only) is valid', () => {
  assert.deepEqual(parseClilocBinary(buildBinary([])), [])
})

// ── Compressed detection ───────────────────────────────────────────────────

test('isCompressedCliloc: recognises the Mythic marker', () => {
  // Every cliloc the client ships opens with a DWORD whose high byte is 0x8E.
  // Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e).
  assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true)
  assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true)
  assert.equal(isCompressedCliloc(buildBinary([])), false)
})

test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => {
  // This is the whole reason the marker check exists. Without it the plain
  // parser reads compressed bytes as ~19k records of negative ids and 60 KB
  // "strings" before dying somewhere in the middle — and the resulting error
  // names truncation, which is the wrong problem to hand an operator.
  const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)])
  assert.throws(() => parseCliloc(compressed), (err) => {
    assert.equal(err.code, 'COMPRESSED')
    assert.match(err.message, /CLILOCS\.md/)
    return true
  })
})

// ── Text ───────────────────────────────────────────────────────────────────

test('parseClilocText: tab-delimited, skipping a header row', () => {
  const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n')
  assert.deepEqual(entries, [
    { number: 1023721, flag: 0, text: 'quarter staff' },
    { number: 1015012, flag: 0, text: 'Greater Heal' },
  ])
})

test('parseClilocText: splits on the FIRST separator only', () => {
  // Cliloc text is full of commas. Splitting on all of them would truncate every
  // such entry at its first one.
  const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n')
  assert.equal(entry.text, 'a scroll of magery, unfinished')
})

test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => {
  const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n')
  assert.equal(entry.text, 'a "quarter" staff, plain')
})

test('parseClilocText: reads an optional flag column', () => {
  const [entry] = parseClilocText('1025913\t1\tbonnet\n')
  assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' })
})

test('parseClilocText: text that is itself a number stays the text', () => {
  // `number,text` where text is "100" is indistinguishable from `number,flag`
  // with an empty text. Keeping it as the text is the safer miss — the other way
  // silently deletes a real entry.
  const [entry] = parseClilocText('1000000,100\n')
  assert.equal(entry.text, '100')
})

test('parseClilocText: blank lines and # comments are ignored', () => {
  const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n')
  assert.equal(entries.length, 1)
})

test('parseClilocText: a file with no entries is an error, not an empty table', () => {
  assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY')
})

test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => {
  // `Number('')` is 0, not NaN, so a line that merely starts with a separator
  // would otherwise become a bogus cliloc 0.
  assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY')
})

test('parseClilocText: keeps entries whose text is EMPTY', () => {
  // About half of a real table is empty strings (unused ids). They must survive
  // parsing — the import layer decides whether to store them, and both input
  // formats have to agree on what the file contained.
  const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n')
  assert.equal(entries.length, 2)
  assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' })
})

// ── Sniffing ───────────────────────────────────────────────────────────────

test('parseCliloc: sniffs binary vs text from the header, not the extension', () => {
  assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff')
  assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff')
})

test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => {
  // The recoverable guess: a mis-sniffed text file says "no entries found",
  // while a mis-sniffed binary yields plausible nonsense.
  assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY')
})

// ── Display ────────────────────────────────────────────────────────────────

test('displayText: drops interpolated arguments we never receive', () => {
  // The bridge sends a cliloc id, never the property packet that carries the
  // arguments, so a name containing them has to be reduced to what is knowable.
  assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
  assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the')
})

test('displayText: a string that is nothing but arguments resolves to nothing', () => {
  assert.equal(displayText('[~1_stuff~]'), '')
  assert.equal(isPlaceholderOnly('[~1_stuff~]'), true)
  assert.equal(isPlaceholderOnly('quarter staff'), false)
})

test('displayText: a trailing % is only stripped when a placeholder was removed', () => {
  // "cold damage ~1_val~%" loses its % because that % was the unit belonging to
  // the number we never had. A string that genuinely ends in one keeps it.
  assert.equal(displayText('50%'), '50%')
  assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
})

test('displayText: ordinary names pass through untouched', () => {
  assert.equal(displayText('quarter staff'), 'quarter staff')
  assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished')
  assert.equal(displayText('  spiked collar  '), 'spiked collar')
})

test('displayText: punctuation is only tidied when a placeholder was removed', () => {
  // A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the
  // bracket trim was unconditional. A string with no placeholder has no debris
  // to clean, so it is left alone apart from whitespace.
  assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)')
  assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater')
  assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane')
  // …but the debris a placeholder leaves behind is still cleaned.
  assert.equal(displayText('[~1_stuff~]'), '')
  assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
})

test('displayText: null and undefined are empty, not "null"', () => {
  assert.equal(displayText(null), '')
  assert.equal(displayText(undefined), '')
})
