'use strict';

/**
 * CLOSING-LINE CAPTURE (Session 64) — CAPTURE ONLY. No CLV metric here.
 *
 * WHY THIS EXISTS: CLV is currently un-backtestable. `ledger_entries
 * .closing_line` equals the locked line on 92% of rows — not because lines
 * never move (56 rows DID move) but because `captureClosing` overwrites a
 * single field with whatever the current props say and, when a prop fails to
 * match, silently leaves the earlier value in place. There is no timestamp and
 * no provenance, so "captured a genuine close" is indistinguishable from
 * "never successfully updated". That ambiguity is the actual C4 defect.
 *
 * This module writes an IMMUTABLE, append-only capture instead: one row per
 * (prop identity × side × book × capture moment), with BOTH raw side prices so
 * the existing de-vig engine can compute a fair closing probability later.
 *
 * THE JOIN: rows carry (sport, player_key, stat, side, game_date) — the natural
 * key MINUS `line`, deliberately. A closing line that equals the graded line is
 * the boring case; the whole point of CLV is the case where it MOVED, so `line`
 * cannot be part of the key. Verified safe: across current retention, all 164
 * identity groups have exactly ONE line per (sport, player_key, stat, side,
 * game_date) — zero ambiguity.
 *
 * THE REFUSAL: a prop we cannot legitimately price at the close records
 * `missed_reason` and NO price. Substituting a stale or mid-day line would
 * manufacture a CLV proof from a number that was never the close.
 */

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

// How close to first pitch/tip counts as "the close". Our odds refresh runs
// every ~20 min during slate hours, so this window is sized to catch the last
// observable line without reaching back into mid-day pricing.
const WINDOW_MINUTES_BEFORE = Number(process.env.CLOSING_WINDOW_MIN || 45);
// Sharp/no-vig reference book — "beat the market", distinct from "beat our book".
const SHARP_BOOKS = new Set(['pinnacle']);
const CAPTURE_RATE_FLOOR = Number(process.env.CLOSING_CAPTURE_FLOOR || 0.6);

function etDate(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);
}

function numOrNull(v) {
  if (v == null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

/** A refusal row: identity + why, never a price. */
function missedRow(sport, p, side, reason, nowIso) {
  return {
    sport,
    player_key: nameKey(p.player),
    player_name: p.player,
    stat: p.stat_type,
    side,
    game_date: etDate(p.game_time) || null,
    game_time: p.game_time || null,
    book: p.book || null,
    line_type: SHARP_BOOKS.has(String(p.book || '').toLowerCase()) ? 'sharp' : 'book',
    line: null,
    over_odds: null,
    under_odds: null,
    captured_at: nowIso,
    missed_reason: reason,
  };
}

/**
 * Build capture rows for a slate at a moment in time. PURE — no I/O, so the
 * refusal rules are unit-testable and cannot silently depend on a live feed.
 *
 * Returns [] for props that are not yet near lock (nothing to record — it is
 * simply not the close yet). Returns refusal rows for props inside the window
 * that cannot be priced honestly.
 */
function buildCaptureRows(sport, props, opts = {}) {
  const now = opts.now || new Date();
  const nowIso = now.toISOString();
  const windowMs = (opts.windowMinutes ?? WINDOW_MINUTES_BEFORE) * 60_000;
  const out = [];

  for (const p of props || []) {
    if (!p || !p.player || !p.stat_type) continue;
    const sides = ['over', 'under'];

    // No bound game time → we cannot know when the close IS. Record it.
    if (!p.game_time || !etDate(p.game_time)) {
      for (const s of sides) out.push(missedRow(sport, p, s, 'unbound_game_time', nowIso));
      continue;
    }
    // Doubleheader: two locks, no game number on the prop → unattributable.
    if (p.game_ambiguous) {
      for (const s of sides) out.push(missedRow(sport, p, s, 'doubleheader_ambiguous', nowIso));
      continue;
    }

    const lockMs = new Date(p.game_time).getTime();
    const delta = lockMs - now.getTime();
    if (delta > windowMs) continue;                    // not the close yet — say nothing
    if (delta < 0) {                                   // already started
      for (const s of sides) out.push(missedRow(sport, p, s, 'missed_window', nowIso));
      continue;
    }

    const over = numOrNull(p.over_odds);
    const under = numOrNull(p.under_odds);
    const line = numOrNull(p.line);
    // A one-sided price cannot produce a fair (de-vigged) closing probability,
    // so it is not a usable close. Recorded as such rather than half-stored.
    if (over == null || under == null || line == null) {
      for (const s of sides) out.push(missedRow(sport, p, s, 'one_sided_price', nowIso));
      continue;
    }

    for (const s of sides) {
      out.push({
        ...missedRow(sport, p, s, null, nowIso),
        line,
        over_odds: over,
        under_odds: under,
      });
    }
  }
  return out;
}

/**
 * RETRY — bounded, lock-walled (Session 64 Phase 2).
 *
 * The closing capture gets a retry the snapshot path deliberately does NOT:
 * a snapshot can be re-run at the next slot, but a MISSED CLOSE IS PERMANENT.
 * The odds feed flaked once on a dry induce, so the fetch is hardened here and
 * ONLY here.
 *
 * Three hard rules, each test-driven:
 *  - BOUNDED attempts with a short backoff — the window is minutes wide, so all
 *    attempts must fit inside it. Never infinite.
 *  - HARD LOCK-WALL: inside `lockWallMinutes` of first pitch (or past it) we
 *    stop and record missed_close. A price captured AT or AFTER lock is NOT a
 *    close, and storing one as if it were would fabricate the CLV baseline.
 *  - NO BOUND LOCK TIME → not close-capturable at all. Record missed_close
 *    immediately; never burn retries on a prop whose close we cannot time.
 */
function missedFrom(sport, props, reason, nowIso) {
  const out = [];
  for (const p of props || []) {
    if (!p || !p.player || !p.stat_type) continue;
    for (const side of ['over', 'under']) out.push(missedRow(sport, p, side, reason, nowIso));
  }
  return out;
}

async function captureWithRetry(sport, opts = {}) {
  const nowFn = opts.now || (() => new Date());
  const attemptsMax = Number.isFinite(opts.attempts) ? opts.attempts : 3;
  const backoffMs = Number.isFinite(opts.backoffMs) ? opts.backoffMs : 5_000;
  const lockWallMinutes = Number.isFinite(opts.lockWallMinutes) ? opts.lockWallMinutes : 2;
  const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
  const fallback = opts.fallbackProps || [];

  // No bound lock time → the close is untimeable. Refuse immediately.
  if (!opts.nextLockAt) {
    return {
      rows: missedFrom(sport, fallback, 'unbound_game_time', nowFn().toISOString()),
      attempts: 0, gave_up: true, reason: 'no_bound_lock',
    };
  }

  let lastErr = null;
  for (let i = 1; i <= attemptsMax; i += 1) {
    const minsToLock = (new Date(opts.nextLockAt).getTime() - nowFn().getTime()) / 60_000;
    if (!(minsToLock > lockWallMinutes)) {
      return {
        rows: missedFrom(sport, fallback, 'missed_window', nowFn().toISOString()),
        attempts: i - 1, gave_up: true, reason: 'lock_wall',
      };
    }
    try {
      const props = await opts.fetchProps();
      if (Array.isArray(props) && props.length) {
        return {
          rows: buildCaptureRows(sport, props, { now: nowFn(), windowMinutes: opts.windowMinutes }),
          attempts: i, gave_up: false, reason: null,
        };
      }
      lastErr = 'empty_response';
    } catch (e) {
      lastErr = e && e.message ? e.message : String(e);
    }
    if (i < attemptsMax) await sleep(backoffMs);
  }
  // Exhausted inside the window: record the refusal, never a substituted line.
  return {
    rows: missedFrom(sport, fallback, 'fetch_failed', nowFn().toISOString()),
    attempts: attemptsMax, gave_up: true, reason: lastErr,
  };
}

/** Silent-failure discipline: a capture pass that mostly misses is a broken
 *  pipe, and a missing close cannot be recovered later. */
function captureRateAlarm({ eligible = 0, captured = 0 } = {}, opts = {}) {
  const floor = Number.isFinite(opts.floor) ? opts.floor : CAPTURE_RATE_FLOOR;
  if (!eligible) return { alarm: false, reason: null, rate: null };
  const rate = captured / eligible;
  return {
    alarm: rate < floor,
    rate,
    reason: rate < floor
      ? `closing capture rate ${Math.round(rate * 100)}% of ${eligible} eligible props (floor ${Math.round(floor * 100)}%) — tonight's closes are unrecoverable`
      : null,
  };
}

/** Persist append-only. Never updates an existing row: a capture is a
 *  historical fact, and rewriting it would destroy the provenance that C4
 *  lacked in the first place. */
async function persist(rows, deps = {}) {
  const out = { attempted: 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 sb = getClient();
    if (!sb) { 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 sb.from('closing_captures').insert(chunk);
      if (error) { out.error = error.message; break; }
      out.written += chunk.length;
    }
  } catch (e) {
    out.error = e && e.message ? e.message : String(e);
  }
  return out;
}

module.exports = {
  buildCaptureRows, captureWithRetry, captureRateAlarm, persist,
  WINDOW_MINUTES_BEFORE, SHARP_BOOKS,
};
