process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const fs = require('fs')
const os = require('os')
const path = require('path')

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

const {
  AtlasSourceError,
  aggregateCreatures,
  displayName,
  sameSources,
  hashSources,
  buildAtlas,
  PARSER_VERSION,
} = require('../src/utils/spawnAtlasSource')
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')

after(() => db.close())

// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
//
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
// contain a built-in facet list anywhere: a shard may add facets, replace them
// outright, or rename them when its maps are updated, and everything has to keep
// working with no code change.

function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
  fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
  fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
  fs.mkdirSync(path.join(root, 'Config'), { recursive: true })

  for (const facet of facets) {
    fs.writeFileSync(
      path.join(root, 'Spawns', `${facet}.xml`),
      `<Spawns>
        <Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
          <MaxCount>3</MaxCount><IsRunning>True</IsRunning>
          <Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
        <Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
          <MaxCount>1</MaxCount><IsRunning>True</IsRunning>
          <Objects2>lizardman:MX=2:SB=0</Objects2></Points>
        <Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
          <MaxCount>1</MaxCount><IsRunning>False</IsRunning>
          <Objects2>Ghost:MX=1</Objects2></Points>
      </Spawns>`,
      'utf8',
    )
    // The location file names its facet differently from <Map>, the real
    // `Ter Mur` / `Tokuno Islands` drift.
    fs.writeFileSync(
      path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
      `<places><parent name="${facet} Isles"><parent name="Deep Cave">
        <child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
      'utf8',
    )
  }

  fs.writeFileSync(
    path.join(root, 'Data', 'Regions.xml'),
    `<ServerRegions>${facets
      .map(
        (facet) => `<Facet name="${facet}">
        <region type="TownRegion" priority="10" name="${facet} City">
          <rect x="1000" y="1000" width="500" height="500" />
        </region></Facet>`,
      )
      .join('')}</ServerRegions>`,
    'utf8',
  )

  if (includeChampions) {
    fs.writeFileSync(
      path.join(root, 'Config', 'ChampionSpawns.xml'),
      `<championSystem><spawn name="Deep" group="G" type="Terror">
        <location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
      </spawn></championSystem>`,
      'utf8',
    )
  }
}

function tempTree(options) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
  writeTree(root, options)
  return root
}

// ── buildAtlas against a custom-facet tree ─────────────────────────────────

test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
  const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
  const atlas = buildAtlas(root)
  assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
  assert.equal(atlas.meta.counts.facets, 2)
})

test('buildAtlas: reconciles a location file that spells the facet differently', () => {
  // "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
  // silently emptied the Ter Mur / Tokuno landmark buckets.
  const root = tempTree({ facets: ['Sosaria'] })
  const atlas = buildAtlas(root)
  assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
  // And the fallback actually fires, rather than the point reading Wilderness.
  const far = atlas.points.find((p) => p.name === 'SosariaB')
  assert.equal(far.landmark, 'Deep Cave')
  assert.equal(far.label, 'Deep Cave')
})

test('buildAtlas: resolves a contained point to its region', () => {
  const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
  const inCity = atlas.points.find((p) => p.name === 'SosariaA')
  assert.equal(inCity.region, 'Sosaria City')
  assert.equal(inCity.label, 'Sosaria City')
})

test('buildAtlas: drops spawners that are switched off in-world', () => {
  const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
  assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
  assert.equal(atlas.meta.counts.pointsDisabled, 1)
})

test('buildAtlas: a champion altar resolves through the same placement index', () => {
  const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
  assert.equal(atlas.champions[0].label, 'Sosaria City')
  assert.equal(atlas.champions[0].facet, 'Sosaria')
})

test('buildAtlas: a tree with no champion file still builds', () => {
  const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
  assert.deepEqual(atlas.champions, [])
})

test('buildAtlas: missing path and empty path raise typed errors', () => {
  assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
  assert.throws(
    () => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
    (err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
  )
})

test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
  fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
  fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
  assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
})

// ── Hashing ────────────────────────────────────────────────────────────────

test('hashSources: stable across reads, changes when a file changes', () => {
  const root = tempTree({ facets: ['Sosaria'] })
  const first = hashSources(root)
  assert.ok(sameSources(first, hashSources(root)))

  fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
  assert.equal(sameSources(first, hashSources(root)), false)
})

test('sameSources: a missing or extra file is a difference', () => {
  assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
  assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
  assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
  assert.equal(sameSources(null, { a: '1' }), false)
  assert.equal(sameSources({ a: '1' }, null), false)
})

// ── Aggregation ────────────────────────────────────────────────────────────

const POINTS = [
  { facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
  { facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
  { facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
]

test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
  const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
  assert.equal(lizardman.total, 10)
  assert.equal(lizardman.points, 3)
  assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
})

test('aggregateCreatures: differing case collapses to one creature', () => {
  const creatures = aggregateCreatures(POINTS)
  assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
  assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
  assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
})

test('displayName: most common wins, ties break to the capitalised form', () => {
  assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
  assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
  // Deterministic regardless of insertion order — a committed artifact is gone,
  // but a spurious diff in the DB on every restart would be just as wrong.
  assert.equal(
    displayName(new Map([['abc', 1], ['abd', 1]])),
    displayName(new Map([['abd', 1], ['abc', 1]])),
  )
})

test('pointTypeRows: collapses a repeated type to the larger max', () => {
  // The primary key is (point_id, slug), so a duplicate would otherwise fail the
  // insert and take the whole transaction with it.
  const rows = shardAtlas.pointTypeRows([
    { types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
  ])
  assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
})

test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
  const rows = shardAtlas.pointTypeRows([
    { types: [{ type: 'A', max: 1 }] },
    { types: [{ type: 'B', max: 1 }] },
  ])
  assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
})

// ── The refresh decision ───────────────────────────────────────────────────
//
// The boot path's two contracts: it never blocks startup, and it never applies a
// facet removal on its own.

let applied
let pendingRow
let facetsInDb
let metaRow

beforeEach(() => {
  applied = null
  pendingRow = null
  facetsInDb = []
  metaRow = null
  atlasDb.replaceAtlas = async (atlas) => {
    applied = atlas
    return { points: atlas.points.length, creatures: atlas.creatures.length }
  }
  atlasDb.getMeta = async () => metaRow
  atlasDb.getFacets = async () => facetsInDb
  atlasDb.getPending = async () => pendingRow
  atlasDb.setPending = async (payload, status) => {
    pendingRow = { ...payload, status }
  }
  atlasDb.clearPending = async () => {
    pendingRow = null
  }
  settings.get = async () => ''
  process.env.SERVUO_PATH = ''
})

test('refresh: no configured path is skipped, not an error', async () => {
  const result = await shardAtlas.refresh()
  assert.equal(result.status, 'skipped')
})

test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
  const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
  assert.equal(result.status, 'unavailable')
  assert.equal(result.code, 'NOT_FOUND')
})

test('refresh: a fresh database imports', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'imported')
  assert.ok(applied)
  assert.deepEqual(result.addedFacets, ['Sosaria'])
})

test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  metaRow = buildAtlas(root).meta
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'unchanged')
  assert.equal(applied, null)
})

// The hash gate alone would strand an install whose maps never change on
// whatever an older build derived: a corrected parse would ship and never reach
// the data, because the only thing compared is the tree.
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'imported')
  assert.ok(applied)
})

test('refresh: an atlas imported before parser versions existed is stale', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  const meta = buildAtlas(root).meta
  delete meta.parserVersion
  metaRow = meta
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'imported')
})

test('refresh: --force reimports an unchanged tree', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  metaRow = buildAtlas(root).meta
  const result = await shardAtlas.refresh({ path: root, force: true })
  assert.equal(result.status, 'imported')
  assert.ok(applied)
})

test('refresh: a NEW facet applies straight away', async () => {
  // Additions cannot destroy anything an operator would miss.
  const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
  facetsInDb = ['Sosaria']
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'imported')
  assert.deepEqual(result.addedFacets, ['Underdark'])
})

test('refresh: a REMOVED facet is staged, not applied', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  facetsInDb = ['Sosaria', 'Underdark']
  const result = await shardAtlas.refresh({ path: root })
  assert.equal(result.status, 'needsReview')
  assert.deepEqual(result.removedFacets, ['Underdark'])
  // The critical part: the existing atlas was left alone.
  assert.equal(applied, null)
  assert.equal(pendingRow.status, 'pending')
})

test('refresh: approving applies the removal', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  facetsInDb = ['Sosaria', 'Underdark']
  await shardAtlas.refresh({ path: root })
  assert.equal(applied, null)

  const result = await shardAtlas.approvePending({ path: root })
  assert.equal(result.status, 'imported')
  assert.ok(applied)
  assert.deepEqual(result.removedFacets, ['Underdark'])
})

test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  facetsInDb = ['Sosaria', 'Underdark']
  await shardAtlas.refresh({ path: root })
  await shardAtlas.rejectPending()
  assert.equal(pendingRow.status, 'rejected')

  const again = await shardAtlas.refresh({ path: root })
  assert.equal(again.status, 'unchanged')
  assert.equal(applied, null)
})

test('refresh: changing the tree asks again after a rejection', async () => {
  const root = tempTree({ facets: ['Sosaria'] })
  facetsInDb = ['Sosaria', 'Underdark']
  await shardAtlas.refresh({ path: root })
  await shardAtlas.rejectPending()

  fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
  const again = await shardAtlas.refresh({ path: root })
  assert.equal(again.status, 'needsReview')
})

test('refreshOnBoot: never throws, whatever goes wrong', async () => {
  atlasDb.getMeta = async () => {
    throw new Error('database is on fire')
  }
  atlasDb.getFacets = async () => {
    throw new Error('still on fire')
  }
  atlasDb.replaceAtlas = async () => {
    throw new Error('and the import too')
  }
  process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })

  const result = await shardAtlas.refreshOnBoot()
  assert.equal(result.status, 'failed')
})

test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
  process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
  const result = await shardAtlas.refreshOnBoot()
  assert.equal(result.status, 'unavailable')
})

test('refresh: an explicit path overrides the configured one', async () => {
  const configured = tempTree({ facets: ['Configured'] })
  const override = tempTree({ facets: ['Override'] })
  settings.get = async () => configured

  const result = await shardAtlas.refresh({ path: override })
  assert.equal(result.status, 'imported')
  assert.deepEqual(result.addedFacets, ['Override'])
})
