// Point the DB at a closed port BEFORE requiring anything that builds a pool.
// Every DB call this suite would make is monkeypatched.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

// Unit-test the visibility framework's INVARIANTS — the rules that make it a
// security boundary rather than a convenience filter (docs/link/v3.md §3):
//
//   1. acct / webId are admin-only ALWAYS and cannot be configured down.
//   2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
//   3. The compiled defaults reproduce pre-v3 behavior, so installing this
//      module changes nothing until an admin edits the config.
//   4. The ladder is ordered and each rung implies the ones below it.

const visibility = require('../src/utils/shardVisibility')
const model = require('../src/model/shardVisibility/shardVisibility.model')
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
const db = require('../src/utils/db')

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

const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }

// Default both DB reads to "no rows" so a test that doesn't care never blocks on
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
// Tests that exercise stored config or a DB failure override these.
beforeEach(() => {
  model.listAll = async () => []
  shardLinks.listForUser = async () => []
  visibility.invalidate()
})

afterEach(() => {
  model.listAll = originals.listAll
  shardLinks.listForUser = originals.listForUser
  visibility.invalidate()
})

// Stub the stored config; the framework merges rows over compiled defaults.
function withRows(rows) {
  model.listAll = async () => rows
  visibility.invalidate()
}

// ── The ladder ─────────────────────────────────────────────────────────────

test('ladder is ordered and each rung implies the ones below it', () => {
  assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
  for (let i = 0; i < visibility.LADDER.length; i += 1) {
    for (let j = 0; j <= i; j += 1) {
      assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
    }
    for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
      assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
    }
  }
})

test('an unknown rung always loses, on BOTH sides of the comparison', () => {
  assert.equal(visibility.isLevel('not-a-rung'), false)

  // An unknown REQUIREMENT is satisfied by nobody below admin...
  for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
    assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
  }
  assert.equal(visibility.meets('admin', 'not-a-rung'), true)

  // ...and an unknown VIEWER level grants nothing. This is the direction that
  // matters: a shared admin fallback would have made a garbage viewer level
  // pass every gate.
  for (const required of visibility.LADDER.slice(1)) {
    assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
    assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
    assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
  }
})

test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
  const config = await visibility.getConfig()
  assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
  assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
  const out = visibility.projectFeature(
    'guilds',
    { leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
    'not-a-rung',
    config,
  )
  assert.equal('acct' in out.leader, false)
  assert.equal('webId' in out.leader, false)
})

// ── Rule 1: locked fields ──────────────────────────────────────────────────

test('acct and webId are stripped below admin regardless of feature config', () => {
  const config = visibility.compileDefaults()
  const frame = {
    kind: 'guild.update',
    name: 'The Nameless',
    leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
  }
  for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
    const out = visibility.projectFeature('guilds', frame, level, config)
    assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
    assert.equal(out.leader.serial, '0x1A2B')
    assert.equal('acct' in out.leader, false, `${level} must not see acct`)
    assert.equal('webId' in out.leader, false, `${level} must not see webId`)
  }
  const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
  assert.equal(asAdmin.leader.acct, 'whitlocktech')
  assert.equal(asAdmin.leader.webId, '42')
})

test('a stored rule trying to loosen a locked field is ignored', async () => {
  withRows([
    { feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
  ])
  const config = await visibility.getConfig()
  const out = visibility.projectFeature(
    'guilds',
    { leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
    'anonymous',
    config,
  )
  assert.equal('acct' in out.leader, false)
  assert.equal('webId' in out.leader, false)
})

test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
  const config = visibility.compileDefaults()
  // shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
  // An exact-key check missed every one of these, which is how GET
  // /public/shard/idoc served the owner's game account to anonymous callers.
  const row = {
    serial: '0x1',
    name: 'Marble Tower',
    ownerAcct: 'cadmus_acct',
    leaderWebId: 42,
    governorAcct: 'blackthorn_acct',
  }
  const out = visibility.projectFeature('houses', row, 'staff', config)
  assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
  assert.equal('leaderWebId' in out, false)
  assert.equal('governorAcct' in out, false)
  assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')

  const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
  assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
})

// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
//
// The leaderboards field rule is spelled `name` because that is the key
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
// "characterName", which describes the meaning — and projectValue matches on the
// literal key, so a rule under that spelling would have been silently inert. This
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
// test is the guard on it: if someone renames the rule back, an admin who tightens
// character names would get no enforcement and no error.
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
  withRows([
    { feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
  ])
  const config = await visibility.getConfig()
  const board = {
    system: 'QueensLoyalty',
    nameString: "Queen's Loyalty",
    top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
  }

  const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
  assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
  assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
  // The BOARD's own display name is a different key and must not be caught by it.
  assert.equal(anon.nameString, "Queen's Loyalty")

  const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
  assert.equal(member.top[0].name, 'Darrow')
})

// Default config: boards are public, exactly as v3.md §7 specifies.
test('leaderboards are anonymous-visible by default, names included', () => {
  const config = visibility.compileDefaults()
  const out = visibility.projectFeature(
    'leaderboards',
    { top: [{ rank: 1, name: 'Darrow', points: 1 }] },
    'anonymous',
    config,
  )
  assert.equal(out.top[0].name, 'Darrow')
  assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
})

test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
  for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
    assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
  }
  // Must not over-match: these are ordinary public fields.
  for (const key of ['name', 'serial', 'ownerName', 'price', 'contact', 'region']) {
    assert.equal(visibility.isLockedField(key), false, `${key} must stay configurable`)
  }
})

test('a Date survives projection instead of collapsing to {}', () => {
  const config = visibility.compileDefaults()
  const when = new Date('2026-07-06T19:32:29.000Z')
  // The DB-backed read models carry real Date columns; rebuilding one key-by-key
  // yields `{}` because a Date has no enumerable own properties.
  const out = visibility.projectFeature('houses', { name: 'Keep', updatedAt: when }, 'anonymous', config)
  assert.ok(out.updatedAt instanceof Date)
  assert.equal(out.updatedAt.toISOString(), when.toISOString())
})

test('visibleKinds tracks live config and stays independent of the stream flag', async () => {
  const config = visibility.compileDefaults()
  assert.ok(visibleIncludes(config, 'anonymous', 'guild.update'))
  // `stream: false` suppresses SSE fan-out only — the stored history stays readable.
  assert.ok(visibleIncludes(config, 'anonymous', 'vendor.listing'))
  assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)

  const gated = { ...config, guilds: { ...config.guilds, audience: 'staff' } }
  assert.equal(visibleIncludes(gated, 'anonymous', 'guild.update'), false)
  assert.ok(visibleIncludes(gated, 'staff', 'guild.update'))

  const off = { ...config, guilds: { ...config.guilds, enabled: false } }
  assert.equal(visibleIncludes(off, 'admin', 'guild.update'), false)
  // Rule 2 still holds: an unmapped kind is in nobody's readable set.
  assert.equal(visibleIncludes(config, 'admin', 'staff.audit'), false)
})

const visibleIncludes = (config, level, kind) => visibility.visibleKinds(level, config).includes(kind)

test('projection recurses into arrays and nested actors', () => {
  const config = visibility.compileDefaults()
  const rows = [
    { city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
    { city: 'Vesper', governor: { name: 'B', acct: 'b' } },
  ]
  const out = visibility.projectFeature('governors', rows, 'anonymous', config)
  assert.equal(out.length, 2)
  assert.equal(out[0].governor.name, 'A')
  assert.equal('acct' in out[0].governor, false)
  assert.equal('webId' in out[0].governor, false)
  assert.equal('acct' in out[1].governor, false)
})

// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────

test('an unmapped kind reaches nobody below admin', async () => {
  const config = await visibility.getConfig()
  for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
    for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
      assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
    }
    assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
  }
})

test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
  assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
  assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
  // house.decay — the IDOC signal the public page renders — IS mapped.
  assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
})

test('vendor.sale is not public (sales are owner-private)', async () => {
  const config = await visibility.getConfig()
  assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
  assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
})

// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────

// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
// or narrowing of what anonymous visitors see and must be reviewed as such.
const PRE_V3_PUBLIC_KINDS = [
  'player.death',
  'player.murdered',
  'mob.killed',
  'house.decay',
  'quest.complete',
  'skill.gain',
  'fame.change',
  'karma.change',
  'mob.login',
  'mob.logout',
  'economy.supply',
  'server.hello',
  'server.shutdown',
  'server.crashed',
  'champ.update',
  'champ.remove',
  'guild.update',
  'guild.remove',
  'guild.join',
  'city.update',
  'presence.online',
  'region.enter',
]

// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']

test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
  assert.deepEqual(
    [...visibility.PUBLIC_KINDS].sort(),
    [...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
  )
})

test('no pre-v3 public kind was dropped', () => {
  for (const kind of PRE_V3_PUBLIC_KINDS) {
    assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
  }
})

test('the market stream is off by default but its REST feature is not', async () => {
  const config = await visibility.getConfig()
  assert.equal(config.market.enabled, true)
  assert.equal(config.market.audience, 'anonymous')
  assert.equal(config.market.stream, false)
  assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
  assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
})

test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
  const config = await visibility.getConfig()
  assert.equal(config.presence.fields.location, 'staff')
  assert.equal(visibility.meets('player', 'staff'), false)
  assert.equal(visibility.meets('staff', 'staff'), true)
})

test('every mapped kind names a real feature', () => {
  for (const [kind, feature] of visibility.KIND_FEATURE) {
    assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
  }
})

// ── Config merge ───────────────────────────────────────────────────────────

test('a disabled feature is invisible to everyone below admin', async () => {
  withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
  const config = await visibility.getConfig()
  assert.equal(config.champs.enabled, false)
  assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
  assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
  assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
})

test('raising a feature audience gates the lower rungs out', async () => {
  withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
  const config = await visibility.getConfig()
  assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
  assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
  assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
  assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
})

test('an unknown stored feature name is ignored, not resurrected', async () => {
  withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
  const config = await visibility.getConfig()
  assert.equal('sekrit' in config, false)
  assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
})

test('an invalid stored rung falls back to the default rather than failing open', async () => {
  withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
  const config = await visibility.getConfig()
  assert.equal(config.houses.audience, 'anonymous') // the compiled default
  assert.equal(config.houses.fields.owner, 'staff') // the compiled default
})

test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
  model.listAll = async () => {
    throw new Error('db down')
  }
  visibility.invalidate()
  const config = await visibility.getConfig()
  assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
  assert.equal(config.presence.fields.location, 'staff')
  assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
})

// ── Viewer level ───────────────────────────────────────────────────────────

test('viewerLevel resolves the ladder from role and link status', async () => {
  shardLinks.listForUser = async () => []
  assert.equal(await visibility.viewerLevel({}), 'anonymous')

  visibility.forgetUser(1)
  assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
  visibility.forgetUser(2)
  assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')

  // A member with no linked game account sits at logged_in...
  visibility.forgetUser(3)
  assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')

  // ...and reaches `player` once a link exists.
  shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
  visibility.forgetUser(4)
  assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
})

test('editor is a content role and gets no shard privilege', async () => {
  // Mapping editor to `staff` here would silently widen what editors can see;
  // today's modAccess gate is admin|moderator only.
  shardLinks.listForUser = async () => []
  visibility.forgetUser(5)
  assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
})

test('a link lookup failure downgrades rather than escalating', async () => {
  shardLinks.listForUser = async () => {
    throw new Error('db down')
  }
  visibility.forgetUser(6)
  assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
})
