// Point the DB at a closed port BEFORE requiring anything that builds a pool.
// Nothing here reaches the database: these are the model's PURE parts — the
// flatten/shape rules the frame passes through on the way in and out — plus the
// visibility projection over the shapes they produce.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

const market = require('../src/model/shardMarket/shardMarket.model')
const clilocs = require('../src/model/shardClilocs/shardClilocs.model')
const clilocDb = require('../src/model/shardClilocs/shardClilocs.db')
const visibility = require('../src/utils/shardVisibility')
const db = require('../src/utils/db')

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

// Stand in for the cliloc table. Without this each unresolved lookup waits out
// the pool's 10s acquire timeout against the dead port — the model swallows the
// failure exactly as it would in production (an operator who never converted a
// cliloc file is in a supported state), so the RESULT is the same either way;
// this only stops the suite spending half a minute proving it.
const TABLE = new Map([[1023721, 'quarter staff']])
clilocDb.lookup = async (numbers) =>
  numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) }))

const FRAME = {
  kind: 'vendor.listing',
  t: 1000,
  serial: '0x40001234',
  shopName: "Darrow's Bargains",
  ownerSerial: '0x1A2B',
  ownerName: 'Darrow',
  location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
  count: 2,
  total: 2,
  truncated: false,
  items: [],
}

// ── flattenFrame ───────────────────────────────────────────────────────────

test('flattenFrame lifts the nested location into columns', () => {
  const v = market.flattenFrame(FRAME)
  assert.equal(v.serial, '0x40001234')
  assert.equal(v.map, 'Trammel')
  assert.equal(v.x, 1421)
  assert.equal(v.region, 'Britain')
  assert.equal(v.house, "Darrow's Villa")
})

// A vendor standing in the street has no house, and a frame from an older plugin
// may have no location at all. Neither is an error.
test('flattenFrame tolerates a missing location entirely', () => {
  const v = market.flattenFrame({ serial: '0x1', shopName: null })
  assert.equal(v.map, null)
  assert.equal(v.x, null)
  assert.equal(v.region, null)
  assert.equal(v.house, null)
})

// `total` is what the SHOP holds; `count` is what the frame carried. A truncated
// shop must not report its published slice as its size, or the page says
// "showing 250 of 250" for a vendor holding three thousand stacks.
test('flattenFrame keeps the shop total separate from the published count', () => {
  const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true })
  assert.equal(v.itemTotal, 3104)
  assert.equal(v.truncated, true)
})

// An older plugin sends no `total`. Falling back to `count` is right — it is the
// only number available and it is correct whenever nothing was truncated.
test('flattenFrame falls back to count when total is absent', () => {
  const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined })
  assert.equal(v.itemTotal, 7)
})

test('flattenFrame clips over-length strings rather than letting the insert fail', () => {
  const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) })
  assert.equal(v.ownerName.length, 64)
})

// ── shapeItems ─────────────────────────────────────────────────────────────
//
// resolveMany never throws and, with no cliloc table reachable, resolves nothing
// — which is exactly the state of a shard whose operator never converted one, so
// these run against the real function rather than a stub.

test('shapeItems prefers the item\'s literal name over its cliloc', async () => {
  const items = await market.shapeItems({
    items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }],
  })
  assert.equal(items[0].displayName, 'a shard sigil')
  // The cliloc is kept regardless, so a later import can still re-resolve it.
  assert.equal(items[0].cliloc, 1023721)
})

test('shapeItems resolves the cliloc when the item has no literal name', async () => {
  const items = await market.shapeItems({
    items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }],
  })
  assert.equal(items[0].displayName, 'quarter staff')
})

// The supported state for a shard whose operator never converted a cliloc file:
// no name, not a fabricated one. Clients render the item id, exactly as they did
// before the table existed.
test('shapeItems leaves displayName null for an unknown cliloc', async () => {
  const items = await market.shapeItems({
    items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }],
  })
  assert.equal(items[0].displayName, null)
})

// Unpriced rows are inventory, not listings. The shard drops them too; enforcing
// it here as well means a plugin that stops doing so cannot put un-buyable rows
// on the market page.
test('shapeItems drops unpriced listings', async () => {
  const items = await market.shapeItems({
    items: [
      { serial: '0x1', itemId: 1, price: 0 },
      { serial: '0x2', itemId: 2, price: -1 },
      { serial: '0x3', itemId: 3, price: 5 },
    ],
  })
  assert.deepEqual(items.map((i) => i.serial), ['0x3'])
})

test('shapeItems caps a pathological frame', async () => {
  const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({
    serial: `0x${i}`,
    itemId: 1,
    price: 1,
  }))
  const items = await market.shapeItems({ items: many })
  assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR)
})

test('shapeItems tolerates a frame with no items array', async () => {
  assert.deepEqual(await market.shapeItems({}), [])
})

// ── Visibility projection ──────────────────────────────────────────────────
//
// The regression that matters. Part A pre-wired `market.ownerName` and
// `market.location` before the frame existed, and the sibling rule it pre-wired
// for leaderboards (`characterName`) turned out to be INERT because projectValue
// matches literal JSON keys. These assert the market rules actually bite — on the
// read model AND on the wire frame, which is why both carry the same key names.

const config = visibility.compileDefaults()

const listing = market.shapeListing({
  serial: '0x40012ABC',
  item_id: 3922,
  hue: 0,
  amount: 1,
  price: 25000,
  name: null,
  cliloc: 1023721,
  display_name: 'quarter staff',
  child: 0,
  vendor_serial: '0x40001234',
  shop_name: "Darrow's Bargains",
  owner_serial: '0x1A2B',
  owner_name: 'Darrow',
  map: 'Trammel',
  x: 1421,
  y: 1699,
  z: 0,
  region: 'Britain',
  house: "Darrow's Villa",
  updated_at: new Date(0),
})

test('market defaults expose owner and location (they are already public in game)', () => {
  const out = visibility.projectFeature('market', listing, 'anonymous', config)
  assert.equal(out.vendor.ownerName, 'Darrow')
  assert.equal(out.vendor.location.region, 'Britain')
})

test('tightening market.ownerName hides it from below that rung', () => {
  const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } }
  const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
  const staff = visibility.projectFeature('market', listing, 'staff', tightened)
  assert.equal('ownerName' in anon.vendor, false)
  assert.equal(staff.vendor.ownerName, 'Darrow')
  // The shop name is a separate field and must survive — hiding the owner is not
  // the same as hiding the shop.
  assert.equal(anon.vendor.shopName, "Darrow's Bargains")
})

// The whole reason `location` is one nested object: a single rule has to take the
// facet, the coordinates, the region and the house together. Five flat keys would
// be five rules that drift apart.
test('tightening market.location hides the whole location object at once', () => {
  const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } }
  const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
  const player = visibility.projectFeature('market', listing, 'player', tightened)
  assert.equal('location' in anon.vendor, false)
  assert.equal(player.vendor.location.map, 'Trammel')
})

// The same rules must bite on the LIVE frame, not just the stored read model —
// the market's SSE stream is off by default but an admin can turn it on, and a
// field rule that only worked on one of the two paths is exactly the leak §3.6.1
// records.
test('the same rules apply to the raw vendor.listing frame', () => {
  const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } }
  const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened)
  assert.equal('ownerName' in out, false)
  assert.equal('location' in out, false)
  assert.equal(out.shopName, "Darrow's Bargains")
})

// Rule 1 is not configurable and does not depend on the market rules at all: a
// frame that somehow carried an account name must never publish it.
test('acct and webId are stripped from a market payload regardless of config', () => {
  const out = visibility.projectFeature(
    'market',
    { serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' },
    'staff',
    config,
  )
  assert.equal('ownerAcct' in out, false)
  assert.equal('webId' in out, false)
  assert.equal(out.shopName, 'Shop')
})

// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by
// omission — which would be a silent failure rather than a loud one.
test('both market kinds are mapped to the market feature', () => {
  assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market')
  assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market')
})

// The market's live firehose is off by default (a page of whole vendor
// inventories is the site's biggest bandwidth item and no page needs it live),
// but the REST reads are unaffected — which is what `visibleKinds` ignoring the
// stream flag encodes.
test('market kinds are stream-suppressed by default but still readable', () => {
  assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true)
  assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
  assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
  assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing'))
})

test('an admin who enables the stream gets the frames', () => {
  const on = { ...config, market: { ...config.market, stream: true } }
  assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true)
})

// Guards the stub above against silently doing nothing: if the model stopped
// going through db.lookup, every shapeItems assertion would still "pass" by
// resolving nothing, which is also what a real miss looks like.
test('the cliloc resolver is the path shapeItems resolves through', async () => {
  const found = await clilocs.resolveMany([1023721])
  assert.equal(found.get(1023721), 'quarter staff')
})
