'use strict';

/**
 * MODEL SNAPSHOT RETENTION (Session 64, Phase 2 — priority zero).
 *
 * As of 2026-07-20 the only store of model history was `ledger_entries`: 640
 * rows, 6 game days, and NO MODEL INPUTS. Every other warehouse table in the
 * schema was empty. That meant we could score the grades we emitted but could
 * not replay a different model against the same conditions — the only question
 * a backtest exists to answer, and the gate the metrics-engine north star
 * requires ("does this metric predict better than without it?").
 *
 * This writes `model_snapshots`: one APPEND-ONLY row per graded prop per
 * snapshot cycle, carrying the market values, the model output, the refusal
 * (if any), and the FEATURE VECTOR that produced it.
 *
 * CONTRACT: best-effort, exactly like the ledger write. A retention failure
 * must NEVER break a snapshot. Every path returns a summary; none throw.
 *
 * Two deliberate choices:
 *  - REFUSALS ARE STORED. The ledger drops them, so a gate that refuses props
 *    which would have won is invisible. That is lost edge we cannot measure.
 *  - EVERY ROW IS STAMPED with model_version + code_sha. A backtest that mixes
 *    model eras is worthless, and `ledger_entries` is already permanently
 *    contaminated across the 2026-07-19 fix boundary with no way to separate.
 */

const crypto = require('crypto');
const { normalizeName, nameKey } = require('../utils/playerName');

/** ET calendar date of an ISO timestamp (shares gameBinder's rule). */
function etDateOf(iso) {
  if (!iso) return null;
  const t = new Date(iso);
  if (Number.isNaN(t.getTime())) return null;
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(t);
}

/**
 * Bump when the grading model changes in a way that makes rows non-comparable.
 * This is the marker `ledger_entries` never had.
 */
const MODEL_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';

function codeSha() {
  return process.env.SOURCE_COMMIT || process.env.GIT_SHA || process.env.COOLIFY_GIT_COMMIT_SHA || null;
}

/** Strict numeric parse — `Number(null) === 0` is this codebase's classic
 *  fabrication bug, so absent must stay absent. */
function numOrNull(v) {
  if (v == null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

function intOrNull(v) {
  const n = numOrNull(typeof v === 'string' ? v.replace('+', '') : v);
  return n == null ? null : Math.round(n);
}

function boolOrNull(v) {
  return typeof v === 'boolean' ? v : null;
}

/**
 * Build retention rows from ONE prop's graded sides (both over and under,
 * graded or refused). `base` is the prop the grader was called with.
 */
function rowsFromSides(base, sides, ctx = {}) {
  const rows = [];
  const list = Array.isArray(sides) ? sides : [];
  for (const s of list) {
    if (!s) continue;
    const player = s.player || base.player;
    if (!player) continue;
    const stat = s.stat_type || base.stat_type;
    const line = numOrNull(s.line != null ? s.line : base.line);
    const side = String(s.direction || '').toLowerCase();
    if (!stat || line == null || (side !== 'over' && side !== 'under')) continue;

    const refused = !!(s.insufficient_data || !s.grade);

    rows.push({
      snapshot_id: ctx.snapshotId,
      captured_at: ctx.capturedAt,
      cycle_hour_utc: ctx.cycleHourUtc ?? null,
      model_version: MODEL_VERSION,
      code_sha: codeSha(),

      sport: ctx.sport,
      game_id: ctx.gameIdFor ? ctx.gameIdFor(base, s) : (base.game_id || `${ctx.sport}:${ctx.gameDate}`),
      // Session 64 (Order 1.5) — the GAME's date, from the bound game_time,
      // never the snapshot clock. ctx.gameDate is only a last resort for props
      // the binder could not tie to a real game.
      game_date: etDateOf(base && base.game_time) || ctx.gameDate,
      player_key: nameKey(player),
      player_name: normalizeName(player).display || player,
      team: s.team || base.team || null,
      opponent: s.opponent || base.opponent || null,
      stat,
      line,
      side,

      book: s.book || base.book || null,
      book_odds: intOrNull(s.book_odds),
      over_odds: intOrNull(base.over_odds),
      under_odds: intOrNull(base.under_odds),
      fair_odds: intOrNull(s.fair_odds),
      fair_prob: numOrNull(s.fair_prob),
      overround: numOrNull(s.overround),
      devig_method: s.devig_method || null,

      grade: s.grade || null,
      grade_11: s._grade_11 || null,
      confidence: numOrNull(s.confidence),
      confidence_basis: s.confidence_basis || null,
      p_win: numOrNull(s.p_win),
      ev_pct: numOrNull(s.ev_pct),
      projection: numOrNull(s.projection),
      edge_pct: numOrNull(s.edge_pct),
      takeable: boolOrNull(s.takeable),
      value: boolOrNull(s.value),
      archetype: s.archetype || null,

      refused,
      refusal_reason: refused
        ? (s.suppressed_reason || (s.insufficient_data ? 'insufficient_data' : 'no_grade'))
        : null,

      // The counterfactual enabler. Absent on pre-feature refusals (the juice
      // gate runs before features are computed) — honestly null, never faked.
      features: s._features && Object.keys(s._features).length ? s._features : null,
    });
  }
  return rows;
}

/** A collector to hand to gradeAndCacheSlate's `onGraded` hook. */
function createCollector(ctx) {
  const rows = [];
  return {
    rows,
    onGraded(base, sides) {
      try {
        rows.push(...rowsFromSides(base, sides, ctx));
      } catch { /* collection never affects grading */ }
    },
  };
}

/**
 * Persist rows. Chunked, upsert-on-conflict-ignore so a retried cycle can never
 * duplicate. No-ops (successfully) without Supabase env, so tests and local dev
 * never touch a database.
 */
async function persist(rows, deps = {}) {
  const out = { attempted: Array.isArray(rows) ? rows.length : 0, written: 0, skipped: false, error: null };
  if (!out.attempted) return out;
  try {
    const getClient = deps.getClient || require('../utils/supabase').getSupabaseServiceClient;
    const supabase = getClient();
    if (!supabase) { out.skipped = true; return out; }
    const CHUNK = 250;
    for (let i = 0; i < rows.length; i += CHUNK) {
      const chunk = rows.slice(i, i + CHUNK);
      const { error } = await supabase
        .from('model_snapshots')
        .upsert(chunk, {
          onConflict: 'snapshot_id,player_key,stat,line,side',
          ignoreDuplicates: true,
        });
      if (error) { out.error = error.message; break; }
      out.written += chunk.length;
    }
  } catch (e) {
    out.error = e && e.message ? e.message : String(e);
  }
  return out;
}

/**
 * Fill archetype / team / opponent onto collected rows from the ENRICHED grades.
 *
 * Retention collects at GRADE time, which is the only moment the feature vector
 * exists — but archetype/team/opponent are attached later, during snapshot
 * enrichment. Capturing at grade time alone left all three permanently null,
 * which specifically blocks the archetype-baselined metrics work.
 *
 * CONTRACT: this ONLY fills those three fields. It must never touch `features`
 * or any model output — grade-time values are the record, and enrichment must
 * not rewrite history. Unmatched rows (e.g. refusals, which never reach the
 * enriched slate) pass through untouched with the fields left null: honestly
 * absent, not guessed.
 */
function mergeEnrichment(rows, enrichedGrades) {
  if (!Array.isArray(rows) || !rows.length) return rows || [];
  const byPlayer = new Map();
  for (const g of enrichedGrades || []) {
    const raw = g && (g.player || g.player_name);
    if (!raw) continue;
    const k = nameKey(raw);
    // First enriched grade per player wins; archetype/team are player-level.
    if (!byPlayer.has(k)) {
      byPlayer.set(k, {
        archetype: g.archetype ?? null,
        team: g.team ?? null,
        opponent: g.opponent ?? null,
      });
    }
  }
  return rows.map((r) => {
    const e = byPlayer.get(r.player_key);
    if (!e) return r;
    return {
      ...r,
      archetype: r.archetype ?? e.archetype ?? null,
      team: r.team ?? e.team ?? null,
      opponent: r.opponent ?? e.opponent ?? null,
    };
  });
}

function newSnapshotId() {
  return crypto.randomUUID();
}

module.exports = {
  MODEL_VERSION,
  codeSha,
  rowsFromSides,
  createCollector,
  mergeEnrichment,
  persist,
  newSnapshotId,
  __internals: { numOrNull, intOrNull, boolOrNull },
};
