'use strict';

/**
 * opsWatch — pure ops-alarm logic for the scheduler (Session 8, A1 board).
 *
 * The record must never die silently. This module holds the TESTABLE pieces
 * the scheduler wires to ntfy (via opsNotify):
 *
 *   1. createFailureTracker — per-sport CONSECUTIVE snapshot-failure counter.
 *      Single-slot errors are normal before books post lines; 3 in a row is a
 *      broken pipeline. Pages exactly once per losing streak (at the
 *      threshold crossing), re-arms on any good outcome.
 *   2. zeroSettleAlarm — "settle pass finished but the record did not
 *      advance." SIGNAL CHOICE (documented): the ledger settle pass's own
 *      return values. settleLedger fetches `game_date < today AND outcome IS
 *      NULL` from POSTGRES, so the signal survives the Redis TTL expiry that
 *      silently killed morning settles in Session 60 — a
 *      snapshot:{sport}:previous read would vanish in exactly that failure
 *      mode, and it costs zero extra reads. Scoped to SETTLEABLE sports (mlb)
 *      because NBA/WNBA/soccer rows legitimately stay pending until they have
 *      a settled-result feed. A genuinely empty yesterday fetches 0 rows ->
 *      settled 0 / pending 0 -> no alarm, ever.
 *   3. checkQuotaDaily — odds-api >= 80% -> one alert per day (Redis dedupe
 *      by date key). Complements quotaTracker's once-per-MONTH warn.
 *   4. buildPulseMessage / countRecentSettles — the 9 AM ET daily pulse, one
 *      notification, VOICE v1.1: deadpan, numbers, no exclamation points.
 *
 * Everything here is pure or fully injectable — no requires of redis/ntfy.
 */

/** Sports with a real settled-result feed. MLB = statsapi game logs; NBA/WNBA =
 *  ESPN per-game logs (Wave 1). Keep in sync with outcomeService/ledgerService's
 *  defaultGetPlayerStats. Soccer stays out until it has a settled-result feed. */
const SETTLEABLE_SPORTS = ['mlb', 'nba', 'wnba'];

/** Sports whose zero-settle alarm requires a real-finals probe before paging:
 *  their slates go dark for months (offseason) and a stale pending row must not
 *  false-page on a genuine off-day. MLB is exempt (proven daily feed in-season;
 *  a dark day fetches 0 rows and never alarms). */
const FINALS_GATED_SPORTS = ['nba', 'wnba'];

const PAGE_THRESHOLD = 3;

/** A snapshot result that means "the pipeline produced nothing": a hard error
 *  or an empty odds feed. 'no grades' (props arrived, grader refused) and 'ok'
 *  are NOT failures for this counter. */
function isBadSnapshotResult(result) {
  if (!result) return false;
  if (result.status === 'error') return true;
  return result.status === 'skipped' && String(result.reason || '') === 'no props';
}

/**
 * Per-sport consecutive-failure counter (in-process; a restart re-arms, which
 * is acceptable — the missed-cron watchdog covers a crashed scheduler).
 * record() returns { sport, count, shouldPage }: shouldPage is true ONLY at
 * the exact threshold crossing, so a sport that keeps failing pages once per
 * losing streak, not once per slot.
 */
function createFailureTracker(threshold = PAGE_THRESHOLD) {
  const counts = new Map();
  return {
    record(sport, result) {
      const sp = String(sport || '').toLowerCase();
      const bad = isBadSnapshotResult(result);
      const count = bad ? (counts.get(sp) || 0) + 1 : 0;
      counts.set(sp, count);
      return { sport: sp, count, shouldPage: bad && count === threshold };
    },
    count(sport) { return counts.get(String(sport || '').toLowerCase()) || 0; },
    threshold,
  };
}

/** The daily "close the book" slot: the first configured UTC hour >= 06
 *  (hours 0-5 UTC are late-night ET slots of the PREVIOUS ET day). Falls back
 *  to the smallest configured hour when nothing is >= 06. */
function morningHourUtc(hoursUtc) {
  const hours = (hoursUtc || []).filter((h) => Number.isInteger(h));
  if (hours.length === 0) return 14;
  const daytime = hours.filter((h) => h >= 6);
  return (daytime.length > 0 ? daytime : hours).sort((a, b) => a - b)[0];
}

/**
 * Evaluate the zero-settle signal from settleAllLedgers results.
 * alarm === true only when settleable-sport rows EXISTED for settlement
 * (fetched from Postgres: settled + pending > 0) and none settled.
 *
 * Wave 1 — a finals-gated sport (NBA/WNBA) only contributes to the alarm when
 * yesterday actually had games: `opts.finalsBySport[sport]` truthy (the caller
 * counts ESPN `state==='post'` events). Without that signal its stale pendings
 * are excluded, so an offseason/off-day never false-pages "settled 0". MLB is
 * always counted (a dark MLB day fetches 0 rows → settled 0 / pending 0 → no
 * alarm on its own).
 * opts: { settleable, finalsBySport }. A bare array 2nd arg (legacy call:
 * `zeroSettleAlarm(results, settleableArray)`) is still honored.
 */
function zeroSettleAlarm(ledgerResults, opts = {}) {
  const o = Array.isArray(opts) ? { settleable: opts } : (opts || {});
  const settleable = o.settleable || SETTLEABLE_SPORTS;
  const finalsBySport = o.finalsBySport || {};
  const rows = (Array.isArray(ledgerResults) ? ledgerResults : [])
    .filter((r) => r && settleable.includes(String(r.sport || '').toLowerCase()))
    .filter((r) => {
      const sp = String(r.sport || '').toLowerCase();
      // Finals-gated sports need a "yesterday had games" signal to count.
      if (FINALS_GATED_SPORTS.includes(sp)) return Boolean(finalsBySport[sp]);
      return true;
    });
  const settled = rows.reduce((n, r) => n + (r.settled || 0), 0);
  const pending = rows.reduce((n, r) => n + (r.pending || 0), 0);
  return { alarm: settled === 0 && pending > 0, settled, pending };
}

/** ET calendar date (YYYY-MM-DD) of a Date; UTC fallback if Intl is absent. */
function dateET(d = new Date()) {
  try {
    return new Intl.DateTimeFormat('en-CA', {
      timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
    }).format(d);
  } catch {
    return d.toISOString().slice(0, 10);
  }
}

/**
 * Quota alert: odds-api usage >= thresholdPct -> alert once per UTC day
 * (Redis dedupe key ops:quota_day:{provider}:{date}). All deps injectable:
 * { getStatus, cacheGet, cacheSet, notify, now, providerId, thresholdPct }.
 * Never throws — an ops check must not break the snapshot run.
 */
async function checkQuotaDaily(deps = {}) {
  const providerId = deps.providerId || 'odds-api';
  const thresholdPct = deps.thresholdPct != null ? deps.thresholdPct : 0.8;
  try {
    const status = await deps.getStatus(providerId);
    const pct = status && Number.isFinite(status.pct) ? status.pct : null;
    if (pct == null || pct < thresholdPct) return { alerted: false, pct };
    const day = (deps.now ? deps.now() : new Date()).toISOString().slice(0, 10);
    const key = `ops:quota_day:${providerId}:${day}`;
    if (await deps.cacheGet(key)) return { alerted: false, deduped: true, pct };
    await deps.cacheSet(key, '1', 2 * 24 * 3600);
    await deps.notify(
      `${providerId} at ${Math.round(pct * 100)}% of ${status.quotaType || 'period'} quota `
      + `(${status.used}/${status.limit}). Blocks at 95. PropLine stays primary; `
      + `the backup path is thinning.`,
      { title: 'VYNDR quota', priority: 'high', tags: ['warning', 'chart_decreasing'] },
    );
    return { alerted: true, pct };
  } catch (err) {
    return { alerted: false, error: err.message };
  }
}

/** Count settled outcomes across logs whose settledAt falls inside the last
 *  windowHours (default 24). Pure — feed it the outcomes:{sport}:log arrays. */
function countRecentSettles(logs, nowMs, windowHours = 24) {
  const cutoff = nowMs - windowHours * 3600 * 1000;
  let n = 0;
  for (const log of logs || []) {
    for (const o of Array.isArray(log) ? log : []) {
      const t = o && o.settledAt ? new Date(o.settledAt).getTime() : NaN;
      if (Number.isFinite(t) && t >= cutoff) n += 1;
    }
  }
  return n;
}

/**
 * Assemble the ONE daily-pulse notification body. VOICE v1.1: deadpan,
 * numbers, no exclamation points. Missing data renders as "n/a" — a pulse
 * never fabricates a zero.
 * pieces: { dateEt, ledgerRows (number|null), settles24h (number|null),
 *           quota ({pct, used, limit}|null), health ({disk_pct, mem_pct}|null) }
 */
function buildPulseMessage(pieces = {}) {
  const na = (v) => (v == null ? 'n/a' : String(v));
  const quota = pieces.quota && Number.isFinite(pieces.quota.pct)
    ? `${Math.round(pieces.quota.pct * 100)}% (${pieces.quota.used}/${pieces.quota.limit})`
    : 'n/a';
  const h = pieces.health || {};
  const disk = Number.isFinite(h.disk_pct) ? `${h.disk_pct}%` : 'n/a';
  const mem = Number.isFinite(h.mem_pct) ? `${h.mem_pct}%` : 'n/a';
  return [
    `VYNDR pulse — ${pieces.dateEt || dateET()}`,
    `ledger rows yesterday: ${na(pieces.ledgerRows)}`,
    `settles last 24h: ${na(pieces.settles24h)}`,
    `odds-api quota: ${quota}`,
    `disk ${disk} · mem ${mem}`,
    'desk pack: see /desk',
  ].join('\n');
}

/**
 * RETENTION ZERO-WRITE ALARM (Session 64, Phase 3).
 *
 * `model_snapshots` is the moat's raw material and features are IRREPLACEABLE —
 * a night not captured cannot be reconstructed later (unlike outcomes, which
 * re-derive from box scores). Retention is deliberately best-effort so it can
 * never break a snapshot, which means a broken write is SILENT by construction.
 * This is the counterweight: if a slot graded props but retention wrote fewer
 * rows than the slate, that is a paged event at missed-snapshot severity.
 *
 * Pure. Caller supplies what actually happened; this only decides.
 *
 * @param {Array} results  per-sport snapshot results: { sport, status, gradeCount }
 * @param {Object} written map of sport -> rows retention reported writing
 * @returns {{alarm:boolean, reason:string|null, detail:Array}}
 */
function retentionZeroWriteAlarm(results = [], written = {}) {
  const detail = [];
  let alarm = false;
  let reason = null;
  for (const r of results || []) {
    if (!r || r.status !== 'ok') continue;           // only slots that DID grade
    const graded = Number(r.gradeCount) || 0;
    if (graded <= 0) continue;
    const rows = Number(written[r.sport]);
    // Retention writes BOTH sides plus refusals, so rows should be >= graded.
    // Fewer rows than graded props means capture is dropping history.
    if (!Number.isFinite(rows) || rows <= 0) {
      alarm = true;
      detail.push({ sport: r.sport, graded, rows: Number.isFinite(rows) ? rows : null, kind: 'zero' });
    } else if (rows < graded) {
      alarm = true;
      detail.push({ sport: r.sport, graded, rows, kind: 'short' });
    } else {
      detail.push({ sport: r.sport, graded, rows, kind: 'ok' });
    }
  }
  if (alarm) {
    const bad = detail.filter((d) => d.kind !== 'ok');
    reason = bad.map((d) => `${String(d.sport).toUpperCase()} graded ${d.graded} but retention wrote ${d.rows === null ? 'NOTHING' : d.rows}`).join('; ');
  }
  return { alarm, reason, detail };
}

/**
 * SETTLEMENT-RATE ALARM (Session 64, Order 1 Phase 3).
 *
 * `zeroSettleAlarm` only fires on a TOTAL zero. The diagnostic found 29 of 86
 * MLB rows (Jul 17) and 28 of 103 (Jul 18) quietly failing to settle while the
 * pass "succeeded" — the third silent-failure surface of the night. A slate
 * that mostly settles but persistently drops a third of its rows is broken.
 *
 * Resolved = settled + voided + unrecoverable (every TERMINAL state). A void is
 * a legitimate resolution — the row is no longer immortal — so it counts toward
 * the rate even though it is excluded from the record.
 *
 * Pure. Caller supplies settle results; this only decides.
 */
const SETTLE_RATE_FLOOR = Number(process.env.SETTLE_RATE_FLOOR || 0.8);

function settlementRateAlarm(results = [], opts = {}) {
  const floor = Number.isFinite(opts.floor) ? opts.floor : SETTLE_RATE_FLOOR;
  const detail = [];
  let alarm = false;
  for (const r of results || []) {
    if (!r || r.error || r.skipped) continue;
    const settled = Number(r.settled) || 0;
    const voided = Number(r.voided) || 0;
    const unrec = Number(r.unrecoverable) || 0;
    const pending = Number(r.pending) || 0;
    const resolved = settled + voided + unrec;
    const attempted = resolved + pending;
    if (attempted === 0) continue;              // nothing due — never page
    const rate = resolved / attempted;
    const bad = rate < floor;
    if (bad) alarm = true;
    detail.push({ sport: r.sport, attempted, resolved, pending, rate: Math.round(rate * 100) / 100, bad });
  }
  const reason = alarm
    ? detail.filter((d) => d.bad)
      .map((d) => `${String(d.sport || '?').toUpperCase()} resolved ${d.resolved}/${d.attempted} (${Math.round(d.rate * 100)}%), ${d.pending} still pending`)
      .join('; ')
    : null;
  return { alarm, reason, detail, floor };
}

/**
 * HARNESS STALENESS ALARM (Session 64). The harness is the gate every future
 * metric family must pass; if it silently stops running, we lose the ability to
 * refuse — and a validator that isn't running looks exactly like one that keeps
 * passing. Same discipline as the retention and settlement alarms.
 */
function harnessStaleAlarm(lastRanAtIso, now = new Date(), maxAgeHours = 36) {
  if (!lastRanAtIso) {
    return { alarm: true, reason: 'the backtest harness has NEVER recorded a run' };
  }
  const t = new Date(lastRanAtIso).getTime();
  if (Number.isNaN(t)) return { alarm: true, reason: 'unparseable last-run timestamp' };
  const ageH = (now.getTime() - t) / 3_600_000;
  if (ageH > maxAgeHours) {
    return { alarm: true, reason: `the backtest harness has not run for ${Math.round(ageH)}h (limit ${maxAgeHours}h)` };
  }
  return { alarm: false, reason: null, age_hours: Math.round(ageH) };
}

module.exports = {
  retentionZeroWriteAlarm,
  harnessStaleAlarm,
  settlementRateAlarm,
  SETTLE_RATE_FLOOR,
  createFailureTracker,
  isBadSnapshotResult,
  zeroSettleAlarm,
  morningHourUtc,
  checkQuotaDaily,
  countRecentSettles,
  buildPulseMessage,
  dateET,
  SETTLEABLE_SPORTS,
  FINALS_GATED_SPORTS,
  PAGE_THRESHOLD,
};
