'use strict';

/**
 * ledgerService — the truth infrastructure (Session 58, work-order Phase 1).
 *
 * Persists every grade to Supabase `ledger_entries`:
 *   - Pipeline pre-grades (user_id NULL) — the PUBLIC model record. Written by
 *     snapshotService after each snapshot locks. This is the priority path:
 *     hundreds of settles per night vs user scans trickling in.
 *   - User scans are written by the web /api/scan route (it owns the user
 *     identity); this service owns settlement + closing capture for BOTH.
 *
 * DATA SEMANTICS: `line` / `locked_odds` / `book` / `closing_line` /
 * `closing_odds` are REAL book values captured at grade / refresh time —
 * never computed. `model_value` is VYNDR's projection. A grade with no
 * projection (insufficient_data) is never written — no hollow rows.
 *
 * Closing-line value: captureClosing runs on EVERY snapshot and overwrites
 * closing_line/closing_odds for today's unsettled rows with the CURRENT feed
 * values — the last write before the game starts is the closing line (once a
 * game starts its props leave the feed, so updates stop naturally).
 * settleLedger then computes `clv` SIGNED BY SIDE: for an OVER, a closing
 * line BELOW the locked line = the market moved toward the graded side =
 * positive = 'beat'. For an UNDER, the inverse.
 *
 * Everything is injectable; without SUPABASE env the service no-ops
 * gracefully (tests / local dev without a database).
 */

const { nameKey, normalizeName } = require('../utils/playerName');
const { settleResult, statValue, logRowOnDate } = require('./outcomeService');

const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
const UPSERT_CHUNK = 200;
const SETTLE_FETCH_LIMIT = 500;
// Session 64 — bounded retry: a row that cannot be resolved after this many
// date-targeted attempts becomes 'unrecoverable' rather than pending forever.
const SETTLE_ATTEMPT_CAP = Number(process.env.SETTLE_ATTEMPT_CAP || 4);
// Bump when the settlement RULE changes, so healed rows are distinguishable
// from originals and the harness can filter by how a row was scored.
const SETTLEMENT_VERSION = Number(process.env.SETTLEMENT_VERSION || 2);
const settleSource = require('./settleSource');
const MODEL_ERA_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';
/** Day's games for a sport (date-pinned since S57) — tells us what a player's
 *  ABSENCE means: DNP, postponed, or simply not final yet. */
async function getScheduleFn(sport, date) {
  // Never reach the network from a test run (the opsNotify/refreshTeamStats
  // precedent) — a schedule lookup inside the settle loop would hang the suite.
  if (process.env.NODE_ENV === 'test') return [];
  try {
    return await require('./scheduleService').getSchedule(sport, date);
  } catch { return []; }
}
const AGG_WINDOW_DAYS = 30;
const AGG_FETCH_LIMIT = 5000;
/** Below this many settled rows, callers must not render a percentage. */
const MIN_AGG_SAMPLE = 20;
// Truth-Everywhere Part 2 (item 7) — CLV capture is broken (closing_line ==
// locked_line; see the C4 finding). Until C4 records a real closing line,
// beat_close/CLV are suppressed everywhere. Read at call time (not module load)
// so C4 can flip it via CLV_CAPTURE_RELIABLE=1 without a redeploy, and tests can
// exercise the CLV math directly.
function clvCaptureReliable() { return process.env.CLV_CAPTURE_RELIABLE === '1'; }

/**
 * S6 (A1 board) — CLV distribution buckets (the MODEL tab strip). Signed CLV:
 * positive = beat the close. Outliers clamp into the edge buckets so every
 * settled clv lands somewhere. `side` drives the UI color (green = beat,
 * red = faded, dim = flat) — same meanings as clv_result.
 */
const CLV_BUCKETS = [
  { label: '[-2,-1)', min: -2, max: -1, side: 'faded' },
  { label: '[-1,-.5)', min: -1, max: -0.5, side: 'faded' },
  { label: '[-.5,0)', min: -0.5, max: 0, side: 'faded' },
  { label: '0', min: 0, max: 0, side: 'flat' },
  { label: '(0,.5]', min: 0, max: 0.5, side: 'beat' },
  { label: '(.5,1]', min: 0.5, max: 1, side: 'beat' },
  { label: '(1,2]', min: 1, max: 2, side: 'beat' },
];

/** Bucket index for one signed clv value (clamped into the edge buckets). */
function clvBucketIndex(clv) {
  const v = numOrNull(clv); // strict — Number(null) is 0, a fabricated CLV
  if (v == null) return -1;
  if (v === 0) return 3;
  if (v < 0) {
    if (v >= -0.5) return 2;
    if (v >= -1) return 1;
    return 0; // ≤ -1 clamps into [-2,-1)
  }
  if (v <= 0.5) return 4;
  if (v <= 1) return 5;
  return 6; // > 1 clamps into (1,2]
}

function isConfigured() {
  return Boolean(process.env.SUPABASE_URL
    && (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY));
}

function defaultClient() {
  return require('../utils/supabase').getSupabaseServiceClient();
}

/** ET calendar date (YYYY-MM-DD) of an ISO timestamp; null when unparseable. */
function dateET(iso) {
  if (!iso) return null;
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return null;
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(d);
}

const todayET = () => dateET(new Date().toISOString());

/** Derived game id when the feed carries no event id: sport:date:AWAY@HOME. */
function gameIdFor(sport, prop, gameDate) {
  const away = String((prop && prop.away_team) || 'UNK').replace(/\s+/g, '');
  const home = String((prop && prop.home_team) || 'UNK').replace(/\s+/g, '');
  return `${sport}:${gameDate}:${away}@${home}`;
}

const sideOf = (direction) =>
  (String(direction || 'over').toLowerCase() === 'under' ? 'under' : 'over');

// Strict numeric parse: null/undefined stay null (Number(null) is 0 — a
// fabricated zero line/odds is exactly what the data-semantics rule forbids).
const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));

// Nickname token (last word, lowercased) — the stable cross-source team
// identifier ("New York Yankees" ↔ "Yankees" ↔ "NYY" won't match, but
// full-name feeds match full-name feeds; abbr feeds match abbr feeds).
const nickToken = (name) => {
  const w = String(name || '').trim().split(/\s+/);
  return (w[w.length - 1] || '').toLowerCase().replace(/[^a-z]/g, '');
};
const teamsMatch = (a, b) => {
  if (!a || !b) return false;
  const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
  return sa === sb || nickToken(a) === nickToken(b);
};

/**
 * Session 59 — team/opponent from the REAL feed. The player's team comes
 * from the stats resolve (g.team); the opponent is the other side of the
 * prop's game IF the team matches one of its participants. No match →
 * opponent stays null — never guessed.
 */
function teamOpponentFor(g, prop) {
  const team = g && g.team ? String(g.team) : null;
  if (!team || !prop) return { team, opponent: null };
  if (teamsMatch(team, prop.home_team)) return { team, opponent: prop.away_team || null };
  if (teamsMatch(team, prop.away_team)) return { team, opponent: prop.home_team || null };
  return { team, opponent: null };
}

/** Index odds props by nameKey|stat for lock/closing lookups.
 *  Session 61 — prefer a book row with BOTH sides priced (same rule as
 *  snapshotService.indexOdds): fewer genuinely-absent locked/closing odds
 *  when another book carried the side. Real rows only, never synthesized. */
function indexProps(props) {
  const map = {};
  const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
  for (const p of props || []) {
    if (!p || !p.player || !p.stat_type) continue;
    const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
    if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p;
  }
  return map;
}

function oddsForSide(prop, side) {
  if (!prop) return null;
  const v = side === 'under'
    ? (prop.under_odds ?? prop.under ?? null)
    : (prop.over_odds ?? prop.over ?? null);
  return v == null ? null : String(v);
}

/**
 * Build ledger rows from a snapshot's enriched grades + the raw odds props.
 * Skips anything without a real grade or without a captured line — the
 * ledger never holds a fabricated market value or a refused read.
 */
/**
 * The Layer-2 blend as it stood at grade time. Stored as the VECTOR, not a
 * label: "did archetype-awareness help?" can only be answered against the axes
 * that were live, and a single text column cannot express a blend. Null (not
 * an empty object) when the grade carried no archetype — honest absence.
 */
function archetypeVectorOf(g) {
  if (!g || typeof g !== 'object') return null;
  if (g.archetype_axes && typeof g.archetype_axes === 'object') return g.archetype_axes;
  if (Array.isArray(g.archetype_blend) && g.archetype_blend.length) {
    return { blend: g.archetype_blend, primary: g.archetype || null };
  }
  if (g.archetype) return { blend: [], primary: g.archetype };
  return null;
}

function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
  const sp = String(sport || '').toLowerCase();
  let skippedUnbound = 0;
  const byKey = indexProps(oddsProps);
  const rows = [];
  for (const g of grades || []) {
    if (!g || !g.grade || g.insufficient_data) continue;
    const player = g.player || g.player_name;
    if (!player) continue;
    const stat = String(g.stat_type || g.stat || '').toLowerCase();
    if (!stat) continue;
    const side = sideOf(g.direction);
    const locked = g.gradedAt || {};
    const line = numOrNull(locked.line) ?? numOrNull(g.line);
    if (line == null) continue; // no real captured line → no row
    const prop = byKey[`${nameKey(player)}|${stat}`] || null;
    const gradedTs = locked.timestamp || nowIso;
    // Session 64 (Order 1.5) — a game date comes from the GAME, never from the
    // grade clock. The old `|| dateET(gradedTs) || todayET()` fallback is what
    // filed tonight's props under yesterday and made settlement impossible.
    // gameBinder attaches game_time upstream; if a prop still has none, the
    // row is UNRESOLVED and is skipped — the ledger holds real values or
    // nothing, and a mis-dated row is fabricated data.
    const gameDate = dateET(prop && prop.game_time) || (prop && prop.game_date) || null;
    if (!gameDate) { skippedUnbound += 1; continue; }
    const { team, opponent } = teamOpponentFor(g, prop);
    rows.push({
      team,
      opponent,
      user_id: null,
      player_key: nameKey(player),
      player_name: normalizeName(player).display || player,
      sport: sp,
      stat,
      line,
      side,
      locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(prop, side),
      book: (prop && prop.book) || g.book || null,
      grade: g.grade,
      edge: numOrNull(g.edge_pct),
      confidence: numOrNull(g.confidence),
      // Session 70 — THE INSTRUMENT. p_win is the projection we ACTUALLY made,
      // captured at lock and never re-derived at settle: a re-derivation would
      // measure a projection that never happened. fair_prob_lock is the market
      // at the same instant, so lock-vs-close movement is attributable.
      // archetype_vector is the Layer-2 blend as it stood at grade time, so
      // calibration can be sliced BY archetype later — a text label could not
      // attribute anything.
      p_win: numOrNull(g.p_win),
      // Session 71 — the CHALLENGER, retained beside the champion on the SAME
      // row so both join to the same outcome and the same close. The champion
      // is what served the user; this is measured, never served.
      p_win_challenger: numOrNull(g.p_win_challenger),
      challenger_delta: numOrNull(g.challenger_delta),
      challenger_adjustments: g.challenger_adjustments || null,
      challenger_version: g.challenger_version || null,
      // Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
      // not the actual: this is what we knew when we projected, and it is what
      // the instrument measures. The actual lands in game_context and is never
      // read from here.
      wx_forecast: g.wx_forecast || null,
      env_multiplier: numOrNull(g.env_multiplier),
      env_park_base: numOrNull(g.env_park_base),
      env_weather_mod: numOrNull(g.env_weather_mod),
      env_weather_state: g.env_weather_state || null,
      fair_prob_lock: numOrNull(g.fair_prob),
      archetype_vector: archetypeVectorOf(g),
      projection_locked_at: gradedTs,
      model_value: numOrNull(g.projection),
      graded_at: gradedTs,
      // Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are
      // labelled 'pre-retention-unknown' by migration 026; they cannot be
      // resolved retroactively.
      model_version: MODEL_ERA_VERSION,
      game_id: gameIdFor(sp, prop, gameDate),
      game_date: gameDate,
    });
  }
  if (skippedUnbound > 0) {
    console.warn(`[ledger] ${skippedUnbound} ${sp} rows SKIPPED — no real game time; refusing to date them from the grade clock`);
  }
  return rows;
}

/**
 * Upsert the pipeline's pre-grades (user_id NULL — the public model record).
 * Idempotent: re-runs hit the dedupe constraint and are IGNORED, so the
 * original locked line/odds are never overwritten by a later run.
 */
async function recordPipelineGrades(sport, grades, oddsProps, opts = {}) {
  if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', written: 0 };
  const sb = opts.sb || defaultClient();
  const nowIso = (opts.now || (() => new Date().toISOString()))();
  const rows = rowsFromSnapshot(sport, grades, oddsProps, nowIso);
  if (rows.length === 0) return { written: 0 };
  let written = 0;
  for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
    const chunk = rows.slice(i, i + UPSERT_CHUNK);
    const { error } = await sb.from('ledger_entries')
      .upsert(chunk, { onConflict: CONFLICT, ignoreDuplicates: true });
    if (error) return { written, error: error.message };
    written += chunk.length;
  }
  return { written };
}

/**
 * attachClosingProb(sport, opts) — the MARKET half of the instrument.
 *
 * Reads the append-only `closing_captures` (100% coverage on graded props, and
 * the only store with real provenance) and writes the de-vigged closing
 * probability onto the matching ledger row. Write-once: a row that already has
 * a `closing_prob` is never rewritten, so the FIRST true close is the one that
 * survives — the same lock-wall discipline the grade itself follows.
 *
 * A row with no usable capture gets `market_unavailable_reason`, NOT a guessed
 * price. Calibration (p_win vs outcome) still works on those rows; only
 * market-comparison is absent, and it says so.
 */
async function attachClosingProb(sport, opts = {}) {
  if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
  const sb = opts.sb || defaultClient();
  const sp = String(sport || '').toLowerCase();

  const { data: rows, error } = await sb.from('ledger_entries')
    .select('id, player_key, stat, side, game_date')
    .is('user_id', null).eq('sport', sp)
    .is('closing_prob', null).is('market_unavailable_reason', null)
    .limit(opts.limit || 2000);
  if (error) return { updated: 0, error: error.message };
  if (!rows || !rows.length) return { updated: 0, absent: 0 };

  // closing_captures deliberately stores BOTH RAW SIDE PRICES rather than a
  // probability (Session 64), so the de-vig runs here — the same devigTwoWay
  // the grade-time fair price uses, which is what makes lock and close
  // comparable at all. Asking this table for a `fair_prob` column is a bug: it
  // has none, and every row then looks closeless.
  const { devigTwoWay } = require('../utils/devig');
  const { data: caps } = await sb.from('closing_captures')
    .select('player_key, stat, side, game_date, over_odds, under_odds, captured_at, missed_reason')
    .eq('sport', sp).limit(50000);

  // Latest usable capture per identity = the TRUE close.
  const best = new Map();
  for (const c of caps || []) {
    if (c.missed_reason) continue;
    const dv = devigTwoWay(c.over_odds, c.under_odds);
    const leg = dv && dv[String(c.side).toLowerCase() === 'under' ? 'under' : 'over'];
    const fp = leg && Number.isFinite(leg.fair_prob) ? leg.fair_prob : null;
    if (fp == null) continue; // one-sided or unusable → not a close
    const k = `${c.player_key}|${c.stat}|${c.side}|${c.game_date}`;
    const prev = best.get(k);
    if (!prev || String(c.captured_at) > String(prev.captured_at)) best.set(k, { ...c, fair_prob: fp });
  }

  let updated = 0; let absent = 0;
  // A close can still ARRIVE for a game that has not started. Marking today's
  // rows market-unavailable would be premature absence — as dishonest in the
  // other direction as imputing one. Only a past game can be declared closeless.
  const cutoff = opts.beforeDate || todayET();
  for (const r of rows) {
    const hit = best.get(`${r.player_key}|${r.stat}|${r.side}|${r.game_date}`);
    if (!hit && String(r.game_date) >= String(cutoff)) continue; // still capturable
    const patch = hit
      ? { closing_prob: hit.fair_prob, closing_captured_at: hit.captured_at }
      : { market_unavailable_reason: 'no_usable_close' };
    const { error: e } = await sb.from('ledger_entries').update(patch).eq('id', r.id);
    if (e) continue;
    if (hit) updated += 1; else absent += 1;
  }
  return { updated, absent, candidates: rows.length };
}

/**
 * Overwrite closing_line/closing_odds on today's UNSETTLED rows from the
 * current (real) odds feed. Runs on every snapshot; the last capture before
 * game start is the closing line. Matches by player_key+stat — the closing
 * line may legitimately differ from the locked line (that's CLV).
 */
async function captureClosing(sport, oddsProps, opts = {}) {
  if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
  const sb = opts.sb || defaultClient();
  const sp = String(sport || '').toLowerCase();
  const gameDate = opts.gameDate || todayET();
  const byKey = indexProps(oddsProps);
  if (Object.keys(byKey).length === 0) return { updated: 0 };

  const { data: open, error } = await sb.from('ledger_entries')
    .select('id, player_key, stat, side')
    .eq('sport', sp)
    .eq('game_date', gameDate)
    .is('outcome', null)
    .limit(SETTLE_FETCH_LIMIT);
  if (error) return { updated: 0, error: error.message };

  let updated = 0;
  // Group row ids by identical closing values → one UPDATE per prop.
  const groups = new Map();
  for (const row of open || []) {
    const prop = byKey[`${row.player_key}|${row.stat}`];
    const closingLine = prop ? numOrNull(prop.line) : null;
    if (closingLine == null) continue;
    const closingOdds = oddsForSide(prop, row.side);
    const gk = `${closingLine}|${closingOdds ?? ''}`;
    if (!groups.has(gk)) groups.set(gk, { line: closingLine, odds: closingOdds, ids: [] });
    groups.get(gk).ids.push(row.id);
  }
  for (const g of groups.values()) {
    const { error: upErr } = await sb.from('ledger_entries')
      .update({ closing_line: g.line, closing_odds: g.odds })
      .in('id', g.ids);
    if (!upErr) updated += g.ids.length;
  }
  return { updated };
}

/**
 * Signed CLV per the Phase 1 amendment: positive = the market moved TOWARD
 * the graded side. OVER: locked − closing (closing dropped ⇒ positive).
 * UNDER: closing − locked.
 */
function computeClv(side, lockedLine, closingLine) {
  const locked = numOrNull(lockedLine);
  const closing = numOrNull(closingLine);
  if (locked == null || closing == null) return null;
  const raw = sideOf(side) === 'over' ? locked - closing : closing - locked;
  return Math.round(raw * 100) / 100;
}

function clvResultOf(clv) {
  if (clv == null) return null;
  if (clv > 0) return 'beat';
  if (clv < 0) return 'faded';
  return 'flat';
}

/**
 * Settle unsettled ledger rows with game_date <= yesterday against the real
 * stat result (same free MLB game-log source outcomeService uses; other
 * sports stay pending until they have a settled-result feed). Also computes
 * CLV from the captured closing line. Idempotent: only rows with
 * outcome IS NULL are fetched, and a row is written at most once.
 */

/**
 * Per-read directional CLV for one settling row (Session 64).
 *
 * THE JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date)
 * — WITHOUT `line`, because a close that moved off the graded line is the whole
 * point. Verified clean: 164 identity groups, zero ambiguity.
 *
 * The LOCK end reads model_snapshots (both side prices + an already-de-vigged
 * fair_prob from the same devig function), NOT ledger_entries.locked_odds,
 * which is single-side and cannot be de-vigged.
 *
 * Never throws: a CLV failure must not block a settlement.
 */
async function computeDirectionalForRow(sb, sport, row, deps = {}) {
  try {
    const dclv = deps.directionalClv || require('./directionalClv');
    const [{ data: snaps }, { data: closes }] = await Promise.all([
      sb.from('model_snapshots')
        .select('fair_prob, over_odds, under_odds, captured_at')
        .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat)
        .eq('side', row.side).eq('game_date', row.game_date)
        .order('captured_at', { ascending: true }).limit(1),
      sb.from('closing_captures')
        .select('over_odds, under_odds, missed_reason, captured_at')
        .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat)
        .eq('side', row.side).eq('game_date', row.game_date)
        .order('captured_at', { ascending: false }).limit(1),
    ]);
    const lock = snaps && snaps[0];
    const close = closes && closes[0];
    if (!lock) return null;                       // no retained lock → nothing to compare
    return dclv.computeDirectionalClv({
      side: row.side,
      lockFairProb: lock.fair_prob,
      lockOverOdds: lock.over_odds,
      lockUnderOdds: lock.under_odds,
      closeOverOdds: close ? close.over_odds : null,
      closeUnderOdds: close ? close.under_odds : null,
      missedReason: close ? close.missed_reason : null,
    });
  } catch (e) {
    console.warn('[ledger] directional CLV failed (settlement continues):', e.message);
    return null;
  }
}

async function settleLedger(sport, opts = {}) {
  if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', settled: 0, pending: 0 };
  const sb = opts.sb || defaultClient();
  const sp = String(sport || '').toLowerCase();
  const nowIso = (opts.now || (() => new Date().toISOString()))();
  const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats;
  // Session 64 — injectable like every other dep: tests must never reach the
  // network, and a schedule lookup inside the settle loop would otherwise hang
  // the suite.
  const getSchedule = opts.getSchedule || getScheduleFn;
  const cutoff = opts.beforeDate || todayET(); // settle strictly-before today

  const { data: open, error } = await sb.from('ledger_entries')
    .select('id, player_key, player_name, stat, line, side, closing_line')
    .eq('sport', sp)
    .is('outcome', null)
    .lt('game_date', cutoff)
    .order('game_date', { ascending: true })
    .limit(SETTLE_FETCH_LIMIT);
  if (error) return { settled: 0, pending: 0, error: error.message };
  if (!open || open.length === 0) return { settled: 0, pending: 0 };

  // We need each row's game_date for the log match — refetch with it included.
  const { data: rows } = await sb.from('ledger_entries')
    .select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts, dclv_computed_at, player_key')
    .in('id', open.map((r) => r.id));

  // One game-log fetch per unique player.
  const players = [...new Set((rows || []).map((r) => r.player_name))];
  const logByPlayer = {};
  for (const player of players) {
    try {
      const stats = await getPlayerStats(player, sp);
      // Session 64 — prefer the FULL season log for settlement. MLB already
      // fetched it (getPlayerStats was discarding it via .slice(-10)), so this
      // costs nothing and removes window-decay entirely. Fall back to last10
      // for sources that only expose a window (ESPN).
      const full = stats && Array.isArray(stats.fullLog) ? stats.fullLog : null;
      const win = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
      logByPlayer[player] = (full && full.length) ? full : win;
    } catch { logByPlayer[player] = []; }
  }

  let settled = 0;
  let pending = 0;
  let voided = 0;
  let unrecoverable = 0;
  for (const row of rows || []) {
    const log = logByPlayer[row.player_name] || [];
    // Session 64 — DATE-TARGETED settlement. The old code searched a ROLLING
    // window and, on a miss, did `pending += 1` with no terminal state: a row
    // that could never settle (player DNP, or the window rolled past the game)
    // looked identical to one settling tomorrow, and pended forever.
    // `resolveOutcome` reads the FULL log for that exact date and, when the
    // player is absent, consults the day's SCHEDULE to learn what the absence
    // MEANS — DNP vs postponed vs not-yet-final.
    const res = await settleSource.resolveOutcome({
      sport: sp,
      playerName: row.player_name,
      gameDate: row.game_date,
      statType: row.stat,
      getFullLog: async () => log,
      getSchedule,
      matchesDate: (r, d) => logRowOnDate(r, d, sp),
      statValue: (statObj, st) => statValue(statObj, st, sp),
    });

    const attempts = Number(row.settle_attempts || 0) + 1;

    // Not final yet (scheduled / in progress / SUSPENDED) → stay pending. A
    // suspended game resumes and settles later; voiding it would destroy a
    // real bet.
    if (res.state === 'pending') {
      await sb.from('ledger_entries').update({ settle_attempts: attempts })
        .eq('id', row.id).is('outcome', null);
      pending += 1;
      continue;
    }

    // Could not determine — bounded retry, then a terminal state. Nothing is
    // immortal.
    if (res.state === 'unknown') {
      const terminal = attempts >= SETTLE_ATTEMPT_CAP;
      await sb.from('ledger_entries').update({
        settle_attempts: attempts,
        ...(terminal ? {
          outcome: 'unrecoverable',
          settled_at: nowIso,
          settlement_source: res.source || 'unknown',
          settlement_version: SETTLEMENT_VERSION,
        } : {}),
      }).eq('id', row.id).is('outcome', null);
      if (terminal) unrecoverable += 1; else pending += 1;
      continue;
    }

    // No bet existed — DNP or postponed/cancelled. VOID is a terminal truth,
    // not a failure, and it is excluded from every record denominator.
    if (res.state === 'void') {
      const { error: vErr } = await sb.from('ledger_entries').update({
        outcome: 'void',
        settled_at: nowIso,
        settle_attempts: attempts,
        settlement_source: res.reason || 'void',
        settlement_version: SETTLEMENT_VERSION,
      }).eq('id', row.id).is('outcome', null);
      if (vErr) { pending += 1; continue; }
      voided += 1;
      continue;
    }

    const actual = res.value;
    const outcome = settleResult(row.side, actual, row.line);
    if (!outcome) { pending += 1; continue; }
    const clv = computeClv(row.side, row.line, row.closing_line);
    // Session 64 — DIRECTIONAL CLV is computed HERE, in the settle pass. This
    // is the trigger: at settle the game is final, so the close has landed and
    // the read is final — the only moment BOTH ends of the comparison exist.
    // (Grade + locked prices are written hours earlier; the close at lock. A
    // CLV function without this trigger would be a correct dead wire.)
    // IMMUTABLE ONCE COMPUTED (Session 64). A settle can re-run — stat
    // correction, protested/replayed game — and a badge that flips
    // positive→negative AFTER a user saw or screenshotted it is a credibility
    // failure. So the FIRST computation wins: if dclv_computed_at is already
    // set, we do not recompute or overwrite. The lock is the same discipline
    // the grade itself uses.
    const dclvRes = row.dclv_computed_at
      ? null
      : await computeDirectionalForRow(sb, sp, row, opts);
    const { error: upErr } = await sb.from('ledger_entries')
      .update({
        outcome,
        actual_value: actual,
        settled_at: nowIso,
        clv,
        clv_result: clvResultOf(clv),
        ...(dclvRes ? {
          dclv: dclvRes.clv,
          dclv_state: dclvRes.state,
          dclv_fair_lock: dclvRes.fair_lock,
          dclv_fair_close: dclvRes.fair_close,
          dclv_computed_at: nowIso,
        } : {}),
        settle_attempts: attempts,
        settlement_source: res.source || 'date_log',
        settlement_version: SETTLEMENT_VERSION,
      })
      .eq('id', row.id)
      .is('outcome', null); // double-settle guard even across concurrent runs
    // NOTE: dclv fields are only present in the update payload when
    // dclv_computed_at was null, so a re-settle can never rewrite a shown badge.
    if (upErr) { pending += 1; continue; }
    settled += 1;
  }
  return { settled, voided, unrecoverable, pending };
}

/**
 * Phase 2.5 (Session 60) — a PUBLIC grade revision. The intraday refresh
 * re-graded a prop whose line moved ≥1.0 against the graded side and the
 * grade dropped: update the locked row's grade and set revised_from_grade
 * ONCE (the original letter is preserved forever — revisions are public,
 * never silent). Only unsettled public rows for today are touched.
 */
async function applyRevision(sport, { playerKey, stat, line, side, newGrade, fromGrade }, opts = {}) {
  if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured' };
  const sb = opts.sb || defaultClient();
  const { error } = await sb.from('ledger_entries')
    .update({ grade: newGrade, revised_from_grade: fromGrade })
    .is('user_id', null)
    .eq('sport', String(sport).toLowerCase())
    .eq('player_key', playerKey)
    .eq('stat', stat)
    .eq('line', line)
    .eq('side', side)
    .is('outcome', null);
  return error ? { error: error.message } : { revised: true };
}

async function settleAllLedgers(opts = {}) {
  const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
  const results = [];
  for (const sp of sports) {
    try { results.push({ sport: sp, ...(await settleLedger(sp, opts)) }); }
    catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
  }
  return results;
}

async function defaultGetPlayerStats(name, sport) {
  const sp = String(sport || '').toLowerCase();
  if (sp === 'mlb') {
    return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
  }
  // Wave 1 — NBA/WNBA settle against the FREE ESPN per-game log.
  if (sp === 'nba' || sp === 'wnba') {
    return require('./adapters/espnStatsAdapter').getPlayerGameLog(name, sp);
  }
  return { found: false }; // soccer — no free settled-result feed yet → pending
}

/**
 * Session 8 (A1 board, ops) — count of ledger rows for one game_date (the
 * daily pulse's "rows written yesterday"). Returns null (NOT 0) when Supabase
 * isn't configured or the count fails — the pulse renders "n/a", never a
 * fabricated zero.
 */
async function countRowsForDate(gameDate, opts = {}) {
  if (!gameDate) return null;
  if (!opts.sb && !isConfigured()) return null;
  try {
    const sb = opts.sb || defaultClient();
    const { count, error } = await sb.from('ledger_entries')
      .select('id', { count: 'exact', head: true })
      .eq('game_date', gameDate);
    if (error) return null;
    return count || 0;
  } catch {
    return null;
  }
}

/**
 * 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
 * beat-the-close rate, pending count. Percentages are null below
 * MIN_AGG_SAMPLE — the UI must show "record building" instead.
 *
 * A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)`
 * scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same
 * n≥20 gate) over one user's own ledger, powering public profiles. The
 * public default is untouched.
 */
async function getModelAggregate(opts = {}) {
  const empty = {
    window_days: AGG_WINDOW_DAYS, min_sample: MIN_AGG_SAMPLE,
    settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null,
    clv_sample: 0, clv_beat: 0, clv_faded: 0, clv_flat: 0, beat_close_pct: null,
    clv_distribution: null, // S6 — set past the n≥20 gate only
    pending: 0,
  };
  if (!opts.sb && !isConfigured()) return empty;
  const sb = opts.sb || defaultClient();
  const nowMs = (opts.nowMs || (() => Date.now()))();
  const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);

  let settledQ = sb.from('ledger_entries')
    .select('outcome, clv_result, clv, player_key, grade, model_value');
  settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
  settledQ = settledQ
    .not('outcome', 'is', null)
    // Session 64 — void (no bet existed: DNP/postponed) and unrecoverable
    // (truth not fetchable) are TERMINAL but are NOT results. They must never
    // enter a record denominator, exactly as pushes are excluded from hit_pct.
    // Without this, voiding a row would silently move the public record.
    .not('outcome', 'in', '("void","unrecoverable")')
    // Session 64 (Order 2) — QUARANTINE. A row whose GRADE is untrustworthy
    // (wrong_opponent_grade) stays a real public settled result but must never
    // train or validate a model, so it leaves the denominator exactly as
    // void/unrecoverable do. NOTE: `analysis_flags` (e.g. doubleheader) is
    // deliberately NOT filtered here — those rows settle validly and belong in
    // the record; they are excluded only from per-game/opponent analysis.
    .is('quarantine_reason', null)
    // 2026-07 — a grade with a non-positive model_value had NO real projection
    // (the pre-fix degradation). Those locks are kept in the append-only ledger
    // but must not count toward the public model record — their hit/miss is
    // noise, not model skill. `.gt` also excludes NULL model_value. Post-fix no
    // such row can be written (projection<=0 now refuses), so this only filters
    // the historical blast radius.
    .gt('model_value', 0)
    .gte('game_date', since)
    .limit(AGG_FETCH_LIMIT);
  if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
  if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
  if (opts.team) settledQ = settledQ.eq('team', opts.team); // Session 60 (5.2) — VYNDR-on-team
  const { data: settledRows, error } = await settledQ;
  if (error) return { ...empty, error: error.message };

  let pendingQ = sb.from('ledger_entries')
    .select('id', { count: 'exact', head: true });
  pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null);
  pendingQ = pendingQ.is('outcome', null).gt('model_value', 0); // same real-projection filter
  if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
  if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
  if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
  const { count: pending } = await pendingQ;

  const agg = { ...empty, pending: pending || 0 };
  // Session 60 (5.5) — calibration by grade tier (A+ alone, then first
  // letter). Same n≥20 rule PER TIER: a tier below threshold reports a
  // null pct and the UI shows "building", never a small-sample %.
  const tierOf = (g) => {
    const s = String(g || '').trim().toUpperCase();
    if (!s) return null;
    return s === 'A+' ? 'A+' : s[0];
  };
  const byTier = {};
  for (const r of settledRows || []) {
    agg.settled += 1;
    if (r.outcome === 'hit') agg.hits += 1;
    else if (r.outcome === 'miss') agg.misses += 1;
    else if (r.outcome === 'push') agg.pushes += 1;
    if (r.clv_result) {
      agg.clv_sample += 1;
      if (r.clv_result === 'beat') agg.clv_beat += 1;
      else if (r.clv_result === 'faded') agg.clv_faded += 1;
      else agg.clv_flat += 1;
    }
    const t = tierOf(r.grade);
    if (t) {
      byTier[t] = byTier[t] || { settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null };
      const b = byTier[t];
      b.settled += 1;
      if (r.outcome === 'hit') b.hits += 1;
      else if (r.outcome === 'miss') b.misses += 1;
      else if (r.outcome === 'push') b.pushes += 1;
    }
  }
  for (const t of Object.keys(byTier)) {
    const b = byTier[t];
    const d = b.hits + b.misses;
    if (b.settled >= MIN_AGG_SAMPLE && d > 0) b.hit_pct = Math.round((b.hits / d) * 100);
  }
  agg.by_tier = byTier;
  const decided = agg.hits + agg.misses;
  // n<20 → null: never render a percentage on a small sample.
  if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
    agg.hit_pct = Math.round((agg.hits / decided) * 100);
  }
  // Truth-Everywhere Part 2 (item 7) — CLV is currently MEASURED WRONG:
  // captureClosing re-records the LOCKED line as the "closing" line
  // (closing_line == locked_line across the whole sample), so every row's CLV
  // computes to 0/flat and beat_close reads a fabricated-looking 0%. That's
  // comparing a number to itself. Until C4 (real closing-line capture) lands,
  // CLV_CAPTURE_RELIABLE stays false and beat_close_pct / clv_distribution are
  // suppressed at the SOURCE — every public surface hides BEAT CLOSE rather
  // than showing a measured-wrong zero. Flip this to true when C4 ships.
  if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
    agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100);
  }
  // S6 (A1 board) — clv_distribution rides the SAME n≥20 gate (this is the
  // single home of the gate — consumers never re-derive it). Null below the
  // sample floor or with zero settled clv values; the UI renders nothing.
  agg.clv_distribution = null;
  if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
    const dist = CLV_BUCKETS.map((b) => ({ ...b, count: 0 }));
    let counted = 0;
    for (const r of settledRows || []) {
      const i = clvBucketIndex(r.clv);
      if (i >= 0) { dist[i].count += 1; counted += 1; }
    }
    if (counted > 0) agg.clv_distribution = dist;
  }
  return agg;
}

// Truth-Everywhere Part 2 (item 7) — the public 30D accuracy VIEW, built from
// the CLEAN ledger aggregate (model_value > 0), NOT the Redis outcome log
// (which still counts degraded projection-0 rows and can't be filtered). Same
// shape the AccuracyBadge / buckets consumed from outcomeService, so no
// frontend change. Redis is a cache; when a cache can't be filtered, read truth.
const ACCURACY_VIEW_SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
function _aggToRecord(agg, sport) {
  const byGrade = {};
  for (const [tier, b] of Object.entries(agg.by_tier || {})) {
    byGrade[tier] = {
      hits: b.hits, misses: b.misses, pushes: b.pushes,
      total: b.hits + b.misses + b.pushes, pct: b.hit_pct ?? null,
    };
  }
  return {
    sport,
    updated_at: null,
    window_days: agg.window_days,
    sample: agg.settled,
    min_sample: agg.min_sample,
    overall: {
      hits: agg.hits, misses: agg.misses, pushes: agg.pushes,
      total: agg.hits + agg.misses + agg.pushes, pct: agg.hit_pct ?? null,
    },
    byGrade,
  };
}
async function getAccuracyView(opts = {}) {
  const base = { sb: opts.sb, nowMs: opts.nowMs };
  const overallAgg = await getModelAggregate(base);
  const sports = {};
  for (const s of ACCURACY_VIEW_SPORTS) {
    const a = await getModelAggregate({ ...base, sport: s });
    if (a.settled > 0) sports[s] = _aggToRecord(a, s);
  }
  return {
    overall: _aggToRecord(overallAgg, 'overall'),
    sports,
    min_sample: overallAgg.min_sample,
    updated_at: null,
  };
}

// Grade-tier buckets for the ledger accuracy strip, from the clean aggregate.
function accuracyBucketsFromAgg(agg) {
  // First-letter buckets (A+ folds into A for the public strip, matching the
  // old outcomeService.accuracyBuckets contract), n≥20 gate per bucket.
  const order = ['A', 'B', 'C', 'D', 'F'];
  const rolled = {};
  for (const [tier, b] of Object.entries(agg.by_tier || {})) {
    const k = tier === 'A+' ? 'A' : tier[0];
    rolled[k] = rolled[k] || { hits: 0, misses: 0, total: 0 };
    rolled[k].hits += b.hits;
    rolled[k].misses += b.misses;
    rolled[k].total += b.hits + b.misses + b.pushes;
  }
  return order
    .filter((k) => rolled[k] && rolled[k].total > 0)
    .map((k) => {
      const r = rolled[k];
      const decided = r.hits + r.misses;
      const pct = r.total >= MIN_AGG_SAMPLE && decided > 0 ? Math.round((r.hits / decided) * 100) : null;
      return { grade: k, hits: r.hits, total: r.total, pct };
    });
}

module.exports = {
  attachClosingProb,
  recordPipelineGrades,
  captureClosing,
  settleLedger,
  settleAllLedgers,
  applyRevision,
  countRowsForDate,
  getModelAggregate,
  getAccuracyView,
  accuracyBucketsFromAgg,
  MIN_AGG_SAMPLE,
  __internals: {
    rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
    dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
    teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS,
  },
};
