/* ============================================================
   VYNDR — MARKET BREADTH / CONSENSUS-vs-MODEL (Wave 4A, Step 4).

   Makes the DeskShowcase "consensus vs model, live line moves" claim REAL.
   Given a prop's per-book rows + the model's projected value, compute the
   market CONSENSUS (median book line) and the model's position vs that
   consensus, SIGNED by the graded side (so an over that the model projects
   ABOVE the market and an under it projects BELOW both read as a positive
   edge → signal-green via the color contract).

   DATA-SEMANTICS RULE: VYNDR never invents a market number. A consensus is
   only honest with ≥2 DISTINCT books posting a finite line → otherwise null
   (absent beats invented). A non-numeric line is IGNORED, never coerced to
   0 (the classic `Number(null) === 0` fabrication bug).

   Plain CommonJS so the .tsx strip imports it (allowJs) AND Jest exercises
   the logic directly — same pattern as slateAdapter.js / colorContract.js.
   ============================================================ */

/** Strict numeric parse — null (never 0) when a value isn't a real number. */
function numOrNull(v) {
  const n = typeof v === 'number' ? v : parseFloat(v);
  return Number.isFinite(n) ? n : null;
}

const round2 = (x) => Math.round(x * 100) / 100;

/** Median of the finite numbers in `nums`. Empty / all-non-finite → null. */
function median(nums) {
  const sorted = (Array.isArray(nums) ? nums : [])
    .map(numOrNull)
    .filter((n) => n != null)
    .sort((a, b) => a - b);
  if (sorted.length === 0) return null;
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : round2((sorted[mid - 1] + sorted[mid]) / 2);
}

/**
 * computeBreadth(books, modelValue, side) → breadth | null.
 *
 * `books` = the prop's per-book rows ([{ book, line, over_odds, under_odds }])
 * — the same grouped shape the slate threads onto each prop. One opinion per
 * DISTINCT book (dedupe by book name). <2 distinct books with a finite line
 * → null (no honest consensus).
 *
 * Returns:
 *   consensus  — median book line (the market's line)
 *   bookCount  — distinct books contributing a finite line
 *   model      — the model's projected value (numOrNull) or null
 *   delta      — model − consensus (signed to the MARKET), or null
 *   signedEdge — signed to the GRADED SIDE (positive = model beats market),
 *                or null when the model value is absent
 *   position   — 'above' | 'below' | 'inline' (model vs consensus), or null
 *   side       — normalized 'over' | 'under'
 */
function computeBreadth(books, modelValue, side = 'over') {
  const rows = Array.isArray(books) ? books : [];
  const byBook = new Map();
  for (const r of rows) {
    if (!r || !r.book) continue;
    const ln = numOrNull(r.line);
    if (ln == null) continue;
    // First finite line per distinct book wins (one opinion per book).
    if (!byBook.has(r.book)) byBook.set(r.book, ln);
  }
  if (byBook.size < 2) return null; // <2 books → never fabricate a consensus

  const consensus = median([...byBook.values()]);
  const isUnder = String(side || 'over').toLowerCase().startsWith('u');
  const model = numOrNull(modelValue);

  let delta = null;
  let signedEdge = null;
  let position = null;
  if (model != null && consensus != null) {
    delta = round2(model - consensus);
    signedEdge = round2(isUnder ? consensus - model : model - consensus);
    position = delta > 0 ? 'above' : delta < 0 ? 'below' : 'inline';
  }

  return {
    consensus,
    bookCount: byBook.size,
    model,
    delta,
    signedEdge,
    position,
    side: isUnder ? 'under' : 'over',
  };
}

/**
 * collectBreadth(items, limit) — compute breadth for a list of props and
 * return the qualifying rows ranked by |signedEdge| desc (the biggest model
 * disagreements with the market lead). Non-qualifying props (<2 books) are
 * dropped, so an empty result means the strip self-hides.
 *
 * `items` = [{ player, stat, side, line, books, modelValue }].
 */
function collectBreadth(items, limit = 6) {
  const out = [];
  for (const it of Array.isArray(items) ? items : []) {
    if (!it) continue;
    const b = computeBreadth(it.books, it.modelValue, it.side);
    if (!b) continue;
    // Betting-logic audit — a CONSENSUS-vs-MODEL row needs a model opinion. A
    // prop with no model (ungraded, or a refused/suppressed read like a
    // rare-event 0.5 market) has nothing to compare — drop it, don't rank a
    // "MODEL —" placeholder onto the board.
    if (b.model == null || b.signedEdge == null) continue;
    out.push({ player: it.player, stat: it.stat, line: numOrNull(it.line), ...b });
  }
  const abs = (x) => Math.abs(numOrNull(x) == null ? 0 : numOrNull(x));
  out.sort((a, b) => abs(b.signedEdge) - abs(a.signedEdge));
  return out.slice(0, Math.max(0, limit));
}

module.exports = { median, computeBreadth, collectBreadth, numOrNull };
