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

const {
  ClilocSourceError,
  CUSTOM_DIR,
  resolveBase,
  listCustom,
  readSources,
  hashSources,
  sameSources,
  missingSources,
  readCliloc,
} = require('../src/utils/clilocSource')

// The fs layer, exercised against real temp directories rather than mocks —
// the behaviours that matter here (which file wins, what a directory listing
// yields, what happens when one vanishes) are precisely the ones a mock would
// define away.

function tmpdir() {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-'))
  return dir
}

const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n'

function write(dir, name, contents) {
  const file = path.join(dir, name)
  fs.mkdirSync(path.dirname(file), { recursive: true })
  fs.writeFileSync(file, contents)
  return file
}

// ── Resolution ─────────────────────────────────────────────────────────────

test('resolveBase: a directory picks the most specific candidate', () => {
  const dir = tmpdir()
  // A converted file sitting next to the client's own compressed one must win —
  // otherwise pointing at a client folder finds the file that will be rejected.
  write(dir, 'cliloc.enu', 'ignored')
  write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
  assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv')
})

test('resolveBase: a file path roots overlays at its DIRECTORY', () => {
  // An operator who pointed at a file should not have to re-point at its folder
  // just to add a custom/ directory beside it.
  const dir = tmpdir()
  const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  assert.deepEqual(resolveBase(file), { root: dir, base: file })
})

test('resolveBase: a missing path and an empty path are different errors', () => {
  assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH')
  assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND')
})

test('resolveBase: a directory with no cliloc file names what it looked for', () => {
  assert.throws(() => resolveBase(tmpdir()), (err) => {
    assert.ok(err instanceof ClilocSourceError)
    assert.equal(err.code, 'NO_FILE')
    assert.match(err.message, /clilocs\.tsv/)
    return true
  })
})

// ── Overlays ───────────────────────────────────────────────────────────────

test('listCustom: no overlay directory is normal, not an error', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  assert.deepEqual(listCustom(dir), [])
})

test('listCustom: sorted, and only recognised extensions', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']]))
  write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']]))
  write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me')
  assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv'])
})

test('readSources: base first, then overlays, with root-relative labels', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
  const { files } = readSources(dir)
  // Forward-slashed so the same directory read on Windows and Linux fingerprints
  // identically — otherwise every boot on one of them looks like a change.
  assert.deepEqual(files.map((f) => [f.label, f.kind]), [
    ['clilocs.tsv', 'base'],
    ['custom/shard.tsv', 'custom'],
  ])
})

// ── Merging ────────────────────────────────────────────────────────────────

test('readCliloc: an overlay ADDS ids the base never had', () => {
  // The whole point: shards add items, and those carry cliloc ids no stock
  // client table has.
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']]))
  const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
  assert.equal(byNumber.get(1023721), 'quarter staff')
  assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil')
})

test('readCliloc: an overlay OVERRIDES a stock id', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']]))
  const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
  assert.equal(byNumber.get(1023721), 'gnarled staff of testing')
})

test('readCliloc: later overlays beat earlier ones, deterministically', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[7, 'base']]))
  write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']]))
  write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']]))
  const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
  assert.equal(byNumber.get(7), 'second')
})

test('readCliloc: reports what each source contributed', () => {
  // An operator who adds an overlay wants to see it took effect; "overrode: 0"
  // on a file meant to re-label stock items says it did not.
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']]))
  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']]))
  const { source } = readCliloc(dir)
  assert.deepEqual(source.sources, [
    { label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 },
    { label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 },
  ])
})

test('readCliloc: a malformed overlay names the file it came from', () => {
  // "Which of my six overlay files is broken" is otherwise a guessing game.
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n')
  assert.throws(() => readCliloc(dir), (err) => {
    assert.equal(err.code, 'EMPTY')
    assert.match(err.message, /^custom\/broken\.tsv: /)
    return true
  })
})

test('readCliloc: a compressed BASE is still rejected by name', () => {
  const dir = tmpdir()
  write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)]))
  assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED')
})

// ── Drift over the SET ─────────────────────────────────────────────────────

test('hashSources: fingerprints every source, and counts the overlays', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
  const fp = hashSources(dir)
  assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv'])
  assert.equal(fp.customCount, 1)
})

test('sameSources: adding or editing an overlay counts as drift', () => {
  const dir = tmpdir()
  write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
  const before = hashSources(dir).hashes

  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
  const added = hashSources(dir).hashes
  assert.equal(sameSources(before, added), false, 'a new overlay is drift')

  write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']]))
  const edited = hashSources(dir).hashes
  assert.equal(sameSources(added, edited), false, 'an edited overlay is drift')
  assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not')
})

test('missingSources: a vanished source is detected, an added one is not "missing"', () => {
  const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' }
  assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv'])
  assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), [])
  // Nothing loaded yet (a first import) is not a vanished source.
  assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), [])
})
