// Point the DB pool at a dead port before it's built; every db method is
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const pool = require('../src/utils/db')
after(() => pool.close())

// Unit-test the shard-state model's mapping/derivation rules against a fake
// shardState.db (no DB). These are the transforms the ingest dispatcher and the
// public read endpoints both depend on:
//   - a partial online refresh (char.vitals) only writes the keys it carries, so
//     it never clobbers login-only fields with undefined;
//   - is_idoc is DERIVED from the decay stage, not trusted from the wire;
//   - the economy series is clamped, returned oldest→newest, and gold coerced to
//     a JS number (mariadb hands back BigInt-ish strings for large gold totals);
//   - an empty presence table reads as a well-formed zero snapshot, not null;
//   - champ/guild/governor rows fall back to hoisted columns when payload is absent;
//   - remove/upsert guard against missing identifiers instead of hitting the DB.
const db = require('../src/model/shardState/shardState.db')
const shardState = require('../src/model/shardState/shardState.model')

// Records the (id, fields) the model hands to each db write, and serves canned
// rows back for reads.
let calls
const saved = {}
const DB_KEYS = [
  'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy',
  'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence',
  'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds',
  'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages',
]

beforeEach(() => {
  calls = {}
  for (const k of DB_KEYS) {
    saved[k] = db[k]
    calls[k] = []
    db[k] = async (...args) => {
      calls[k].push(args)
    }
  }
})

afterEach(() => {
  for (const k of DB_KEYS) db[k] = saved[k]
})

// ── partial online refresh must not clobber ─────────────────────────────
test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => {
  // A char.vitals event carries hits but not name/acct — those must not be sent as
  // undefined columns (which would overwrite the login row).
  await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 })
  const [serial, fields] = calls.upsertOnline[0]
  assert.equal(serial, 5)
  assert.deepEqual(fields, { hits: 40, hits_max: 100 })
  assert.ok(!('name' in fields), 'name not written when absent from the event')
})

test('upsertOnline maps camelCase vitals to snake_case columns', async () => {
  await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 })
  const [, fields] = calls.upsertOnline[0]
  assert.equal(fields.web_id, 3)
  assert.equal(fields.hits_max, 90)
  assert.equal(fields.mana_max, 50)
  assert.equal(fields.stam_max, 70)
})

test('upsertOnline ignores an event with no serial (never touches the DB)', async () => {
  await shardState.upsertOnline({ name: 'Nobody' })
  await shardState.upsertOnline(null)
  assert.equal(calls.upsertOnline.length, 0)
})

// ── is_idoc is derived, not trusted ─────────────────────────────────────
test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => {
  await shardState.upsertHouse({ serial: 1, stage: 'IDOC' })
  await shardState.upsertHouse({ serial: 2, stage: 'idoc' })
  await shardState.upsertHouse({ serial: 3, stage: 'Slightly' })
  assert.equal(calls.upsertHouse[0][1].is_idoc, 1)
  assert.equal(calls.upsertHouse[1][1].is_idoc, 1)
  assert.equal(calls.upsertHouse[2][1].is_idoc, 0)
})

test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => {
  await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } })
  const [serial, fields] = calls.upsertHouse[0]
  assert.equal(serial, 7)
  assert.equal(fields.in_registry, 1)
  assert.equal(fields.owner_serial, 20)
  assert.equal(fields.owner_name, 'Liege')
})

test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => {
  await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null })
  const [, fields] = calls.upsertHouse[0]
  assert.equal(fields.owner_serial, null)
  assert.equal(fields.owner_name, null)
  assert.equal(fields.in_registry, 1)
})

// ── economy series shaping ──────────────────────────────────────────────
test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => {
  // db.listEconomy returns newest-first; the model reverses for charting.
  db.listEconomy = async (n) => {
    assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY')
    return [
      { accounts: 3, gold: '9000000000', t: 30 },
      { accounts: 2, gold: '20', t: 20 },
      { accounts: 1, gold: null, t: 10 },
    ]
  }
  const out = await shardState.listEconomy(999999)
  assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first')
  assert.equal(out[2].gold, 9000000000)
  assert.equal(typeof out[2].gold, 'number')
  assert.equal(out[0].gold, null, 'null gold stays null, not 0')
})

test('listEconomy floors a non-positive limit to the default', async () => {
  let seen
  db.listEconomy = async (n) => {
    seen = n
    return []
  }
  await shardState.listEconomy(0)
  assert.equal(seen, 100)
})

// ── presence defaults ───────────────────────────────────────────────────
test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => {
  db.latestPresence = async () => null
  const out = await shardState.latestPresence()
  assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null })
})

test('latestPresence parses JSON string columns from the DB', async () => {
  db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' })
  const out = await shardState.latestPresence()
  assert.equal(out.count, 12)
  assert.deepEqual(out.byFacet, { felucca: 5 })
  assert.equal(out.t, 99)
})

// ── payload fallback shaping ────────────────────────────────────────────
test('listChamps returns the stored payload verbatim when present', async () => {
  const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' }
  db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }]
  const out = await shardState.listChamps()
  assert.deepEqual(out[0], payload)
})

test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => {
  db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }]
  const out = await shardState.listChamps()
  assert.equal(out[0].kind, 'champ.update')
  assert.equal(out[0].name, 'Rikktor')
  assert.equal(out[0].active, true)
  assert.equal(out[0].bossUp, false)
})

test('listGuilds falls back to a shaped leader object when payload is absent', async () => {
  db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }]
  const out = await shardState.listGuilds()
  assert.equal(out[0].leader.serial, 5)
  assert.equal(out[0].leader.name, 'Cap')
})

// ── guards against missing identifiers ──────────────────────────────────
test('remove helpers are no-ops on a falsy id (never call the DB)', async () => {
  await shardState.removeChamp(undefined)
  await shardState.removeHouse('')
  await shardState.removeGuild(null)
  assert.equal(calls.removeChamp.length, 0)
  assert.equal(calls.removeHouse.length, 0)
  assert.equal(calls.removeGuild.length, 0)
})

test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => {
  await shardState.removeGuild(0)
  assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid')
})

test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => {
  await shardState.upsertChamp({ name: 'no serial' })
  await shardState.upsertGuild({ name: 'no id' })
  await shardState.upsertGovernor({ governor: {} }) // no city
  assert.equal(calls.upsertChamp.length, 0)
  assert.equal(calls.upsertGuild.length, 0)
  assert.equal(calls.upsertGovernor.length, 0)
})

// ── read-shaping locks the camelCase API/app contract ───────────────────
// A field-name regression in these serializers silently breaks the public site
// and the Android client, so pin the shapes the read endpoints emit.
test('listOnline maps snake_case columns to the camelCase player shape', async () => {
  db.listOnline = async () => [
    { serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' },
  ]
  const [p] = await shardState.listOnline()
  assert.equal(p.webId, 7)
  assert.equal(p.hitsMax, 100)
  assert.equal(p.manaMax, 50)
  assert.equal(p.stamMax, 60)
  assert.equal(p.updatedAt, 'ts')
  assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape')
})

test('listIdoc shapes houses and coerces isIdoc/price', async () => {
  db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }]
  const [h] = await shardState.listIdoc()
  assert.equal(h.isIdoc, true)
  assert.equal(h.price, 5000)
  assert.equal(typeof h.price, 'number')
  assert.equal(h.inRegistry, true)
})

test('listPages folds the sender columns into a nested actor and coerces flags', async () => {
  db.listPages = async () => [
    { page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null },
  ]
  const [pg] = await shardState.listPages()
  assert.equal(pg.pageId, 42)
  assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 })
  assert.equal(pg.handled, false)
  assert.equal(pg.sentMs, 1234)
})

test('listGovernors falls back to a shaped governor object when payload is absent', async () => {
  db.listGovernors = async () => [
    { city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null },
  ]
  const [g] = await shardState.listGovernors()
  assert.equal(g.kind, 'city.update')
  assert.equal(g.city, 'Britain')
  assert.equal(g.governor.name, 'Lord')
  assert.equal(g.governorElect, null)
})

test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => {
  let seenLimit
  db.listGovernorTerms = async (city, n) => {
    seenLimit = n
    return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }]
  }
  const out = await shardState.listGovernorHistory('Trinsic', 99999)
  assert.equal(seenLimit, 500) // clamped to the 500 max
  assert.equal(out[0].startedAt, 100)
  assert.equal(typeof out[0].startedAt, 'number')
  assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
})
