// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
// the end releases the pool so the process exits cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
// bits that decide what the anonymous public may and may not see:
//   - getFeed serves only kinds on the public allowlist (staff audit / cheat /
//     login events are stored for the admin channel and must never leak here);
//   - getHouses exposes only IDOC houses and only their location — owner, price,
//     co-owners and decay detail are staff-only and must be stripped;
//   - getStatus assembles the connection/economy summary;
//   - a model failure degrades to a 500, never a thrown/uncaught error.
const ctrl = require('../src/router/v1/public/shard.controller')
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
const shardState = require('../src/model/shardState/shardState.model')
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../src/utils/shardBroadcast')
const visibility = require('../src/utils/shardVisibility')
const db = require('../src/utils/db')

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

// The controller now resolves the visibility config and the caller's rung on
// every read. Stub the MODEL rather than the util's exports: getConfig() and
// project() call the module-internal getConfig, which an exports-level stub does
// not intercept — it would still hit the closed DB port and cost a ~10s pool
// timeout per test before falling back to these same defaults.
const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model')
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'

const DEFAULTS = visibility.compileDefaults()

function mockRes() {
  return {
    statusCode: 200,
    body: null,
    status(c) {
      this.statusCode = c
      return this
    },
    json(b) {
      this.body = b
      return this
    },
  }
}

const originals = {
  eventsList: shardEvents.list,
  listIdoc: shardState.listIdoc,
  onlineCount: shardState.onlineCount,
  latestEconomy: shardState.latestEconomy,
  getRuleset: shardState.getRuleset,
  getSafe: uoLinkConfig.getSafe,
}
afterEach(() => {
  shardEvents.list = originals.eventsList
  shardState.listIdoc = originals.listIdoc
  shardState.onlineCount = originals.onlineCount
  shardState.latestEconomy = originals.latestEconomy
  shardState.getRuleset = originals.getRuleset
  uoLinkConfig.getSafe = originals.getSafe
})

// ── getFeed: the public-safe allowlist is a security boundary ───────────
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
  let queried = false
  shardEvents.list = async () => {
    queried = true
    return [{ kind: 'staff.audit' }]
  }
  const res = mockRes()
  await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
  assert.deepEqual(res.body, [])
  assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
})

test('getFeed serves a specific kind when it IS public-safe', async () => {
  const publicKind = [...broadcast.PUBLIC_KINDS][0]
  let seen
  shardEvents.list = async (opts) => {
    seen = opts
    return [{ kind: publicKind }]
  }
  const res = mockRes()
  await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
  assert.equal(seen.kind, publicKind)
  assert.equal(seen.limit, 5)
  assert.equal(res.body[0].kind, publicKind)
})

test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => {
  let seen
  shardEvents.list = async (opts) => {
    seen = opts
    return []
  }
  await ctrl.getFeed({ query: {} }, mockRes())
  // Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so
  // an admin re-gating a feature takes effect on the stored history too.
  assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS)))
  // Sanity: a known admin-only kind is absent from what the public feed queries.
  assert.ok(!seen.kinds.includes('staff.audit'))
  // The `stream` flag governs SSE fan-out only, so a feature whose live firehose
  // ships off is still readable from history — the one way this set is WIDER
  // than PUBLIC_KINDS.
  for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind))
  assert.ok(seen.kinds.includes('vendor.listing'))
  assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing'))
})

test('getFeed projects each row against ITS OWN kind\'s feature', async () => {
  shardEvents.list = async () => [
    {
      id: 1,
      kind: 'player.death',
      payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } },
    },
    {
      id: 2,
      kind: 'guild.join',
      payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } },
    },
  ]
  const res = mockRes()
  await ctrl.getFeed({ query: {} }, res)
  for (const row of res.body) {
    assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`)
    assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`)
    assert.ok(row.payload.actor.name, 'the in-game name is still public')
  }
})

test('getFeed serves nothing when the viewer may read no kinds at all', async () => {
  let queried = false
  shardEvents.list = async () => {
    queried = true
    return [{ kind: 'staff.audit' }]
  }
  const allGated = Object.fromEntries(
    Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]),
  )
  visibility.getConfig = async () => allGated
  const res = mockRes()
  await ctrl.getFeed({ query: {} }, res)
  visibility.getConfig = async () => DEFAULTS
  assert.deepEqual(res.body, [])
  // An empty allowlist must never fall through to an unfiltered "give me
  // everything" query.
  assert.equal(queried, false)
})

// ── getHouses: the public house view must strip owner/price ─────────────
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
  shardState.listIdoc = async () => [
    {
      serial: 1,
      name: 'Keep',
      region: 'Britain',
      map: 'Felucca',
      x: 1,
      y: 2,
      z: 3,
      // The following are staff-only and must NOT appear in the public payload:
      ownerName: 'Lord British',
      ownerAcct: 'secret',
      price: 999999,
      coOwners: 'a,b',
      decay: 'IDOC',
    },
  ]
  const res = mockRes()
  await ctrl.getHouses({}, res)
  const [h] = res.body
  assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
  assert.equal(h.isIdoc, true)
  assert.equal(h.ownerName, undefined)
  assert.equal(h.price, undefined)
  assert.equal(h.coOwners, undefined)
})

// ── getIdoc: the flattened owner fields are a security boundary too ──────
test('getIdoc never serves the owner game account to a viewer below admin', async () => {
  shardState.listIdoc = async () => [
    {
      serial: '0x1',
      name: 'Marble Tower',
      region: 'Britain',
      map: 'Felucca',
      x: 1,
      y: 2,
      z: 3,
      ownerSerial: '0x2A01',
      ownerName: 'Sir Cadmus',
      ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct`
      price: 1250000,
      isIdoc: true,
    },
  ]
  for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
    const res = mockRes()
    await ctrl.getIdoc({ viewerLevel: level }, res)
    assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`)
  }
  const res = mockRes()
  await ctrl.getIdoc({ viewerLevel: 'admin' }, res)
  assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it')
})

test('getIdoc gates owner identity and price at `staff`, but never the location', async () => {
  shardState.listIdoc = async () => [
    { serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3,
      ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true },
  ]
  const anon = mockRes()
  await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon)
  assert.equal(anon.body[0].ownerName, undefined)
  assert.equal(anon.body[0].ownerSerial, undefined)
  assert.equal(anon.body[0].price, undefined)
  // The public IDOC board still renders: name, region and location survive.
  assert.equal(anon.body[0].name, 'Marble Tower')
  assert.equal(anon.body[0].region, 'Britain')
  assert.equal(anon.body[0].map, 'Felucca')

  const staff = mockRes()
  await ctrl.getIdoc({ viewerLevel: 'staff' }, staff)
  assert.equal(staff.body[0].ownerName, 'Sir Cadmus')
  assert.equal(staff.body[0].price, 1250000)
})

test('getIdoc preserves Date columns rather than flattening them to {}', async () => {
  const when = new Date('2026-07-06T19:32:29.000Z')
  shardState.listIdoc = async () => [
    { serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when },
  ]
  const res = mockRes()
  await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res)
  assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact')
  assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
})

// ── getRuleset: "never published" is a real answer ──────────────────────
test('getRuleset serves null when the shard has never published a ruleset', async () => {
  shardState.getRuleset = async () => null
  const res = mockRes()
  await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
  // Deliberately null, not {} — the page says "not published yet" rather than
  // rendering an empty ruleset as though the shard had no rules.
  assert.equal(res.body, null)
  assert.equal(res.statusCode, 200)
})

test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
  shardState.getRuleset = async () => ({
    kind: 'world.ruleset',
    rev: '1a2b3c4d',
    shard: 'UOMysticmoon',
    expansion: 'EJ',
    systems: { cityLoyalty: true, vvv: true, factions: false },
    caps: { skill: 1000, totalSkill: 7000, stat: 225 },
    champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
  })
  const res = mockRes()
  await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
  assert.equal(res.body.expansion, 'EJ')
  assert.equal(res.body.systems.vvv, true)
  assert.equal(res.body.caps.totalSkill, 7000)
  // Arrays must survive projection as arrays, not become objects.
  assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
})

// §3.6.1's rule: a read path that returns shard data and does not project is a
// bug. The ruleset frame carries no actor today, but it goes through the same
// gate — so a future block that does cannot leak.
test('getRuleset projects: acct/webId never survive below admin', async () => {
  shardState.getRuleset = async () => ({
    expansion: 'EJ',
    connect: 'play.example.com,2593',
    owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
  })
  for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
    const res = mockRes()
    await ctrl.getRuleset({ viewerLevel: level }, res)
    assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
    assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
    // `connect` defaults to the anonymous rung: an operator who published it
    // meant it to be readable.
    assert.equal(res.body.connect, 'play.example.com,2593')
  }
})

// ── points boards ──────────────────────────────────────────────────────
const BOARD = {
  system: 'QueensLoyalty',
  nameString: "Queen's Loyalty",
  nameNumber: 1114938,
  maxPoints: 30000,
  players: 842,
  top: [
    { rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
    { rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
  ],
}

test('getPointsBoards serves every board with its ranked list intact', async () => {
  shardState.listPointsBoards = async () => [BOARD]
  const res = mockRes()
  await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
  assert.equal(res.body.length, 1)
  assert.equal(res.body[0].system, 'QueensLoyalty')
  // The ranked list is an ARRAY through projection, not an object keyed 0/1 —
  // the same trap the ruleset's rankThresholds assertion guards.
  assert.ok(Array.isArray(res.body[0].top))
  assert.equal(res.body[0].top[1].name, 'Mireille')
})

test('getPointsBoards serves an empty list before the shard has published any', async () => {
  shardState.listPointsBoards = async () => []
  const res = mockRes()
  await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
  assert.deepEqual(res.body, [])
  assert.equal(res.statusCode, 200)
})

// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
// no actor today — they write entries inline as {serial, name} precisely so they
// never carry acct/webId — but the gate is what keeps that true if the shape grows.
test('getPointsBoard projects: acct/webId never survive below admin', async () => {
  shardState.getPointsBoard = async () => ({
    system: 'QueensLoyalty',
    top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
  })
  for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
    const res = mockRes()
    await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
    assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
    assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
    assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
  }
})

// "No such system" and "a board nobody has scored in" are different answers.
test('getPointsBoard 404s for a system the shard has never published', async () => {
  shardState.getPointsBoard = async () => null
  const res = mockRes()
  await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
  assert.equal(res.statusCode, 404)
})

test('getPointsBoard rejects a malformed system name before touching the model', async () => {
  let queried = false
  shardState.getPointsBoard = async () => { queried = true; return null }
  for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
    const res = mockRes()
    await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
    assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
  }
  assert.equal(queried, false, 'a malformed name must never reach the query')
})

test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
  shardState.listPointsBoards = async () => {
    throw new Error('pool down')
  }
  const res = mockRes()
  await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
  assert.equal(res.statusCode, 500)
  assert.equal(res.body.message, 'Internal Server Error')
})

test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
  shardState.getRuleset = async () => {
    throw new Error('pool down')
  }
  const res = mockRes()
  await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
  assert.equal(res.statusCode, 500)
  assert.equal(res.body.message, 'Internal Server Error')
})

// ── getStatus assembles the summary ─────────────────────────────────────
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
  uoLinkConfig.getSafe = async () => ({
    enabled: true,
    status: 'connected',
    pluginConnected: true,
    lastEventAt: 'ts',
  })
  shardState.onlineCount = async () => 12
  shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
  const res = mockRes()
  await ctrl.getStatus({}, res)
  assert.equal(res.body.enabled, true)
  assert.equal(res.body.onlineCount, 12)
  assert.equal(res.body.economy.gold, 100)
})

test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
  uoLinkConfig.getSafe = async () => {
    throw new Error('pool down')
  }
  const res = mockRes()
  await ctrl.getStatus({}, res) // must resolve, not reject
  assert.equal(res.statusCode, 500)
  assert.equal(res.body.message, 'Internal Server Error')
})
