/* ============================================================
   VYNDR — live progress engine (A1 board, Session 11).

   LIVE TRACKING math + the strip join. The read is locked pre-game;
   these marks are PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome
   slot (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6).
   GRADES NEVER CHANGE IN-GAME.

   Plain CommonJS so the .tsx components import it (allowJs) AND the
   plain-JS Jest suite exercises every branch directly.

   COLOR LAW (one meaning per color): green = on-pace / already-cleared /
   holding; amber = needs-more / line-passed caution. Red is RESERVED for
   settled-negative truth — an in-progress prop is NEVER red.

   DATA SEMANTICS: a player not in the box has no live entry → no mark,
   never a fabricated 0. All numeric paths are strict-null.
   ============================================================ */

const { nameKey } = require('./playerName');

/** Strict numeric read — null when absent/unparseable, never 0-by-default. */
function numOrNull(v) {
  if (v == null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

/**
 * Pure prop-state math. { side, line, current, progress } → state or null.
 *
 *   over, current > line          → 'hit'      (✓ the over has already cleared
 *                                   — a counting stat cannot un-clear; still
 *                                   TRACKING until the settle pass owns it)
 *   over, projected to clear      → 'on_pace'  (green)
 *   over, otherwise               → 'needs'    (amber, NEEDS N)
 *   under, current < line         → 'holding'  (green HOLDS — an under is
 *                                   never 'hit' until final)
 *   under, current ≥ line         → 'past'     (amber LINE PASSED — not red;
 *                                   nothing settles until the game is final)
 *   current/line missing          → null       (absent beats wrong)
 *
 * NEEDS N beats a push on integer lines: N = floor(line) + 1 − current,
 * ceil'd for fractional stats (IP thirds) — over-strict amber beats an
 * over-claimed green. `progress` is the game fraction (innings/9, quarters/4);
 * on-pace = current / progress ≥ floor(line) + 1. No progress → no pace
 * judgement (stays NEEDS N — honest, not optimistic).
 */
function propState({ side, line, current, progress } = {}) {
  const ln = numOrNull(line);
  const cur = numOrNull(current);
  if (ln == null || cur == null) return null;
  const under = /^u/i.test(String(side || 'O'));
  if (!under) {
    if (cur > ln) return { state: 'hit', label: 'HIT ✓', needs: 0 };
    const clearAt = Math.floor(ln) + 1;
    const needs = Math.max(1, Math.ceil(clearAt - cur - 1e-9));
    const p = numOrNull(progress);
    const onPace = p != null && p > 0 && cur / p >= clearAt;
    return onPace
      ? { state: 'on_pace', label: 'ON PACE', needs }
      : { state: 'needs', label: `NEEDS ${needs}`, needs };
  }
  if (cur < ln) return { state: 'holding', label: 'HOLDS' };
  return { state: 'past', label: 'LINE PASSED' };
}

/**
 * Flatten /api/live/:sport response(s) → one join index:
 * { hasLive, count, players: { [nameKey]: { name, team, values, progress, gameId } } }.
 * Accepts a single response or an array (the Slate merges sports).
 */
function buildLiveIndex(responses) {
  const list = Array.isArray(responses) ? responses : [responses];
  const players = {};
  let hasLive = false;
  let count = 0;
  for (const resp of list) {
    if (!resp) continue;
    if (resp.hasLive) hasLive = true;
    for (const g of resp.games || []) {
      for (const [key, rec] of Object.entries(g.players || {})) {
        if (!rec) continue;
        players[key] = {
          name: rec.name,
          team: rec.team || null,
          values: rec.values || {},
          progress: g.progress || null,
          gameId: g.id,
        };
        count += 1;
      }
    }
  }
  return { hasLive, count, players };
}

/**
 * Join built player strips ↔ the live index (PURE). Only GRADED, unsettled,
 * non-dead props get `prop.live` — settled outcomes, dead reads and awaiting
 * rows are untouched, and a player absent from the box gets NO mark. The
 * state is computed against the LOCKED line (gradedAt.line when present) —
 * the read never re-grades.
 */
function attachLiveProgress(strips, liveIndex) {
  const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
  if (!Array.isArray(strips) || !idx || Object.keys(idx).length === 0) return strips || [];
  return strips.map((strip) => {
    const entry = idx[nameKey(strip.player)];
    if (!entry) return strip;
    const fraction = entry.progress ? numOrNull(entry.progress.fraction) : null;
    let touched = false;
    const props = (strip.props || []).map((p) => {
      if (!p || !p.grade || p.outcome || p.dead || p.awaiting) return p;
      const st = String(p.statType || '').toLowerCase();
      if (!st) return p;
      const current = entry.values ? numOrNull(entry.values[st]) : null;
      if (current == null) return p; // not in the box for this stat — absent
      const line = p.gradedAt && numOrNull(p.gradedAt.line) != null ? p.gradedAt.line : p.line;
      const state = propState({ side: p.side, line, current, progress: fraction });
      if (!state) return p;
      touched = true;
      return {
        ...p,
        live: {
          current,
          line: numOrNull(line),
          ...state,
          progressLabel: entry.progress ? entry.progress.label || null : null,
          progressFraction: fraction,
        },
      };
    });
    return touched ? { ...strip, props } : strip;
  });
}

/**
 * Proximity-to-hit for one game's raw odds props (the Slate's sort key).
 * A prop is TRACKED when it's snapshot-graded AND its player has a live box
 * value for the stat. Proximity (overs only, per spec): current / needed-total
 * = current / (floor(line)+1), capped at 1; already-cleared overs count 1.
 * Unders count as tracked but contribute no over-proximity.
 * Returns { tracked, proximity }.
 */
function gameLiveProximity(rawProps, gradeIndex, liveIndex) {
  const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
  if (!Array.isArray(rawProps) || !idx || !gradeIndex) return { tracked: false, proximity: 0 };
  let tracked = false;
  let proximity = 0;
  for (const p of rawProps) {
    if (!p || !p.player) continue;
    const stat = String(p.stat_type || p.stat || '').toLowerCase();
    const key = nameKey(p.player);
    const rec = gradeIndex[`${key}|${stat}`];
    if (!rec || !rec.grade) continue;
    const entry = idx[key];
    if (!entry) continue;
    const current = entry.values ? numOrNull(entry.values[stat]) : null;
    if (current == null) continue;
    tracked = true;
    const under = /^u/i.test(String(rec.direction || 'over'));
    if (under) continue;
    const line = rec.gradedAt && numOrNull(rec.gradedAt.line) != null ? rec.gradedAt.line : rec.line;
    const ln = numOrNull(line);
    if (ln == null) continue;
    const clearAt = Math.floor(ln) + 1;
    const frac = clearAt > 0 ? Math.min(1, current / clearAt) : 0;
    if (frac > proximity) proximity = frac;
  }
  return { tracked, proximity };
}

/**
 * Stable partition sort: items whose scorer says tracked float to the top,
 * ordered by proximity desc; everything else keeps its original order.
 */
function sortLiveFirst(items, scorer) {
  if (!Array.isArray(items) || typeof scorer !== 'function') return items || [];
  const scored = items.map((it, i) => {
    const s = scorer(it) || { tracked: false, proximity: 0 };
    return { it, i, tracked: !!s.tracked, proximity: numOrNull(s.proximity) || 0 };
  });
  return scored
    .sort((a, b) => {
      if (a.tracked !== b.tracked) return a.tracked ? -1 : 1;
      if (a.tracked && b.tracked && b.proximity !== a.proximity) return b.proximity - a.proximity;
      return a.i - b.i;
    })
    .map((x) => x.it);
}

module.exports = {
  propState,
  buildLiveIndex,
  attachLiveProgress,
  gameLiveProximity,
  sortLiveFirst,
  numOrNull,
};
