// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
//
// The shard-wide shop index: what every player vendor is selling, for how much,
// and where it is standing. This is the website's half of the search the in-game
// Vendor Search gump offers — the same data, the same opt-out, reachable without
// logging in to the game.
//
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
// `vendor.listing` frame per shop whose contents, prices or location moved, and
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
// "kryss" is a search over names, and the shard only ever sends numbers.

const db = require('./shardMarket.db')
const clilocs = require('../shardClilocs/shardClilocs.model')
const log = require('../../utils/logger')('shard-market')

// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
// trusted, but it is a separately-versioned component: a frame from a plugin
// whose cap was raised (or a shard running modified scripts) must not be able to
// turn one ingest into an unbounded transaction.
const MAX_ITEMS_PER_VENDOR = 5000

// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
// keeps the behavior the same in strict mode, where an over-length value is an
// ERROR and would fail the whole vendor rather than shortening one name.
const MAX_NAME = 160
const MAX_SHOP = 160
const MAX_OWNER = 64
const MAX_MAP = 40
const MAX_REGION = 80
const MAX_SERIAL = 20

const clip = (value, max) => {
  if (value == null) return null
  const s = String(value)
  return s.length > max ? s.slice(0, max) : s
}

const int = (value, fallback = 0) => {
  const n = Number(value)
  return Number.isFinite(n) ? Math.trunc(n) : fallback
}

// ── Ingest ─────────────────────────────────────────────────────────────────

/**
 * Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
 *
 * `location` arrives as a nested object rather than flat map/x/y/region, and that
 * shape is load-bearing rather than cosmetic: the visibility projection matches
 * literal JSON keys, so ONE `market.location` rule can hide a vendor's
 * whereabouts only if `location` is a single key on both the live frame and the
 * stored read model. Flattening it here for storage and re-nesting it on read is
 * what keeps that true on both paths.
 *
 * Exported for tests — it is the part with rules in it, and it is pure.
 */
function flattenFrame(ev) {
  const loc = (ev && ev.location) || {}
  return {
    serial: clip(ev.serial, MAX_SERIAL),
    shopName: clip(ev.shopName, MAX_SHOP),
    ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
    ownerName: clip(ev.ownerName, MAX_OWNER),
    map: clip(loc.map, MAX_MAP),
    x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
    y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
    z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
    region: clip(loc.region, MAX_REGION),
    house: clip(loc.house, MAX_SHOP),
    // What the SHOP holds, which is not what the frame carries when it was
    // truncated. Kept apart so the page can say "showing 250 of 3,104" rather
    // than presenting a partial shop as a complete one.
    itemTotal: int(ev.total, int(ev.count, 0)),
    truncated: ev.truncated === true,
    t: Number.isFinite(ev.t) ? ev.t : null,
  }
}

/**
 * Resolve each listing's display name.
 *
 * Order of preference is the item's own literal `name` first, then the cliloc.
 * That is the opposite of what "resolve the id" suggests and it is right: a
 * literal name only exists because a player set one ("Bob's vanquishing kryss"),
 * and it is strictly more specific than the generic cliloc the item still
 * carries.
 *
 * One batched lookup per frame rather than per item; `resolveMany` is cached and
 * never throws, so a cliloc table that is missing entirely just leaves
 * `displayName` null and the page renders item ids, exactly as it did before the
 * table existed.
 */
async function shapeItems(ev) {
  const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []

  const wanted = raw
    .map((i) => int(i && i.cliloc, 0))
    .filter((n) => n > 0)

  const names = await clilocs.resolveMany(wanted)

  return raw
    .filter((i) => i && i.serial)
    .map((i) => {
      const literal = clip(i.name, MAX_NAME)
      const cliloc = int(i.cliloc, 0) || null
      return {
        serial: clip(i.serial, MAX_SERIAL),
        itemId: int(i.itemId, 0),
        hue: int(i.hue, 0),
        amount: int(i.amount, 1),
        price: int(i.price, 0),
        name: literal,
        cliloc,
        displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
        child: i.child === true,
      }
    })
    // Unpriced rows are inventory, not listings. The shard already drops them;
    // this is the same rule enforced where the table is written, so a plugin that
    // stops enforcing it cannot put un-buyable rows on the market page.
    .filter((i) => i.price > 0)
}

/** Ingest one `vendor.listing` frame. */
async function upsertVendor(ev) {
  if (!ev || !ev.serial) return
  const vendor = flattenFrame(ev)
  const items = await shapeItems(ev)
  await db.replaceVendor(vendor, items)
}

/** Ingest one `vendor.listing.remove` frame. */
async function removeVendor(serial) {
  if (!serial) return
  await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
}

// ── Read models ────────────────────────────────────────────────────────────
//
// `location` is re-nested (see flattenFrame) so the stored read model and the
// live wire frame present the same keys to the visibility projection.

const place = (r) => ({
  map: r.map,
  x: r.x,
  y: r.y,
  z: r.z,
  region: r.region,
  house: r.house,
})

// A listing as the search returns it: the item, plus enough of its shop to be
// actionable without a second request. `displayName` falls back to nothing rather
// than to a fabricated "Item 3922" — the client decides how to render an
// unresolved id, and inventing a name here would make it indistinguishable from
// a real one.
const shapeListing = (r) => ({
  serial: r.serial,
  itemId: r.item_id,
  hue: r.hue,
  amount: r.amount,
  price: Number(r.price),
  name: r.name,
  cliloc: r.cliloc,
  displayName: r.display_name,
  child: !!r.child,
  vendor: {
    serial: r.vendor_serial,
    shopName: r.shop_name,
    ownerSerial: r.owner_serial,
    ownerName: r.owner_name,
    location: place(r),
    updatedAt: r.updated_at,
  },
})

const shapeVendor = (r) => ({
  serial: r.serial,
  shopName: r.shop_name,
  ownerSerial: r.owner_serial,
  ownerName: r.owner_name,
  location: place(r),
  count: r.item_count,
  total: r.item_total,
  truncated: !!r.truncated,
  updatedAt: r.updated_at,
})

const shapeItem = (r) => ({
  serial: r.serial,
  itemId: r.item_id,
  hue: r.hue,
  amount: r.amount,
  price: Number(r.price),
  name: r.name,
  cliloc: r.cliloc,
  displayName: r.display_name,
  child: !!r.child,
})

/**
 * Search the index. Returns a page of LISTINGS (not vendors) plus the
 * unpaginated total and the staleness stamp the page's banner needs.
 */
async function search({
  q = '',
  minPrice,
  maxPrice,
  itemId,
  map = '',
  region = '',
  sort = 'price_asc',
  limit = 50,
  offset = 0,
} = {}) {
  const { rows, total } = await db.searchListings({
    q: q.trim(),
    minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
    maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
    itemId: Number.isFinite(itemId) ? itemId : undefined,
    map: map.trim(),
    region: region.trim(),
    sort,
    limit,
    offset,
  })

  const info = await db.meta()

  return {
    listings: rows.map(shapeListing),
    total,
    limit,
    offset,
    // Repeated on every search response rather than left to a separate /meta
    // call: the banner that says how old these prices are must age with the
    // results it labels, and a client that fetched it once would keep showing a
    // stamp from before the page it is looking at.
    staleAt: info.staleAt,
    vendors: info.vendors,
  }
}

/** One shop and its listings. `null` when the index has never seen that serial. */
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
  const row = await db.getVendor(serial)
  if (!row) return null
  const items = await db.listVendorItems(serial, { limit, offset })
  return { ...shapeVendor(row), items: items.map(shapeItem) }
}

/** Index size, staleness, and the facet/region filter options. */
async function meta() {
  const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
  return { ...info, ...places }
}

// ── Cliloc re-resolution ───────────────────────────────────────────────────

// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
// round trips, small enough that a single batch is not a long-held connection.
const RESOLVE_BATCH = 1000

/**
 * Re-resolve every listing's display name against the current cliloc table.
 *
 * Called after a cliloc import, and it has to be: the market's diff sweep will
 * NOT re-send an unchanged shop just because the site learned what its items are
 * called, so without this an operator who configures clilocs after the first
 * market sweep sees item ids until every shop happens to change. That is the same
 * class of staleness the spawn atlas avoids by re-parsing on boot — here the
 * source of truth for names moved, not the data.
 *
 * Never throws. It is a cosmetic backfill on a table that is already serving; a
 * failure means names stay as they were, which is exactly the pre-import state.
 */
async function refreshDisplayNames() {
  let after = 0
  let scanned = 0
  let changed = 0

  try {
    for (;;) {
      const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
      if (rows.length === 0) break

      after = rows[rows.length - 1].id
      scanned += rows.length

      const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))

      const pairs = []
      for (const row of rows) {
        // The literal name still wins, so a re-resolution never overwrites a
        // player-set name with the generic cliloc behind it.
        const next = row.name
          ? clip(row.name, MAX_NAME)
          : clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
        if (next !== row.display_name) pairs.push([next, row.id])
      }

      changed += await db.updateDisplayNames(pairs)
    }

    if (changed > 0) log.info('market display names refreshed', { scanned, changed })
    return { scanned, changed }
  } catch (err) {
    log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
    return { scanned, changed, error: err.message }
  }
}

module.exports = {
  upsertVendor,
  removeVendor,
  search,
  getVendor,
  meta,
  refreshDisplayNames,
  flattenFrame,
  shapeItems,
  shapeListing,
  shapeVendor,
  MAX_ITEMS_PER_VENDOR,
}
