const { pool, query } = require('../../utils/db')

// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
//
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
// writes to either. No foreign keys, consistent with every other shard_* table.

// Insert batch size for one vendor's listings. A shop is capped at
// MarketMaxListings (250 by default) on the shard side, so in practice this is
// one batch — it exists for the operator who raised that cap.
const BATCH = 500

// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
// query — they are special to LIKE itself — so a search for "50% off" would
// otherwise match everything containing "50" and a search for "_" would match
// every single-character name. Escaped with a backslash, which is MariaDB's
// default LIKE escape (no ESCAPE clause needed).
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`

/**
 * Replace one vendor's whole row and listing set, in one transaction.
 *
 * Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
 * that vendor: the shard's sweep only emits a shop whose contents, prices or
 * location moved, and when it does it sends the whole shop. Reconciling it item
 * by item would be more code for the same result and would leave sold items
 * behind on any path the reconciliation missed.
 *
 * All-or-nothing matters here for a specific reason: the two writes are "the
 * shop" and "what is in it", and a failure between them leaves a shop advertising
 * an inventory it no longer has (or none at all) — visibly wrong on the page, and
 * indistinguishable from a genuinely empty shop.
 */
async function replaceVendor(vendor, items) {
  const conn = await pool.getConnection()
  try {
    await conn.beginTransaction()

    await conn.query(
      `INSERT INTO shard_vendors
         (serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
          item_count, item_total, truncated, t)
       VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
       ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
         owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
         z = VALUES(z), region = VALUES(region), house = VALUES(house),
         item_count = VALUES(item_count), item_total = VALUES(item_total),
         truncated = VALUES(truncated), t = VALUES(t),
         -- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
         -- MariaDB does not fire that when every column is written back
         -- unchanged, and a shop that is re-published identically is still
         -- FRESHLY CONFIRMED. Without this the staleness banner would age a
         -- perfectly current shop forever.
         updated_at = CURRENT_TIMESTAMP`,
      [
        vendor.serial,
        vendor.shopName ?? null,
        vendor.ownerSerial ?? null,
        vendor.ownerName ?? null,
        vendor.map ?? null,
        Number.isFinite(vendor.x) ? vendor.x : null,
        Number.isFinite(vendor.y) ? vendor.y : null,
        Number.isFinite(vendor.z) ? vendor.z : null,
        vendor.region ?? null,
        vendor.house ?? null,
        items.length,
        Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
        vendor.truncated ? 1 : 0,
        Number.isFinite(vendor.t) ? vendor.t : null,
      ],
    )

    await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])

    const rows = items.map((i) => [
      vendor.serial,
      i.serial,
      i.itemId,
      i.hue,
      i.amount,
      i.price,
      i.name,
      i.cliloc,
      i.displayName,
      i.child ? 1 : 0,
    ])

    for (let i = 0; i < rows.length; i += BATCH) {
      await conn.batch(
        `INSERT INTO shard_vendor_items
           (vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
         VALUES (?,?,?,?,?,?,?,?,?,?)`,
        rows.slice(i, i + BATCH),
      )
    }

    await conn.commit()
    return { items: rows.length }
  } catch (err) {
    await conn.rollback().catch(() => {})
    throw err
  } finally {
    conn.release()
  }
}

/** Drop one vendor and its listings (vendor.listing.remove). */
async function removeVendor(serial) {
  const conn = await pool.getConnection()
  try {
    await conn.beginTransaction()
    await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
    await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
    await conn.commit()
  } catch (err) {
    await conn.rollback().catch(() => {})
    throw err
  } finally {
    conn.release()
  }
}

// ── Search ─────────────────────────────────────────────────────────────────
//
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
// kryss and for how much" is the question, and answering it per vendor would make
// the caller flatten the shops back out. The vendor's columns ride along on the
// join so a result row is self-contained.

function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
  const where = ['i.price > 0']
  const params = []

  if (q) {
    // Both the resolved display name and the item's own literal, because an item
    // with a player-set name (most of what is actually worth searching for on a
    // player-run shard) may have a generic cliloc.
    where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
    params.push(likeTerm(q), likeTerm(q))
  }
  if (Number.isFinite(minPrice)) {
    where.push('i.price >= ?')
    params.push(minPrice)
  }
  if (Number.isFinite(maxPrice)) {
    where.push('i.price <= ?')
    params.push(maxPrice)
  }
  if (Number.isFinite(itemId)) {
    where.push('i.item_id = ?')
    params.push(itemId)
  }
  if (map) {
    where.push('v.map = ?')
    params.push(map)
  }
  if (region) {
    where.push('v.region = ?')
    params.push(region)
  }

  return { sql: `WHERE ${where.join(' AND ')}`, params }
}

// Whitelisted, because this interpolates into the statement. `recent` sorts by
// the vendor's freshness, which is the only way to see what has just been listed
// on a shard whose sweep is minutes wide.
const SORTS = {
  price_asc: 'i.price ASC, i.id ASC',
  price_desc: 'i.price DESC, i.id ASC',
  recent: 'v.updated_at DESC, i.id ASC',
}

async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
  const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
  const order = SORTS[sort] || SORTS.price_asc

  const rows = await query(
    `SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
            v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
            v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
       FROM shard_vendor_items i
       JOIN shard_vendors v ON v.serial = i.vendor_serial
       ${sql}
      ORDER BY ${order}
      LIMIT ? OFFSET ?`,
    [...params, limit, offset],
  )

  const counted = await query(
    `SELECT COUNT(*) AS n
       FROM shard_vendor_items i
       JOIN shard_vendors v ON v.serial = i.vendor_serial
       ${sql}`,
    params,
  )

  return { rows, total: Number(counted[0]?.n) || 0 }
}

async function getVendor(serial) {
  const rows = await query(
    `SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
            item_count, item_total, truncated, t, updated_at
       FROM shard_vendors WHERE serial = ?`,
    [serial],
  )
  return rows[0] || null
}

async function listVendorItems(serial, { limit, offset }) {
  return query(
    `SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
       FROM shard_vendor_items
      WHERE vendor_serial = ?
      ORDER BY price ASC, id ASC
      LIMIT ? OFFSET ?`,
    [serial, limit, offset],
  )
}

/**
 * What the market page's header needs: how big the index is, and how stale it may
 * be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
 * be a full cycle behind, and the page says so rather than implying live prices.
 */
async function meta() {
  const rows = await query(
    `SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
       FROM shard_vendors`,
  )
  const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
  return {
    vendors: Number(rows[0]?.vendors) || 0,
    items: Number(items[0]?.n) || 0,
    staleAt: rows[0]?.stale_at || null,
    freshAt: rows[0]?.fresh_at || null,
  }
}

/** The distinct facets and regions holding vendors — drives the page's filters. */
async function listPlaces() {
  const maps = await query(
    'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
  )
  const regions = await query(
    'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
  )
  return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
}

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

/**
 * One page of listings whose name still needs resolving, for the bulk pass that
 * runs after a cliloc import.
 *
 * Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
 * scanning, and an OFFSET walk over a table being rewritten skips rows. Every
 * row with a cliloc is re-read, not just the unresolved ones, because an import
 * can also CHANGE a name — a shard overlay relabelling a stock item is the whole
 * reason overlays exist.
 */
async function listResolvableItems(after, limit) {
  return query(
    `SELECT id, cliloc, name, display_name
       FROM shard_vendor_items
      WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
      ORDER BY id
      LIMIT ?`,
    [after, limit],
  )
}

/** Write back a batch of re-resolved display names. */
async function updateDisplayNames(pairs) {
  if (pairs.length === 0) return 0
  const conn = await pool.getConnection()
  try {
    await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
    return pairs.length
  } finally {
    conn.release()
  }
}

module.exports = {
  replaceVendor,
  removeVendor,
  searchListings,
  getVendor,
  listVendorItems,
  meta,
  listPlaces,
  listResolvableItems,
  updateDisplayNames,
  likeTerm,
}
