/**
 * Odds Refresh Worker (Layer A)
 *
 * Long-lived PM2 process that refreshes odds every 90 seconds.
 * Lightweight — no Grok calls, just SGO API → DB updates.
 *
 * 1. Query rm_events for today's non-ended events
 * 2. Fetch SGO API events for needed leagues
 * 3. Match SGO events to rm_events using makeEventId()
 * 4. Update current odds + capture opening lines (once per event)
 * 5. Update rm_forecast_cache odds_data where forecasts exist
 *
 * Usage: npx tsx src/workers/odds-refresh.ts
 */

import 'dotenv/config';
import { Pool } from 'pg';
import { buildDatabasePoolConfig } from '../db/config';
import { deriveEventLifecycleStatus, fetchEvents, hasAnyOpeningOdds, parseOdds, parseOpeningOdds, sanitizeGameOddsForLeague, makeEventId, LEAGUE_MAP, SgoEvent } from '../services/sgo';
import { fetchCurrentOdds, getTheOddsSportKey, hasTheOddsApiConfigured, matchCurrentGameOdds, mergeMissingGameOdds, type TheOddsCurrentEvent } from '../services/the-odds';
import { fetchTeamPropMarketCandidates, type TeamPropMarketCandidate } from '../services/team-prop-market-candidates';
import { mlbPhaseSignal } from '../services/rie/signals/mlb-phase-signal';
import { detectMaterialChange } from '../services/material-change';
import { refreshForEvent } from '../services/forecast-runner';
import { buildPlayerPropIdentityKey } from '../services/player-prop-market-registry';
import { normalizeEventStartTime, reconcileStartTimeFromCurrentOdds } from '../services/start-time-reconciliation';
import { normalizeTeamNameKey, teamNameKeySql } from '../lib/team-abbreviations';
import { generateTeamPropsForTeam } from './weather-report';

const CYCLE_INTERVAL_MS = 90_000; // 90 seconds
const BATCH_SIZE = 3;
const BATCH_DELAY_MS = 300;
const RECENT_EVENT_LOOKBACK_HOURS = 36;
const LIFECYCLE_END_GRACE_HOURS = 8;
const EMPTY_TEAM_PROPS_RETRY_MINUTES = Number(process.env.EMPTY_TEAM_PROPS_RETRY_MINUTES || 30);
const SOURCE_BACKED_TEAM_PROP_RETRY_LEAGUES = new Set([
  'nba',
  'ncaab',
  'nhl',
  'epl',
  'la_liga',
  'bundesliga',
  'serie_a',
  'ligue_1',
  'champions_league',
]);

// Separate pool with limited connections to not compete with API
const pool = new Pool(buildDatabasePoolConfig({ max: 3 }));

pool.on('error', (err) => {
  console.error('[odds-refresh] DB pool error:', err);
});

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function getTodayDateET(): string {
  const now = new Date();
  const etStr = now.toLocaleString('en-US', { timeZone: 'America/New_York' });
  const et = new Date(etStr);
  const y = et.getFullYear();
  const m = String(et.getMonth() + 1).padStart(2, '0');
  const d = String(et.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

function normalizeName(value: string | null | undefined): string {
  return (value || '').trim().toLowerCase();
}

function normalizeStatus(value: string | null | undefined): string {
  return (value || '').trim().toLowerCase();
}

function sortKeys(value: any): any {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value && typeof value === 'object') {
    return Object.keys(value)
      .sort()
      .reduce<Record<string, any>>((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

export function oddsPayloadChanged(previousOdds: any, nextOdds: any): boolean {
  return JSON.stringify(sortKeys(previousOdds || null)) !== JSON.stringify(sortKeys(nextOdds || null));
}

export function shouldRefreshPregameOdds(status: 'scheduled' | 'started' | 'ended'): boolean {
  return status !== 'ended';
}

export function shouldRetryLateOpeningTeamProps(params: {
  league: string;
  propCount: number;
  startsAt?: string | Date | null;
  generatedAt?: string | Date | null;
  now?: Date;
}): boolean {
  if (!SOURCE_BACKED_TEAM_PROP_RETRY_LEAGUES.has(String(params.league || '').toLowerCase())) {
    return false;
  }
  if (Number(params.propCount || 0) > 0) {
    return false;
  }

  const now = params.now || new Date();
  const startsAt = params.startsAt ? new Date(params.startsAt) : null;
  const generatedAt = params.generatedAt ? new Date(params.generatedAt) : null;

  if (!startsAt || !Number.isFinite(startsAt.getTime()) || startsAt.getTime() <= now.getTime()) {
    return false;
  }
  if (!generatedAt || !Number.isFinite(generatedAt.getTime())) {
    return true;
  }

  return generatedAt.getTime() <= now.getTime() - (EMPTY_TEAM_PROPS_RETRY_MINUTES * 60 * 1000);
}

function normalizeSourceBackedPropText(value: any): string {
  return String(value || '').trim().toLowerCase();
}

function normalizeSourceBackedPropLine(value: any): number | null {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return null;
  return Math.round(numeric * 10) / 10;
}

function normalizeSourceBackedPropOdds(value: any): number | null {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return null;
  return Math.round(numeric);
}

function normalizeSourceBackedPropSide(value: any): 'over' | 'under' | null {
  const normalized = normalizeSourceBackedPropText(value);
  if (!normalized) return null;
  if (normalized.startsWith('u')) return 'under';
  if (normalized.startsWith('o')) return 'over';
  return null;
}

function buildStoredSourceBackedPropSignature(prop: any, league?: string | null): string | null {
  const player = normalizeSourceBackedPropText(prop?.player);
  const statType = buildPlayerPropIdentityKey(league, prop?.normalized_stat_type ?? prop?.stat_type);
  const line = normalizeSourceBackedPropLine(prop?.market_line_value ?? prop?.line);
  const side = normalizeSourceBackedPropSide(prop?.recommendation ?? prop?.prop);
  const odds = normalizeSourceBackedPropOdds(prop?.odds);
  if (!player || !statType || line == null || !side) return null;
  return `${player}|${statType}|${line}|${side}|${odds ?? 'null'}`;
}

function buildCandidateSourceBackedPropSignatures(candidate: TeamPropMarketCandidate, league?: string | null): string[] {
  const base = [
    normalizeSourceBackedPropText(candidate.player),
    buildPlayerPropIdentityKey(league, candidate.normalizedStatType),
    String(normalizeSourceBackedPropLine(candidate.marketLineValue)),
  ];

  const signatures: string[] = [];
  if (candidate.availableSides.includes('over')) {
    signatures.push(`${base.join('|')}|over|${normalizeSourceBackedPropOdds(candidate.overOdds) ?? 'null'}`);
  }
  if (candidate.availableSides.includes('under')) {
    signatures.push(`${base.join('|')}|under|${normalizeSourceBackedPropOdds(candidate.underOdds) ?? 'null'}`);
  }
  return signatures;
}

export function sourceBackedTeamPropsNeedRefresh(params: {
  league?: string | null;
  props: any[];
  candidates: TeamPropMarketCandidate[];
}): boolean {
  const storedProps = Array.isArray(params.props) ? params.props : [];
  if (storedProps.length === 0) {
    return params.candidates.length > 0;
  }
  if (params.candidates.length === 0) {
    return true;
  }

  const storedSignatures = new Set<string>();
  const candidateSignatures = new Set<string>();
  for (const candidate of params.candidates) {
    for (const signature of buildCandidateSourceBackedPropSignatures(candidate, params.league)) {
      candidateSignatures.add(signature);
    }
  }

  for (const prop of storedProps) {
    const signature = buildStoredSourceBackedPropSignature(prop, params.league);
    if (!signature || !candidateSignatures.has(signature)) {
      return true;
    }
    storedSignatures.add(signature);
  }

  if (storedSignatures.size !== candidateSignatures.size) {
    return true;
  }

  for (const signature of candidateSignatures) {
    if (!storedSignatures.has(signature)) {
      return true;
    }
  }

  return false;
}

function hasOpeningMoneyline(odds: { home: number | null; away: number | null } | null | undefined): boolean {
  return !!odds && (odds.home !== null || odds.away !== null);
}

function hasOpeningSpread(odds: {
  home: { line: number; odds: number | null } | null;
  away: { line: number; odds: number | null } | null;
} | null | undefined): boolean {
  return !!odds && (odds.home !== null || odds.away !== null);
}

function hasOpeningTotal(odds: {
  over: { line: number; odds: number | null } | null;
  under: { line: number; odds: number | null } | null;
} | null | undefined): boolean {
  return !!odds && (odds.over !== null || odds.under !== null);
}

export function openingPayloadNeedsBackfill(row: {
  opening_moneyline?: { home: number | null; away: number | null } | null;
  opening_spread?: {
    home: { line: number; odds: number | null } | null;
    away: { line: number; odds: number | null } | null;
  } | null;
  opening_total?: {
    over: { line: number; odds: number | null } | null;
    under: { line: number; odds: number | null } | null;
  } | null;
}): boolean {
  return !hasOpeningMoneyline(row.opening_moneyline)
    || !hasOpeningSpread(row.opening_spread)
    || !hasOpeningTotal(row.opening_total);
}

export function mergeOpeningOdds(existing: {
  moneyline?: { home: number | null; away: number | null } | null;
  spread?: {
    home: { line: number; odds: number | null } | null;
    away: { line: number; odds: number | null } | null;
  } | null;
  total?: {
    over: { line: number; odds: number | null } | null;
    under: { line: number; odds: number | null } | null;
  } | null;
}, incoming: {
  moneyline: { home: number | null; away: number | null };
  spread: {
    home: { line: number; odds: number | null } | null;
    away: { line: number; odds: number | null } | null;
  };
  total: {
    over: { line: number; odds: number | null } | null;
    under: { line: number; odds: number | null } | null;
  };
}) {
  return {
    moneyline: hasOpeningMoneyline(incoming.moneyline)
      ? incoming.moneyline
      : (existing.moneyline || { home: null, away: null }),
    spread: hasOpeningSpread(incoming.spread)
      ? incoming.spread
      : (existing.spread || { home: null, away: null }),
    total: hasOpeningTotal(incoming.total)
      ? incoming.total
      : (existing.total || { over: null, under: null }),
  };
}

export function deriveStoredLifecycleStatus(params: {
  currentStatus?: string | null;
  startsAt?: string | Date | null;
  now?: Date;
}): 'scheduled' | 'started' | 'ended' | null {
  const normalizedStatus = normalizeStatus(params.currentStatus);
  if (!['scheduled', 'started', 'ended'].includes(normalizedStatus)) return null;

  const startsAt = params.startsAt ? new Date(params.startsAt) : null;
  if (!startsAt || !Number.isFinite(startsAt.getTime())) return null;

  const now = params.now || new Date();
  const startedAtOrBefore = startsAt.getTime() <= now.getTime();
  const safelyEnded = startsAt.getTime() <= now.getTime() - (LIFECYCLE_END_GRACE_HOURS * 60 * 60 * 1000);

  if (normalizedStatus === 'ended') return 'ended';
  if (safelyEnded) return 'ended';
  if (startedAtOrBefore) return 'started';
  return 'scheduled';
}

function extractStoredMlbSnapshot(vendorInputsSummary: any): any | null {
  if (!vendorInputsSummary || typeof vendorInputsSummary !== 'object') return null;
  return vendorInputsSummary.mlb_snapshot || null;
}

export function hasMlbSnapshotContextChange(
  storedSnapshot: any,
  liveSnapshot: any,
  teamSide: 'home' | 'away',
): boolean {
  const storedHomeStarter = normalizeName(storedSnapshot?.probable_starters?.home?.name);
  const storedAwayStarter = normalizeName(storedSnapshot?.probable_starters?.away?.name);
  const liveHomeStarter = normalizeName(liveSnapshot?.probable_starters?.home?.name);
  const liveAwayStarter = normalizeName(liveSnapshot?.probable_starters?.away?.name);

  const startersChanged =
    storedHomeStarter !== liveHomeStarter ||
    storedAwayStarter !== liveAwayStarter;

  const storedHomeStatus = normalizeStatus(storedSnapshot?.lineups?.homeStatus);
  const storedAwayStatus = normalizeStatus(storedSnapshot?.lineups?.awayStatus);
  const liveHomeStatus = normalizeStatus(liveSnapshot?.lineups?.homeStatus);
  const liveAwayStatus = normalizeStatus(liveSnapshot?.lineups?.awayStatus);

  const lineupChanged = teamSide === 'home'
    ? storedHomeStatus !== liveHomeStatus
    : storedAwayStatus !== liveAwayStatus;

  return startersChanged || lineupChanged;
}

async function markMlbContextChangedPropsStale(todayDate: string): Promise<number> {
  const { rows: events } = await pool.query(
    `SELECT event_id, home_team, away_team, home_short, away_short, starts_at
     FROM rm_events
     WHERE league = 'mlb'
       AND status != 'ended'
       AND DATE(starts_at AT TIME ZONE 'America/New_York') = $1::date`,
    [todayDate],
  );

  let totalStaled = 0;

  for (const event of events) {
    const phase = await mlbPhaseSignal.collect({
      league: 'mlb',
      homeTeam: event.home_team,
      awayTeam: event.away_team,
      homeShort: event.home_short,
      awayShort: event.away_short,
      startsAt: event.starts_at,
      eventId: event.event_id,
    }).catch(() => null);

    const liveHomeStarter = normalizeName(phase?.rawData?.firstFive?.homeStarter?.name);
    const liveAwayStarter = normalizeName(phase?.rawData?.firstFive?.awayStarter?.name);
    const liveHomeStatus = normalizeStatus(phase?.rawData?.context?.lineups?.homeStatus);
    const liveAwayStatus = normalizeStatus(phase?.rawData?.context?.lineups?.awayStatus);

    const { rows: currentAssets } = await pool.query(
      `SELECT id, forecast_type, team_side, vendor_inputs_summary
       FROM rm_forecast_precomputed
       WHERE date_et = $1::date
         AND event_id = $2
         AND forecast_type IN ('TEAM_PROPS', 'PLAYER_PROP')
         AND status = 'ACTIVE'`,
      [todayDate, event.event_id],
    );

    const staleIds: string[] = [];

    for (const asset of currentAssets) {
      const storedSnapshot = extractStoredMlbSnapshot(asset.vendor_inputs_summary);
      if (!storedSnapshot) continue;

      const teamSide = asset.team_side === 'away' ? 'away' : 'home';
      if (hasMlbSnapshotContextChange(
        storedSnapshot,
        {
          probable_starters: {
            home: liveHomeStarter ? { name: liveHomeStarter } : null,
            away: liveAwayStarter ? { name: liveAwayStarter } : null,
          },
          lineups: {
            homeStatus: liveHomeStatus,
            awayStatus: liveAwayStatus,
          },
        },
        teamSide,
      )) {
        staleIds.push(asset.id);
      }
    }

    if (staleIds.length > 0) {
      const result = await pool.query(
        `UPDATE rm_forecast_precomputed
         SET status = 'STALE'
         WHERE id = ANY($1::uuid[])`,
        [staleIds],
      );
      totalStaled += result.rowCount ?? 0;
      console.log(
        `[odds-refresh] MLB context-staled ${result.rowCount ?? 0} assets for ${event.away_short} @ ${event.home_short}` +
        ` | starters ${liveAwayStarter || 'tbd'} @ ${liveHomeStarter || 'tbd'}` +
        ` | lineups ${liveAwayStatus || 'unknown'}/${liveHomeStatus || 'unknown'}`
      );
    }
  }

  return totalStaled;
}

async function getCachedForecastForEvent(
  homeTeam: string,
  awayTeam: string,
  startsAt: string,
): Promise<any | null> {
  const homeTeamKey = normalizeTeamNameKey(homeTeam);
  const awayTeamKey = normalizeTeamNameKey(awayTeam);
  const { rows } = await pool.query(
    `SELECT *
     FROM rm_forecast_cache
     WHERE ${teamNameKeySql('home_team')} = $1
       AND ${teamNameKeySql('away_team')} = $2
       AND starts_at BETWEEN $3::timestamptz - INTERVAL '18 hours'
                         AND $3::timestamptz + INTERVAL '18 hours'
     ORDER BY ABS(EXTRACT(EPOCH FROM (starts_at - $3::timestamptz))) ASC, created_at DESC
     LIMIT 1`,
    [homeTeamKey, awayTeamKey, startsAt],
  );
  return rows[0] || null;
}

async function stampForecastSyncCheck(forecastId: string): Promise<void> {
  await pool.query(
    `UPDATE rm_forecast_cache
     SET last_refresh_at = NOW(),
         last_refresh_type = 'odds_sync_check'
     WHERE id = $1`,
    [forecastId],
  );
}

async function refreshCachedForecastFromOdds(params: {
  league: string;
  sgoEvent: SgoEvent;
  cachedForecast: any;
  oddsPayload: any;
  label: string;
}): Promise<boolean> {
  const { league, sgoEvent, cachedForecast, oddsPayload, label } = params;
  const preOdds = cachedForecast.odds_data;
  const preConfidence = cachedForecast.composite_confidence || cachedForecast.confidence_score || 0;

  if (!oddsPayloadChanged(preOdds, oddsPayload)) {
    await stampForecastSyncCheck(cachedForecast.id);
    return true;
  }

  const refreshEvent = {
    ...sgoEvent,
    eventID: cachedForecast.event_id,
  };

  const refreshResult = await refreshForEvent(refreshEvent, league, cachedForecast, label);
  const materialResult = detectMaterialChange({
    preOdds,
    postOdds: refreshResult.newOdds,
    preConfidence,
    postConfidence: refreshResult.newConfidence,
  });
  const refreshType = refreshResult.refreshMode === 'full_rebuild' ? 'forecast_rebuild' : 'odds_refresh';

  await pool.query(
    `UPDATE rm_forecast_cache
     SET last_refresh_at = NOW(),
         last_refresh_type = $1,
         refresh_count = refresh_count + 1,
         material_change = $2
     WHERE id = $3`,
    [
      refreshType,
      materialResult.isMaterial ? JSON.stringify({
        detected_at: new Date().toISOString(),
        refresh_type: refreshType,
        changes: materialResult.changes,
        banner_text: materialResult.bannerText,
      }) : null,
      cachedForecast.id,
    ],
  );

  if (materialResult.isMaterial) {
    console.log(`[odds-refresh] GAME CHANGER ${label} — ${materialResult.bannerText}`);
  }

  return true;
}

async function expirePrecomputedAssets(): Promise<number> {
  const result = await pool.query(`
    UPDATE rm_forecast_precomputed
    SET status = 'EXPIRED'
    WHERE status = 'ACTIVE'
      AND expires_at < NOW()
  `);

  const count = result.rowCount ?? 0;
  if (count > 0) {
    console.log(`[odds-refresh] Expired ${count} precomputed assets past first pitch`);
  }
  return count;
}

async function reconcileStoredEventLifecycleStatuses(): Promise<{ started: number; ended: number }> {
  const startedResult = await pool.query(`
    UPDATE rm_events
    SET status = 'started',
        updated_at = NOW()
    WHERE COALESCE(status, 'scheduled') = 'scheduled'
      AND starts_at <= NOW()
      AND starts_at > NOW() - INTERVAL '${LIFECYCLE_END_GRACE_HOURS} hours'
  `);

  const endedResult = await pool.query(`
    UPDATE rm_events
    SET status = 'ended',
        updated_at = NOW()
    WHERE COALESCE(status, 'scheduled') IN ('scheduled', 'started')
      AND starts_at <= NOW() - INTERVAL '${LIFECYCLE_END_GRACE_HOURS} hours'
  `);

  return {
    started: startedResult.rowCount ?? 0,
    ended: endedResult.rowCount ?? 0,
  };
}

/**
 * Mark PLAYER_PROP forecasts as STALE when the player is confirmed OUT.
 * Uses 2-source confirmation from PlayerInjury table.
 */
async function markInjuredPropsStale(todayDate: string): Promise<number> {
  const result = await pool.query(`
    WITH confirmed_out AS (
      SELECT LOWER("playerName") AS player_name, league
      FROM "PlayerInjury"
      WHERE status IN ('Out', 'IR', 'Suspension')
      GROUP BY LOWER("playerName"), league
      HAVING COUNT(DISTINCT source) >= 2
    )
    UPDATE rm_forecast_precomputed fp
    SET status = 'STALE'
    FROM confirmed_out co
    WHERE fp.forecast_type = 'PLAYER_PROP'
      AND fp.status = 'ACTIVE'
      AND fp.date_et = $1::date
      AND LOWER(fp.player_name) = co.player_name
      AND LOWER(fp.league) = co.league
    RETURNING fp.player_name
  `, [todayDate]);

  const count = result.rowCount ?? 0;
  if (count > 0) {
    const names = Array.from(new Set(result.rows.map((r: any) => r.player_name)));
    console.log(`[odds-refresh] Injury-staled ${count} props for: ${names.join(', ')}`);
  }
  return count;
}

async function refreshLateOpeningTeamProps(todayDate: string): Promise<number> {
  const { rows } = await pool.query(
    `SELECT e.event_id, e.league, e.home_team, e.away_team, e.home_short, e.away_short, e.starts_at,
            e.moneyline, e.spread, e.total, e.prop_count, fp.team_side, fp.generated_at,
            fp.forecast_payload->'props' AS props,
            COALESCE(jsonb_array_length(COALESCE(fp.forecast_payload->'props', '[]'::jsonb)), 0)::int AS prop_count_current
       FROM rm_events e
       JOIN rm_forecast_precomputed fp
         ON fp.event_id = e.event_id
        AND fp.date_et = $1::date
        AND fp.forecast_type = 'TEAM_PROPS'
        AND fp.status = 'ACTIVE'
      WHERE e.status = 'scheduled'
        AND e.league = ANY($2::text[])`,
    [todayDate, Array.from(SOURCE_BACKED_TEAM_PROP_RETRY_LEAGUES)],
  );

  let retried = 0;

  for (const row of rows) {
    const shouldRetryEmpty = shouldRetryLateOpeningTeamProps({
      league: row.league,
      propCount: row.prop_count_current,
      startsAt: row.starts_at,
      generatedAt: row.generated_at,
    });

    const teamSide = row.team_side === 'away' ? 'away' : 'home';
    const teamName = teamSide === 'home' ? row.home_team : row.away_team;
    const teamShort = teamSide === 'home' ? row.home_short : row.away_short;
    const opponentName = teamSide === 'home' ? row.away_team : row.home_team;
    const opponentShort = teamSide === 'home' ? row.away_short : row.home_short;

    let candidates: TeamPropMarketCandidate[];
    try {
      candidates = await fetchTeamPropMarketCandidates({
        league: row.league,
        teamShort,
        opponentShort,
        teamName,
        opponentName,
        homeTeam: row.home_team,
        awayTeam: row.away_team,
        startsAt: row.starts_at,
        skipTheOddsVerification: true,
        throwOnSourceQueryError: true,
      });
    } catch (err: any) {
      console.warn(
        `[odds-refresh] Skipping TEAM_PROPS refresh for ${row.event_id} ${teamSide}: ` +
        `source candidate load failed (${err?.message || err})`,
      );
      continue;
    }

    const shouldRefreshStale = !shouldRetryEmpty && sourceBackedTeamPropsNeedRefresh({
      league: row.league,
      props: Array.isArray(row.props) ? row.props : [],
      candidates,
    });

    if (!shouldRetryEmpty && !shouldRefreshStale) {
      continue;
    }

    if (shouldRetryEmpty && candidates.length === 0) {
      continue;
    }

    console.log(
      `[odds-refresh] Source-backed TEAM_PROPS refresh ${shouldRetryEmpty ? 'late-open' : 'source-drift'} ` +
      `${teamSide} ${row.event_id} (${row.prop_count_current} stored props, ${candidates.length} current candidates, ` +
      `last generated ${new Date(row.generated_at).toISOString()})`,
    );

    await generateTeamPropsForTeam(
      {
        event_id: row.event_id,
        league: row.league,
        home_team: row.home_team,
        away_team: row.away_team,
        home_short: row.home_short,
        away_short: row.away_short,
        starts_at: row.starts_at,
        moneyline: row.moneyline,
        spread: row.spread,
        total: row.total,
        prop_count: row.prop_count ?? 0,
      },
      teamSide,
      todayDate,
      null,
      null,
      {
        skipTheOddsVerification: true,
        throwOnSourceQueryError: true,
      },
    );
    retried++;
    await sleep(500);
  }

  return retried;
}

async function refreshCycle(): Promise<void> {
  const cycleStart = Date.now();
  const todayDate = getTodayDateET();
  const recentStartsAfterIso = new Date(Date.now() - (RECENT_EVENT_LOOKBACK_HOURS * 60 * 60 * 1000)).toISOString();

  const lifecycleReconciled = await reconcileStoredEventLifecycleStatuses();

  // 1. Get upcoming + recently started unfinished events from DB
  const { rows: dbEvents } = await pool.query(`
    SELECT event_id, league, home_short, away_short, opening_captured_at,
           opening_moneyline, opening_spread, opening_total,
           moneyline, spread, total, starts_at, status
    FROM rm_events
    WHERE COALESCE(status, 'scheduled') != 'ended'
      AND starts_at >= NOW() - INTERVAL '${RECENT_EVENT_LOOKBACK_HOURS} hours'
  `);

  if (dbEvents.length === 0) {
    console.log(`[odds-refresh] No active events for ${todayDate}`);
    return;
  }

  // Build lookup: event_id → db row
  const dbLookup = new Map<string, typeof dbEvents[0]>();
  const neededLeagues = new Set<string>();
  for (const row of dbEvents) {
    dbLookup.set(row.event_id, row);
    neededLeagues.add(row.league);
  }

  // 2. Fetch SGO events for needed leagues (in batches)
  const leagues = Array.from(neededLeagues);
  const sgoEventsByLeague: Record<string, SgoEvent[]> = {};
  const currentOddsByLeague: Record<string, TheOddsCurrentEvent[]> = {};

  for (let i = 0; i < leagues.length; i += BATCH_SIZE) {
    const batch = leagues.slice(i, i + BATCH_SIZE);
    const promises = batch.map(async (league) => {
      try {
        if (!LEAGUE_MAP[league]) return;
        const events = await fetchEvents(league, recentStartsAfterIso);
        if (events.length > 0) sgoEventsByLeague[league] = events;
        if (hasTheOddsApiConfigured()) {
          const sportKey = getTheOddsSportKey(league);
          if (sportKey) {
            currentOddsByLeague[league] = await fetchCurrentOdds({
              sportKey,
              markets: ['h2h', 'spreads', 'totals'],
            });
          }
        }
      } catch (err) {
        console.error(`[odds-refresh] Failed to fetch ${league}:`, err);
      }
    });
    await Promise.all(promises);
    if (i + BATCH_SIZE < leagues.length) await sleep(BATCH_DELAY_MS);
  }

  // 3. Match SGO events → DB events and update
  let oddsUpdated = 0;
  let openingsCaptured = 0;
  let forecastsUpdated = 0;

  for (const [league, sgoEvents] of Object.entries(sgoEventsByLeague)) {
    for (const sgoEvent of sgoEvents) {
      if (!sgoEvent.teams?.home?.names?.short || !sgoEvent.teams?.away?.names?.short) continue;

      // Build event ID same way as seed script
      const eventDateStr = sgoEvent.status?.startsAt
        ? (() => {
            const d = new Date(sgoEvent.status.startsAt);
            const etStr = d.toLocaleString('en-US', { timeZone: 'America/New_York' });
            const et = new Date(etStr);
            const y = et.getFullYear();
            const m = String(et.getMonth() + 1).padStart(2, '0');
            const day = String(et.getDate()).padStart(2, '0');
            return `${y}-${m}-${day}`;
          })()
        : todayDate;

      const eventId = makeEventId(
        league,
        sgoEvent.teams.away.names.short,
        sgoEvent.teams.home.names.short,
        eventDateStr
      );

      const dbRow = dbLookup.get(eventId);
      if (!dbRow) continue;

      const eventStatus = deriveEventLifecycleStatus(sgoEvent);
      const homeTeam = sgoEvent.teams.home.names.long || (sgoEvent.teams.home.names as any).medium || '';
      const awayTeam = sgoEvent.teams.away.names.long || (sgoEvent.teams.away.names as any).medium || '';
      const storedStatus = normalizeStatus(dbRow.status);
      const preserveStoredStart = storedStatus !== 'scheduled' || eventStatus !== 'scheduled';
      const sourceStartsAt = sgoEvent.status?.startsAt || dbRow.starts_at;
      const reconciledStart = reconcileStartTimeFromCurrentOdds({
        homeTeam,
        awayTeam,
        startsAt: preserveStoredStart ? dbRow.starts_at : sourceStartsAt,
        currentEvents: currentOddsByLeague[league] || [],
      });
      const nextStartsAt = reconciledStart.reconciled
        ? reconciledStart.startsAt
        : preserveStoredStart
          ? normalizeEventStartTime(dbRow.starts_at)
          : normalizeEventStartTime(sourceStartsAt);

      await pool.query(
        `UPDATE rm_events
         SET status = $1, starts_at = $2, updated_at = NOW()
         WHERE event_id = $3
           AND (
             COALESCE(status, 'scheduled') IS DISTINCT FROM $1
             OR starts_at IS DISTINCT FROM $2
             OR $4
           )`,
        [eventStatus, nextStartsAt, eventId, eventStatus === 'started'],
      );

      // Parse current odds
      const currentOdds = parseOdds(sgoEvent);
      const fallbackOdds = matchCurrentGameOdds(currentOddsByLeague[league] || [], {
        homeTeam,
        awayTeam,
        startsAt: nextStartsAt,
      });
      const mergedOdds = mergeMissingGameOdds({
        moneyline: currentOdds.moneyline,
        spread: currentOdds.spread,
        total: currentOdds.total,
      }, fallbackOdds);
      const oddsPayload = sanitizeGameOddsForLeague(league, mergeMissingGameOdds(mergedOdds, {
        moneyline: dbRow.moneyline || { home: null, away: null },
        spread: dbRow.spread || { home: null, away: null },
        total: dbRow.total || { over: null, under: null },
      }));

      // 4a. Capture opening lines if not yet captured
      if (!dbRow.opening_captured_at || openingPayloadNeedsBackfill(dbRow)) {
        const openingOdds = parseOpeningOdds(sgoEvent);
        if (hasAnyOpeningOdds(openingOdds)) {
          const mergedOpeningOdds = mergeOpeningOdds({
            moneyline: dbRow.opening_moneyline,
            spread: dbRow.opening_spread,
            total: dbRow.opening_total,
          }, openingOdds);
          const sanitizedOpeningOdds = sanitizeGameOddsForLeague(league, mergedOpeningOdds);
          await pool.query(`
            UPDATE rm_events
            SET opening_moneyline = $1, opening_spread = $2, opening_total = $3, opening_captured_at = NOW()
            WHERE event_id = $4
          `, [
            JSON.stringify(sanitizedOpeningOdds.moneyline),
            JSON.stringify(sanitizedOpeningOdds.spread),
            JSON.stringify(sanitizedOpeningOdds.total),
            eventId,
          ]);
          openingsCaptured++;
        }
      }

      if (!shouldRefreshPregameOdds(eventStatus)) {
        continue;
      }

      // 4b. Update current pregame odds on rm_events
      await pool.query(`
        UPDATE rm_events
        SET moneyline = $1, spread = $2, total = $3, odds_refreshed_at = NOW(), updated_at = NOW(),
            source = CASE
              WHEN $5 THEN 'sgo-auto+theodds'
              ELSE source
            END
        WHERE event_id = $4
      `, [
        JSON.stringify(oddsPayload.moneyline),
        JSON.stringify(oddsPayload.spread),
        JSON.stringify(oddsPayload.total),
        eventId,
        Boolean(fallbackOdds && currentOdds.moneyline.home == null && oddsPayload.moneyline.home != null),
      ]);
      oddsUpdated++;

      const cachedForecast = await getCachedForecastForEvent(
        homeTeam,
        awayTeam,
        nextStartsAt,
      );
      if (cachedForecast) {
        const refreshed = await refreshCachedForecastFromOdds({
          league,
          sgoEvent: { ...sgoEvent, eventID: eventId },
          cachedForecast,
          oddsPayload,
          label: `${awayTeam} @ ${homeTeam} (${league.toUpperCase()})`,
        });
        if (refreshed) forecastsUpdated++;
      }
    }
  }

  // 5. Mark player props STALE for confirmed-OUT players
  const injuryStaled = await markInjuredPropsStale(todayDate);
  const mlbContextStaled = await markMlbContextChangedPropsStale(todayDate);
  const lateTeamPropsRetried = await refreshLateOpeningTeamProps(todayDate);
  const expiredAssets = await expirePrecomputedAssets();

  const elapsed = Date.now() - cycleStart;
  console.log(`[odds-refresh] ${todayDate} | ${oddsUpdated} odds updated, ${openingsCaptured} openings captured, ${forecastsUpdated} forecasts synced, ${lifecycleReconciled.started} lifecycle-started, ${lifecycleReconciled.ended} lifecycle-ended, ${injuryStaled} injury-staled, ${mlbContextStaled} mlb-context-staled, ${lateTeamPropsRetried} late-team-props-retried, ${expiredAssets} expired | ${elapsed}ms`);
}

let shuttingDown = false;
process.on('SIGTERM', () => { console.log('[odds-refresh] SIGTERM received, finishing cycle...'); shuttingDown = true; });
process.on('SIGINT', () => { console.log('[odds-refresh] SIGINT received, finishing cycle...'); shuttingDown = true; });

async function main() {
  console.log('='.repeat(60));
  console.log('RAINMAKER ODDS REFRESH WORKER (Layer A)');
  console.log(`Cycle interval: ${CYCLE_INTERVAL_MS / 1000}s`);
  console.log(`Started: ${new Date().toISOString()}`);
  console.log('='.repeat(60));

  // Run immediately, then loop
  while (!shuttingDown) {
    try {
      await refreshCycle();
    } catch (err) {
      console.error('[odds-refresh] Cycle error:', err);
    }
    if (!shuttingDown) await sleep(CYCLE_INTERVAL_MS);
  }
  console.log('[odds-refresh] Graceful shutdown complete');
  await pool.end();
  process.exit(0);
}

if (require.main === module) {
  main().catch((err) => {
    console.error('[odds-refresh] Fatal error:', err);
    process.exit(1);
  });
}
