// data-cards.jsx — real Pokémon TCG card index + single-card inventory
//
// WHY THIS FILE EXISTS
// The storefront's SETS are original fictional booster products (deliberately —
// see data.jsx). This file is different: it indexes REAL cards, because the
// actual business is buying collections and selling singles, and you cannot run
// singles inventory against made-up cards.
//
// PROVENANCE RULES — read before adding rows
//   1. Every catalog row carries `src`: where its identity was verified.
//      Set code, collector number, denominator and rarity are factual claims.
//      Do not add a card from memory. Verify the number, or leave it out.
//   2. `market` is a point-in-time observation, not a constant. It carries
//      `asOf`. A null market is CORRECT for a card we haven't priced — the UI
//      surfaces those as gaps rather than guessing.
//   3. No card images. Names, numbers and rarities are factual catalog data and
//      fine to index; the artwork is copyrighted and is not reproduced here.
//
// Prices below were verified July 2026 and WILL be stale. See CARD_FEED for the
// intended replacement path.

/* ───────────── sets ─────────────
   `printed` is the number after the slash on the card (e.g. 4/102 -> 102) and is
   therefore self-verifying. `total` includes secret rares and is left null
   unless it was actually confirmed. */
const CARD_SETS = [
  { code: 'BS',     name: 'Base Set',             series: 'Wizards of the Coast', released: '1999-01-09', printed: 102, total: 102,  era: 'Vintage' },
  { code: 'swsh7',  name: 'Evolving Skies',       series: 'Sword & Shield',       released: '2021-08-27', printed: 203, total: null, era: 'Modern' },
  { code: 'sv2',    name: 'Paldea Evolved',       series: 'Scarlet & Violet',     released: '2023-06-09', printed: 193, total: null, era: 'Modern' },
  { code: 'sv3pt5', name: '151',                  series: 'Scarlet & Violet',     released: '2023-09-22', printed: 165, total: null, era: 'Modern' },
  { code: 'sv8',    name: 'Surging Sparks',       series: 'Scarlet & Violet',     released: '2024-11-08', printed: 191, total: null, era: 'Modern' },
  { code: 'sv8pt5', name: 'Prismatic Evolutions', series: 'Scarlet & Violet',     released: '2025-01-17', printed: 131, total: 180,  era: 'Modern' },
];
const cardSet = (code) => CARD_SETS.find(s => s.code === code);

/* ───────────── rarity ───────────── */
const CARD_RARITY = {
  'Holo Rare':                  { short: 'HOLO', tier: 3 },
  'Ultra Rare':                 { short: 'UR',   tier: 4 },
  'Secret Rare':                { short: 'SEC',  tier: 5 },
  'Special Illustration Rare':  { short: 'SIR',  tier: 5 },
};

/* ───────────── condition ─────────────
   Standard TCG condition ladder. The multipliers are RGS house policy applied
   to a Near Mint market price — they are a pricing convention, not market data,
   and are meant to be tuned. */
const CONDITIONS = [
  { code: 'NM',  label: 'Near Mint',   mult: 1.00 },
  { code: 'LP',  label: 'Lightly Played', mult: 0.85 },
  { code: 'MP',  label: 'Moderately Played', mult: 0.70 },
  { code: 'HP',  label: 'Heavily Played', mult: 0.50 },
  { code: 'DMG', label: 'Damaged',     mult: 0.30 },
];
const condition = (code) => CONDITIONS.find(c => c.code === code) || CONDITIONS[0];

/* ───────────── the card index ─────────────
   id = <setCode>-<number>, which is globally unique and matches how every price
   guide and marketplace keys a card. */
const CARD_CATALOG = [
  {
    id: 'BS-4', setCode: 'BS', number: '4', name: 'Charizard',
    rarity: 'Holo Rare', finish: 'Holofoil', variant: 'Unlimited',
    market: { rawNM: 317.24, asOf: '2026-07', basis: 'avg of 1,898 recorded sales; dealer range $300–500' },
    src: 'pricecharting.com + pokeinvest.io, Jul 2026',
  },
  {
    id: 'BS-2', setCode: 'BS', number: '2', name: 'Blastoise',
    rarity: 'Holo Rare', finish: 'Holofoil', variant: 'Unlimited',
    market: { rawNM: 90.25, asOf: '2026-07', basis: 'avg of 1,619 recorded sales' },
    src: 'pricecharting.com, Jul 2026',
  },
  {
    id: 'BS-15', setCode: 'BS', number: '15', name: 'Venusaur',
    rarity: 'Holo Rare', finish: 'Holofoil', variant: 'Unlimited',
    market: { rawNM: 64.00, asOf: '2026-07', basis: 'avg of 1,580 recorded sales' },
    src: 'pricecharting.com, Jul 2026',
  },
  {
    id: 'swsh7-215', setCode: 'swsh7', number: '215', name: 'Umbreon VMAX',
    rarity: 'Secret Rare', finish: 'Holofoil', variant: 'Alternate Art Secret',
    nickname: 'Moonbreon',
    market: { rawNM: 600.00, asOf: '2026-07', basis: 'raw NM trades $400–800; PSA 10 $1,200–1,800' },
    src: 'pokeval.com + collectorscache, Jul 2026',
  },
  {
    id: 'sv3pt5-199', setCode: 'sv3pt5', number: '199', name: 'Charizard ex',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: { rawNM: 265.00, asOf: '2026-07', basis: 'TCGplayer market $234–295 raw NM' },
    src: 'tcgplayer.com + sportscardinvestor.com, Jul 2026',
  },
  {
    id: 'sv2-254', setCode: 'sv2', number: '254', name: 'Iono',
    rarity: 'Ultra Rare', finish: 'Holofoil', variant: 'Full Art',
    market: { rawNM: 11.00, asOf: '2026-07', basis: 'NM retail' },
    src: 'tcgplayer.com + sportscardinvestor.com, Jul 2026',
  },
  {
    // Separate print from 254 — easy to conflate, and conflating them misprices
    // the shelf by two orders of magnitude.
    id: 'sv2-269', setCode: 'sv2', number: '269', name: 'Iono',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: null,
    src: 'tcgplayer.com (identity confirmed; price not captured)',
  },
  {
    id: 'sv8-238', setCode: 'sv8', number: '238', name: 'Pikachu ex',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: { rawNM: 325.97, asOf: '2026-07', basis: 'holofoil market; recent sales $294–1,630' },
    src: 'pokescope.app + tcgplayer.com, Jul 2026',
  },
  {
    id: 'sv8pt5-161', setCode: 'sv8pt5', number: '161', name: 'Umbreon ex',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: { rawNM: 1050.00, asOf: '2026-07', basis: 'raw NM $950–1,500, last sale $1,050; PSA 10 $4,500–6,000' },
    src: 'tcgplayer.com + binderdex.com, Jul 2026',
  },
  {
    id: 'sv8pt5-156', setCode: 'sv8pt5', number: '156', name: 'Sylveon ex',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: null,
    src: 'pokebeach.com set guide (identity confirmed; price not captured)',
  },
  {
    id: 'sv8pt5-144', setCode: 'sv8pt5', number: '144', name: 'Leafeon ex',
    rarity: 'Special Illustration Rare', finish: 'Holofoil',
    market: null,
    src: 'pokebeach.com set guide (identity confirmed; price not captured)',
  },
];
const cardById = (id) => CARD_CATALOG.find(c => c.id === id);
function cardLabel(c) { return c.name + ' ' + c.number + '/' + cardSet(c.setCode).printed; }

/* ───────────── inventory ─────────────
   One row per SKU: a card in a specific condition from a specific buy.
   `sourceLot` points at OPS_PIPELINE in data.jsx, so every single on the shelf
   traces back to the collection it came out of.
   `unitCost` is allocated from that collection's purchase price pro-rata by
   market value — the relative sales value method:

       unitCost = askEach x (lotOffer / lotMarket)

   Both lots were bought at 44% of market, so every unit cost below is its
   condition-adjusted ask x 0.44. Two consequences worth knowing:
     - Cards with no market price get $0 allocated. The method cannot assign
       cost to an item of unknown value; that understates their basis and is
       flagged in the UI rather than papered over with a guess.
     - Bulk absorbs no allocation, which is the point — see BULK_LOTS. */
const INVENTORY = [
  // Okonkwo, c7 — $1,110 market / $488 buy
  { sku: 'BS-4-NM',        cardId: 'BS-4',        condition: 'NM',  qty: 1, unitCost: 139.60, sourceLot: 'c7', acquired: '2026-06-14', location: 'Vault A1' },
  { sku: 'BS-2-LP',        cardId: 'BS-2',        condition: 'LP',  qty: 1, unitCost: 33.75,  sourceLot: 'c7', acquired: '2026-06-14', location: 'Vault A1' },
  { sku: 'BS-15-MP',       cardId: 'BS-15',       condition: 'MP',  qty: 2, unitCost: 19.70,  sourceLot: 'c7', acquired: '2026-06-14', location: 'Vault A1' },
  { sku: 'swsh7-215-NM',   cardId: 'swsh7-215',   condition: 'NM',  qty: 1, unitCost: 264.00, sourceLot: 'c7', acquired: '2026-06-14', location: 'Vault A2' },

  // Vasquez, c2 — $2,300 market / $1,012 buy
  { sku: 'sv8pt5-161-NM',  cardId: 'sv8pt5-161',  condition: 'NM',  qty: 1, unitCost: 462.00, sourceLot: 'c2', acquired: '2026-07-19', location: 'Vault A2' },
  { sku: 'sv3pt5-199-NM',  cardId: 'sv3pt5-199',  condition: 'NM',  qty: 2, unitCost: 116.60, sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv8-238-NM',     cardId: 'sv8-238',     condition: 'NM',  qty: 1, unitCost: 143.40, sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv8-238-LP',     cardId: 'sv8-238',     condition: 'LP',  qty: 1, unitCost: 121.90, sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv8pt5-156-NM',  cardId: 'sv8pt5-156',  condition: 'NM',  qty: 1, unitCost: 0,      sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv8pt5-144-NM',  cardId: 'sv8pt5-144',  condition: 'NM',  qty: 2, unitCost: 0,      sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv2-269-NM',     cardId: 'sv2-269',     condition: 'NM',  qty: 1, unitCost: 0,      sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B1' },
  { sku: 'sv2-254-NM',     cardId: 'sv2-254',     condition: 'NM',  qty: 6, unitCost: 4.84,   sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B2' },
  { sku: 'sv2-254-LP',     cardId: 'sv2-254',     condition: 'LP',  qty: 3, unitCost: 4.11,   sourceLot: 'c2', acquired: '2026-07-19', location: 'Binder B2' },
];

/* ───────────── bulk ─────────────
   Deliberately NOT indexed card-by-card. Per business-supply-chain.md a
   3,000-count box of commons is effectively $0 of value against many hours of
   labour; giving each one a SKU would be the most expensive mistake in the
   system. Tracked as weight/count lots instead. */
const BULK_LOTS = [
  { id: 'bulk-1', label: 'Commons / uncommons', count: 3000, sourceLot: 'c1', perCard: 0.008, location: 'Box C1' },
  { id: 'bulk-2', label: 'Reverse holos, mixed sets', count: 240, sourceLot: 'c2', perCard: 0.10, location: 'Box C2' },
];

/* ───────────── valuation ─────────────
   ask  = NM market x condition multiplier (house policy)
   Rows with no market price return null and are counted as unpriced rather
   than silently valued at zero. */
function skuAsk(row) {
  const c = cardById(row.cardId);
  if (!c || !c.market || c.market.rawNM == null) return null;
  return c.market.rawNM * condition(row.condition).mult;
}
function skuValue(row) { const a = skuAsk(row); return a == null ? null : a * row.qty; }

/* Units on a row can have two different histories. A row bought through a lot
   has a unit cost for every unit. Scan a second copy of the same card in the
   same condition and it merges into that row — SKU is card + condition, and
   two rows with one SKU would break every lookup in the app — but the new unit
   was not bought at the old unit's price. It was not bought at all.

   So the row counts how many of its units have no basis. Cost is charged only
   on the ones that do: multiplying unitCost by the full qty would invent a
   purchase, which understates margin exactly as badly as counting the unit at
   zero would overstate it. */
const uncostedQty = (row) => Math.max(0, Math.min(row.qty, row.uncostedQty || 0));
const costedQty = (row) => row.qty - uncostedQty(row);
function skuCost(row) { return row.unitCost * costedQty(row); }

/* ───────────── channels and fees ─────────────
   SPEC-009: sell on marketplaces now AND take direct orders alongside, with
   local pickup for Fort Leavenworth.

   A flat "23% take" used to live here. It was wrong in a way that mattered:
   real fees are a PERCENTAGE PLUS A FIXED AMOUNT, and postage is close to
   fixed too. Both of those are trivial on a $1,050 card and brutal on a $9
   one, so a single blended rate misprices every decision that depends on it —
   most importantly where the individual-listing line sits.

   Measured against the current shelf: 14.5% take on the Umbreon ex, 30.1% on
   an LP Iono, 16.6% blended. The old flat 23% understated this shelf's profit
   by ~$217.

   Rates verified 2026-08: TCGplayer 10.75% commission + 2.5% + $0.30
   transaction (~13.55% + $0.30); eBay ~13.6% + $0.30 under $10 / $0.40 over,
   both including the regulatory operating fee; Stripe 2.9% + $0.30. */
const CHANNELS = {
  tcgplayer: { id: 'tcgplayer', label: 'TCGplayer', pct: 0.1355, fixed: 0.30, ships: true,
               note: 'Marketplace Seller L1-4. Pro is 9.25% + the same transaction fee.' },
  ebay:      { id: 'ebay',      label: 'eBay',      pct: 0.1360, fixed: 0.35, ships: true,
               note: 'No store subscription. Store sellers pay ~12.7%.' },
  direct:    { id: 'direct',    label: 'Direct',    pct: 0.0290, fixed: 0.30, ships: true,
               note: 'Stripe on our own site. Cheapest fees, but we own trust and chargebacks.' },
  pickup:    { id: 'pickup',    label: 'Local pickup', pct: 0.0290, fixed: 0.30, ships: false,
               note: 'Direct, handed over on post. No postage at all.' },
};
const DEFAULT_CHANNEL = 'tcgplayer';
const channel = (id) => CHANNELS[id] || CHANNELS[DEFAULT_CHANNEL];

// Absorbed postage by value band, matching the shipping tiers in SPEC-009.
// Tracking is the dispute defence, so it starts as soon as the card is worth
// arguing about; signature + insurance once one loss would erase a month.
function shippingCost(ask) {
  if (ask == null) return 0;
  return ask < 20 ? 1.20 : ask < 100 ? 4.50 : 10.00;
}

// What actually lands in the account for one unit sold on a given channel.
function netProceeds(ask, channelId) {
  if (ask == null) return null;
  const c = channel(channelId);
  const fees = ask * c.pct + c.fixed;
  const ship = c.ships ? shippingCost(ask) : 0;
  return { fees, ship, net: ask - fees - ship, takePct: (fees + ship) / ask };
}

/* ───────────── the listing line ─────────────
   Below some price a card costs more to sell than it returns. This computes
   it rather than asserting it, because the answer moved: SPEC-007 proposed a
   ~$2 line, and at $2 the take is ~91% and the sale loses money outright.

   `hourlyFloor` is what Rook's time has to earn for listing to be worth doing;
   `minutes` is handling per card. Returns the ask price at which an individual
   listing clears that floor. */
/* ───────────── what to do with cards below the line ─────────────
   Verified 2026-08, two independent sources agreeing:
     commons / uncommons / rares   $8-25 per 1,000   (~$0.01-0.025 each)
     holo / reverse holo           $15-20 per 1,000
     V / ex / GX / VMAX tier       $80-100 per 1,000 (~$0.08-0.10 each)

   So a bulk buyer is a near-total write-off for anything with real value. The
   alternative is BUNDLING, and it works because the marketplace fee and the
   postage are charged per ORDER, not per card — thirty cards in one parcel pay
   them once.

   A $2 card: ~11% of market sold individually (postage alone exceeds margin),
   ~2.5% to a bulk buyer, ~44% bundled into a lot at 60% of singles value. */
const BULK_BUYER_RATE = 0.025;   // fraction of market value, generous end
const LOT_DISCOUNT    = 0.60;    // lots sell below the sum of their singles

// Net proceeds from bundling `marketValue` of cheap cards into one lot.
function lotProceeds(marketValue, channelId = DEFAULT_CHANNEL) {
  if (!marketValue) return { price: 0, net: 0, rate: 0 };
  const price = marketValue * LOT_DISCOUNT;
  const c = channel(channelId);
  const net = Math.max(0, price - (price * c.pct + c.fixed) - (c.ships ? shippingCost(price) : 0));
  return { price, net, rate: net / marketValue };
}

function listingLine({ channelId = DEFAULT_CHANNEL, buyRate = 0.44, minutes = 3, hourlyFloor = 15 } = {}) {
  for (let cents = 100; cents <= 20000; cents++) {
    const ask = cents / 100;
    const p = netProceeds(ask, channelId);
    const gross = p.net - ask * buyRate;          // after cost of goods
    if (gross * (60 / minutes) >= hourlyFloor) return ask;
  }
  return null;
}

function inventoryRoll(channelId) {
  let units = 0, cost = 0, value = 0, net = 0, fees = 0, ship = 0;
  let unpriced = 0, unpricedUnits = 0;
  // Units imported by a scan carry no cost basis. Tracked alongside the channel
  // maths rather than folded into it: what the shelf is WORTH and what it would
  // NET are properties of the cards, and true for a scanned card too. What
  // changes is whether that net can be called profit — see below.
  let uncosted = 0, uncostedUnits = 0, uncostedValue = 0, uncostedNet = 0;
  let unphotographed = 0, unphotographedUnits = 0;
  INVENTORY.forEach(r => {
    units += r.qty;
    cost += skuCost(r);
    const noBasis = uncostedQty(r);
    if (noBasis > 0) { uncosted += 1; uncostedUnits += noBasis; }
    // A SKU with no photograph is the same class of problem as an unpriced one:
    // inventory that cannot be sold as it stands. SPEC-008 asks for it to be
    // counted beside the unpriced figure, so it is.
    const gap = photoGap(r, { channelId });
    if (gap.blocking) { unphotographed += 1; unphotographedUnits += r.qty; }
    const ask = skuAsk(r);
    if (ask == null) { unpriced += 1; unpricedUnits += r.qty; return; }
    value += ask * r.qty;
    const p = netProceeds(ask, channelId);
    fees += p.fees * r.qty;
    ship += p.ship * r.qty;
    net  += p.net  * r.qty;
    uncostedValue += ask * noBasis;
    uncostedNet   += p.net * noBasis;
  });
  // Margin compares like with like: only value and net from units that have a
  // cost behind them. A scanned card at $0 cost against a real ask would report
  // the whole ask as profit — the flattering direction, and the reason these
  // units are held out rather than counted at zero. Same rule the file already
  // applies to prices: a missing number is a state, not a zero.
  const costedValue = value - uncostedValue;
  const costedNet = net - uncostedNet;
  const bulkCount = BULK_LOTS.reduce((s, b) => s + b.count, 0);
  const bulkValue = BULK_LOTS.reduce((s, b) => s + b.count * b.perCard, 0);
  return {
    units, cost, value, unpriced, unpricedUnits,
    uncosted, uncostedUnits, uncostedValue, uncostedNet, costedValue, costedNet,
    unphotographed, unphotographedUnits,
    skus: INVENTORY.length,
    distinctCards: new Set(INVENTORY.map(r => r.cardId)).size,
    margin: costedValue - cost,
    marginPct: cost > 0 ? ((costedValue - cost) / cost) * 100 : 0,
    fees, ship,
    // Blended, not assumed — this is what the mix actually costs to sell.
    // Whole-shelf: a scanned card still costs the same to ship and list.
    takePct: value > 0 ? (fees + ship) / value : 0,
    net, netMargin: costedNet - cost,
    netRoc: cost > 0 ? ((costedNet - cost) / cost) * 100 : 0,
    bulkCount, bulkValue,
    channel: channel(channelId),
  };
}

/* ───────────── the real feed ─────────────
   The card APIs are unreachable from the build sandbox (egress policy returned
   403 on CONNECT for api.pokemontcg.io, pokemon.com, target.com), so the rows
   above were verified by hand. This is the shape of the loader that should
   replace them — run it where outbound access exists and it can fill both the
   catalog and the market prices.

   The retail pages Rook sent (Pokémon Center, pokemon.com, Walmart, Target) are
   storefronts for sealed product; they don't expose card-level data. For an
   index keyed by set + collector number the sources that matter are:
     - api.pokemontcg.io/v2/cards   set, number, rarity, images   (free, keyed)
     - TCGplayer API                market prices by product id   (partner key)
     - pricecharting.com            sold comps incl. graded       (paid)
*/
const CARD_FEED = {
  catalog: 'https://api.pokemontcg.io/v2/cards?q=set.id:sv8pt5&pageSize=250',
  prices: 'https://api.pokemontcg.io/v2/cards?q=id:swsh7-215',   // includes tcgplayer.prices
  blockedInSandbox: ['api.pokemontcg.io', 'www.pokemon.com', 'www.target.com'],
  // Maps one upstream card object onto a CARD_CATALOG row.
  adapt: function (apiCard) {
    const tp = (apiCard.tcgplayer || {}).prices || {};
    const band = tp.holofoil || tp.normal || tp.reverseHolofoil || {};
    return {
      id: apiCard.id,
      setCode: (apiCard.set || {}).id,
      number: apiCard.number,
      name: apiCard.name,
      rarity: apiCard.rarity,
      finish: tp.holofoil ? 'Holofoil' : 'Normal',
      market: band.market == null ? null : {
        rawNM: band.market,
        asOf: (apiCard.tcgplayer || {}).updatedAt,
        basis: 'tcgplayer market via pokemontcg.io',
      },
      src: 'api.pokemontcg.io',
    };
  },
};

/* ───────────── writes ─────────────
   INVENTORY is a module-level array that four screens read directly. Lens can
   now add to it, so the write lives here, next to the data, rather than being
   done by hand from a screen — one place to validate, one place to keep the
   provenance rules below true.

   TWO THINGS A SCAN DOES NOT KNOW, and neither may be guessed:

   1. CONDITION. Nothing has graded the card in front of the camera. The same
      card at NM and LP are different products at different prices, so the
      caller must supply it — `addScannedSku` refuses a row without a valid
      condition rather than defaulting to NM, which would be the flattering
      answer and wrong more than half the time.
   2. COST. A scan is not a purchase. There is no lot, no offer, no basis. The
      units land in `uncostedQty` and are kept out of cost, margin and ROC —
      a scanned card at unitCost 0 against a $326 ask would report an infinite
      margin. They count as units and as value; they do not count as profit
      until someone says what was paid.

   SKU is card + condition, so scanning a second copy of a card already held in
   that condition increments the quantity rather than creating a duplicate row,
   which is what the SKU convention means everywhere else in this file. The new
   unit still carries no basis, so it is added to `uncostedQty` on the way in —
   otherwise it would silently inherit the price the first copy was bought at
   and overstate what the shelf cost. */
function addScannedSku({ cardId, condition: code, qty = 1, acquired, location = 'Intake' }) {
  const card = cardById(cardId);
  if (!card) throw new Error('addScannedSku: no such card ' + cardId);
  if (!CONDITIONS.some(c => c.code === code)) {
    throw new Error('addScannedSku: a scan cannot infer condition; pass one of '
      + CONDITIONS.map(c => c.code).join('/'));
  }
  const n = Math.max(1, Math.round(qty));
  const sku = cardId + '-' + code;
  const existing = INVENTORY.find(r => r.sku === sku);
  if (existing) {
    existing.qty += n;
    existing.uncostedQty = (existing.uncostedQty || 0) + n;
    return { row: existing, sku, created: false, qty: existing.qty };
  }
  const row = {
    sku, cardId, condition: code, qty: n,
    unitCost: 0, uncostedQty: n,
    sourceLot: null, origin: 'scan',
    acquired: acquired || new Date().toISOString().slice(0, 10),
    location,
  };
  INVENTORY.push(row);
  return { row, sku, created: true, qty: n };
}

/* ───────────── recording what was paid ─────────────
   The other half of `addScannedSku`. Scanned units sit in `uncostedQty`, out of
   margin, until someone says what they cost — this is where that happens.

   ZERO IS A LEGITIMATE ANSWER, and it is why the flag is cleared rather than
   inferred from the number. A card that came in free — a giveaway, a throw-in
   on a lot, a trade — genuinely cost $0, and that is a fact somebody asserted.
   An unrecorded cost is also $0 in the arithmetic and means nothing of the
   sort. `costedOn` is what separates the two, so a recorded $0 counts in margin
   and an unrecorded one never does.

   COST IS A MOVING AVERAGE across the row. A SKU holds one `unitCost` but its
   units can have different histories: one Pikachu ex LP allocated $121.90 out
   of the Vasquez lot, plus one scanned copy bought later for cash. Splitting
   the row would break the SKU convention every lookup in the app depends on,
   so the row keeps a weighted average — standard inventory practice, and the
   only option that leaves `skuCost()` meaning one thing. */
function setScannedCost(sku, unitCost, { sourceLot = null, on = null } = {}) {
  const row = INVENTORY.find(r => r.sku === sku);
  if (!row) throw new Error('setScannedCost: no such SKU ' + sku);
  const pending = uncostedQty(row);
  if (pending === 0) throw new Error('setScannedCost: ' + sku + ' has no units awaiting a cost');
  const each = Number(unitCost);
  if (!Number.isFinite(each) || each < 0) {
    throw new Error('setScannedCost: unit cost must be a number of dollars, got ' + unitCost);
  }
  const priorUnits = costedQty(row);
  const blended = (row.unitCost * priorUnits + each * pending) / row.qty;
  row.unitCost = blended;
  row.uncostedQty = 0;
  row.costedOn = on || new Date().toISOString().slice(0, 10);
  if (sourceLot) row.sourceLot = sourceLot;
  return { row, sku, units: pending, enteredEach: each, unitCost: blended, blended: priorUnits > 0 };
}

/* ───────────── costing a whole collection at once ─────────────
   `setScannedCost` handles the card bought loose over the counter. Intake at
   PCS season is not that shape: 200 cards arrive as one lot with one number on
   it, and typing a unit cost 200 times is how rows stay uncosted.

   The allocation method is the one this file already documents at INVENTORY,
   and the seeded rows were built with it — the relative sales value method:

       rate     = lotOffer / lotMarket        (0.44 on both existing lots)
       unitCost = askEach × rate

   `askEach` is the condition-adjusted ask, so an LP copy absorbs less basis
   than the NM one, which is the point of allocating by value rather than by
   headcount.

   A ROW WITH NO MARKET PRICE IS SKIPPED, NOT COSTED AT ZERO. The method cannot
   assign cost to an item of unknown value — that is stated at INVENTORY and it
   is a real limitation, not a rounding case. Writing $0 would be worse than
   leaving it: `setScannedCost` stamps `costedOn`, which means *someone
   asserted this*, and nobody asserted that an unpriced card was free. Skipped
   rows stay in the uncosted pool and are returned so the UI can say so. */
function allocateLotCost(lotId, { skus = null } = {}) {
  const lot = (typeof OPS_PIPELINE !== 'undefined' ? OPS_PIPELINE : []).find(p => p.id === lotId);
  if (!lot) throw new Error('allocateLotCost: no such collection ' + lotId);
  if (!(lot.market > 0)) throw new Error('allocateLotCost: ' + lotId + ' has no market value to allocate against');
  const rate = lot.offer / lot.market;

  const pool = INVENTORY.filter(r => uncostedQty(r) > 0 && (!skus || skus.includes(r.sku)));
  const costed = [], skipped = [];
  pool.forEach(r => {
    const ask = skuAsk(r);
    if (ask == null) { skipped.push(r.sku); return; }
    const each = ask * rate;
    setScannedCost(r.sku, each, { sourceLot: lotId });
    costed.push({ sku: r.sku, each, units: 1 });
  });
  return {
    lot, rate,
    costed: costed.length,
    skipped,
    allocated: costed.reduce((s, c) => s + c.each * c.units, 0),
  };
}

/* ───────────── what a SKU still needs photographed ─────────────
   SPEC-008's capture ladder, in code, so Ops can flag a SKU the same way it
   flags an unpriced one — both are inventory that cannot be sold as it stands.

   The bands are not about damage risk. Per the SPEC-007/008 reconcile, the rule
   is that a card needing a hand-taken photo is never fed: it is already out of
   the sleeve and in hand, so the feeder saves nothing and risks something.
   Value enters only through the capture requirement. */
function captureFor(ask, { channelId = DEFAULT_CHANNEL } = {}) {
  if (ask == null) return { id: 'unknown', label: 'Price it first', fed: false, faces: 0,
                            note: 'No market price, so which capture it needs is undecidable.' };
  const line = listingLine({ channelId }) || 0;
  if (ask < line) {
    return { id: 'none', label: 'None', fed: false, faces: 0,
             note: 'Below the ' + money(line) + ' listing line on ' + channel(channelId).label +
                   ' — sold in a sorted lot or the bin, not individually.' };
  }
  if (ask < 20) {
    return { id: 'scan', label: 'Scanner front', fed: true, faces: 1,
             note: 'Never hand-handled, so the feeder is the whole capture step.' };
  }
  if (ask < 100) {
    return { id: 'phone', label: 'Phone, front and back', fed: false, faces: 2,
             note: 'Back centring is the common dispute. In hand for that, so not fed.' };
  }
  return { id: 'phone-raking', label: 'Phone, front and back, raking light', fed: false, faces: 2,
           note: 'Above ~$100 the photograph is the condition claim.' };
}

/* ───────────── capturing a photograph ─────────────
   SPEC-008 wants ~1200px on the long edge, JPEG ~80, EXIF stripped, named
   `<sku>-<front|back>.jpg`. All four happen here.

   EXIF IS STRIPPED BY CONSTRUCTION, NOT BY A STEP THAT CAN BE FORGOTTEN. The
   image is decoded, drawn to a canvas, and re-encoded — the output is a fresh
   JPEG built from pixels, so there is nowhere for an APP1 segment to survive.
   That matters more than it sounds: phone photos carry GPS by default, and
   publishing those broadcasts where the inventory is kept, which for a business
   run out of a house on a military installation is a physical security problem
   rather than a privacy nicety. The spec asks for it to be handled in the
   resize rather than left as a reminder to a human. It is.

   Anything that replaces this with a pass-through — uploading the original
   bytes, say — reintroduces the leak silently, which is why the test scans the
   output for an Exif marker rather than trusting the comment. */
const PHOTO_LONG_EDGE = 1200;
const PHOTO_QUALITY = 0.8;

// The SKU already encodes set, number and condition, so the name adds nothing.
const photoFilename = (sku, face) => 'photos/' + String(sku + '-' + face).toLowerCase() + '.jpg';

function preparePhoto(file, { sku, face, longEdge = PHOTO_LONG_EDGE, quality = PHOTO_QUALITY } = {}) {
  return new Promise((resolve, reject) => {
    if (!file) return reject(new Error('preparePhoto: no file'));
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('preparePhoto: could not decode that image')); };
    img.onload = () => {
      const scale = Math.min(1, longEdge / Math.max(img.naturalWidth, img.naturalHeight));
      const w = Math.max(1, Math.round(img.naturalWidth * scale));
      const h = Math.max(1, Math.round(img.naturalHeight * scale));
      const c = document.createElement('canvas');
      c.width = w; c.height = h;
      c.getContext('2d').drawImage(img, 0, 0, w, h);
      URL.revokeObjectURL(url);
      c.toBlob(
        (blob) => blob
          ? resolve({ blob, filename: photoFilename(sku, face), width: w, height: h, bytes: blob.size })
          : reject(new Error('preparePhoto: encoding failed')),
        'image/jpeg', quality);
    };
    img.src = url;
  });
}

/* ───────────── is the photograph actually on the site? ─────────────
   A browser cannot commit a file to the repo. It can produce the right file and
   hand it over, and that is where the capture step ends — so recording the path
   at that moment would clear the Ops flag while the site still has no image,
   and Ops would be lying about the one thing it is there to report.

   So the path is only filed under `photos` once the file answers from the site.
   Until then it sits in `photosPending`, which counts for nothing. This is the
   same distinction as `costedOn`: captured is not deployed, and a flag that
   clears on intent rather than on fact is worse than no flag. */
async function photoIsLive(path) {
  try {
    const res = await fetch(path, { method: 'HEAD', cache: 'no-store' });
    return res.ok;
  } catch (e) {
    return false;
  }
}

async function confirmPhoto(sku, path) {
  const row = INVENTORY.find(r => r.sku === sku);
  if (!row) throw new Error('confirmPhoto: no such SKU ' + sku);
  const live = await photoIsLive(path);
  const add = (list) => [...new Set([...(row[list] || []), path])];
  if (live) {
    row.photos = add('photos');
    row.photosPending = (row.photosPending || []).filter(p => p !== path);
  } else {
    row.photosPending = add('photosPending');
  }
  return { live, path, sku };
}

// Absent `photos` is a legitimate state, exactly as `market: null` is — it
// renders as a gap, never as a zero. Both of these read a missing field rather
// than requiring every row to carry an empty array.
const skuPhotos = (row) => (Array.isArray(row.photos) ? row.photos : []);
const skuPendingPhotos = (row) => (Array.isArray(row.photosPending) ? row.photosPending : []);
function photoGap(row, opts) {
  const need = captureFor(skuAsk(row), opts);
  const have = skuPhotos(row).length;
  return { need, have, missing: Math.max(0, need.faces - have), blocking: need.faces > 0 && have === 0 };
}

Object.assign(window, {
  CARD_SETS, cardSet, CARD_RARITY, CONDITIONS, condition,
  CARD_CATALOG, cardById, cardLabel,
  INVENTORY, BULK_LOTS, addScannedSku, setScannedCost, allocateLotCost,
  captureFor, skuPhotos, skuPendingPhotos, photoGap,
  PHOTO_LONG_EDGE, PHOTO_QUALITY, photoFilename, preparePhoto, photoIsLive, confirmPhoto,
  skuAsk, skuValue, skuCost, uncostedQty, costedQty,
  inventoryRoll, CARD_FEED,
  CHANNELS, DEFAULT_CHANNEL, channel, shippingCost, netProceeds, listingLine,
  BULK_BUYER_RATE, LOT_DISCOUNT, lotProceeds,
});
