/**
 * Forecast Scheduler — per-event scheduling with EU lookahead + T-1h refresh.
 *
 * Long-lived PM2 process that polls every 5 minutes and:
 * - EU leagues: generates forecasts across the configured lookahead window,
 *   retries any still-missing forecasts in the nearer generate window, refreshes T-1h
 * - US leagues: generates at 7 AM ET batch, midday refresh at 2 PM ET, T-1h refresh
 * - Detects material changes (big line moves, confidence shifts) and stores banner data
 * - Logs all actions to rm_scheduler_log for idempotency
 *
 * Usage: npx tsx src/workers/forecast-scheduler.ts
 */

import 'dotenv/config';
import { Pool } from 'pg';
import { buildDatabasePoolConfig } from '../db/config';
import { fetchAllLeagueEvents, SgoEvent, isNcaabD1 } from '../services/sgo';
import { getCachedForecast, resolveCanonicalEventId, RmForecast } from '../models/forecast';
import { generateForEvent, refreshForEvent } from '../services/forecast-runner';
import { detectMaterialChange } from '../services/material-change';
import { resolveAllPending } from '../services/grading-service';
import { resolveClv } from './clv-resolver';
import { backfillTheOddsPlayerPropOdds } from './backfill-theodds-player-prop-odds';
import { runArchiveSettlementBackfill } from './settle-archives';
import { runNightlyScoreResolver } from './nightly-score-resolver';
import {
  isEuLeague,
  EU_GENERATE_BEFORE_MIN,
  PRE_EVENT_REFRESH_MIN,
  US_MORNING_HOUR_ET,
  US_MIDDAY_HOUR_ET,
  SCHEDULER_POLL_MS,
} from '../lib/scheduler-config';
import { EU_EVENT_LOOKAHEAD_DAYS, isLeagueEventInWindow } from '../lib/league-windows';

// Separate pool — limited connections so we don't compete with the API server
const pool = new Pool(buildDatabasePoolConfig({ max: 3 }));
const BENCHMARK_RESOLVE_INTERVAL_MS = 15 * 60 * 1000;
const CLV_RESOLVE_INTERVAL_MS = 15 * 60 * 1000;
const ARCHIVE_SETTLE_INTERVAL_MS = 60 * 60 * 1000;
const THE_ODDS_BACKFILL_INTERVAL_MS = 2 * 60 * 60 * 1000;
const NIGHTLY_SCORE_RESOLVER_HOUR_ET = 3;
const NIGHTLY_SCORE_RESOLVER_MINUTE_ET = 15;

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

let lastBenchmarkResolveAt = 0;
let lastClvResolveAt = 0;
let lastArchiveSettlementAt = 0;
let lastTheOddsBackfillAt = 0;
let lastNightlyScoreResolverDate = '';

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

function getNowET(): Date {
  const etStr = new Date().toLocaleString('en-US', { timeZone: 'America/New_York' });
  return new Date(etStr);
}

function getTodayDateET(): string {
  const et = getNowET();
  return `${et.getFullYear()}-${String(et.getMonth() + 1).padStart(2, '0')}-${String(et.getDate()).padStart(2, '0')}`;
}

type SchedulerAction = 'generate' | 'refresh' | null;
type RunType = 'eu_lookahead' | 'eu_t96h' | 'us_morning' | 'midday_batch' | 't_minus_1h';

interface ActionDecision {
  action: SchedulerAction;
  runType: RunType | null;
}

/**
 * Determine what action (if any) to take for an event right now.
 */
export function determineAction(
  league: string,
  startsAt: string,
  hasForecast: boolean,
  nowET: Date,
): ActionDecision {
  const startTime = new Date(startsAt);
  const startET = new Date(startTime.toLocaleString('en-US', { timeZone: 'America/New_York' }));

  // Hard stop: event already started
  if (startTime <= new Date()) {
    return { action: null, runType: null };
  }

  const minsUntilStart = (startTime.getTime() - new Date().getTime()) / 60_000;
  const hourET = nowET.getHours();
  const minuteET = nowET.getMinutes();

  if (isEuLeague(league)) {
    // EU lookahead window: prebuild once the event is seeded into the forward window.
    if (!hasForecast && minsUntilStart > EU_GENERATE_BEFORE_MIN) {
      return { action: 'generate', runType: 'eu_lookahead' };
    }
    // EU nearer kickoff retry window: if the lookahead attempt still did not produce a forecast,
    // keep trying again once the event enters the tighter generation window.
    if (!hasForecast && minsUntilStart <= EU_GENERATE_BEFORE_MIN && minsUntilStart > PRE_EVENT_REFRESH_MIN) {
      return { action: 'generate', runType: 'eu_t96h' };
    }
    // EU T-1h refresh window (60–50 min before start)
    if (hasForecast && minsUntilStart <= PRE_EVENT_REFRESH_MIN && minsUntilStart > PRE_EVENT_REFRESH_MIN - 10) {
      return { action: 'refresh', runType: 't_minus_1h' };
    }
  } else {
    // US morning batch: 7:00–7:30 AM ET, no forecast yet
    if (!hasForecast && hourET === US_MORNING_HOUR_ET && minuteET < 30) {
      return { action: 'generate', runType: 'us_morning' };
    }
    // US midday batch: 2:00–2:30 PM ET, has forecast, event starts ≥ 2 PM ET
    if (hasForecast && hourET === US_MIDDAY_HOUR_ET && minuteET < 30 && startET.getHours() >= US_MIDDAY_HOUR_ET) {
      return { action: 'refresh', runType: 'midday_batch' };
    }
    // US T-1h refresh (60–50 min before start)
    if (hasForecast && minsUntilStart <= PRE_EVENT_REFRESH_MIN && minsUntilStart > PRE_EVENT_REFRESH_MIN - 10) {
      return { action: 'refresh', runType: 't_minus_1h' };
    }
  }

  return { action: null, runType: null };
}

/**
 * Check if this (event_id, run_type) already ran today.
 */
async function alreadyRanToday(eventId: string, runType: string): Promise<boolean> {
  const todayDate = getTodayDateET();
  const { rows } = await pool.query(
    `SELECT id FROM rm_scheduler_log
     WHERE event_id = $1 AND run_type = $2
       AND (started_at AT TIME ZONE 'UTC')::date = $3::date
       AND status IN ('completed', 'started')
     LIMIT 1`,
    [eventId, runType, todayDate]
  );
  return rows.length > 0;
}

/**
 * Insert a scheduler log entry (started).
 */
async function logStart(eventId: string, action: string, runType: string): Promise<string> {
  const { rows } = await pool.query(
    `INSERT INTO rm_scheduler_log (event_id, action, run_type, status)
     VALUES ($1, $2, $3, 'started')
     RETURNING id`,
    [eventId, action, runType]
  );
  return rows[0].id;
}

/**
 * Mark a scheduler log entry as completed.
 */
async function logComplete(logId: string, details?: any): Promise<void> {
  await pool.query(
    `UPDATE rm_scheduler_log SET status = 'completed', completed_at = NOW(), details = $1 WHERE id = $2`,
    [details ? JSON.stringify(details) : null, logId]
  );
}

/**
 * Mark a scheduler log entry as failed.
 */
async function logFailed(logId: string, error: string): Promise<void> {
  await pool.query(
    `UPDATE rm_scheduler_log SET status = 'failed', completed_at = NOW(), details = $1 WHERE id = $2`,
    [JSON.stringify({ error }), logId]
  );
}

/**
 * Main scheduler cycle — runs once per poll interval.
 */
async function schedulerCycle(): Promise<void> {
  const cycleStart = Date.now();
  const nowET = getNowET();
  console.log(`\n[scheduler] Cycle start: ${nowET.toLocaleString()} ET`);

  // Fetch all events
  const eventsByLeague = await fetchAllLeagueEvents();

  let actions = 0;
  let skipped = 0;

  for (const [league, events] of Object.entries(eventsByLeague)) {
    const validEvents = events.filter(e => e.teams?.home?.names && e.teams?.away?.names);

    for (const event of validEvents) {
      const startsAt = event.status?.startsAt || '';
      if (!startsAt) continue;

      // EU leagues need a forward window so the scheduler can prebuild forecasts before match day.
      if (!isLeagueEventInWindow(league, startsAt, EU_EVENT_LOOKAHEAD_DAYS)) continue;

      // MLB preseason date gate removed — regular season MLB should flow through normally.

      const homeTeam = event.teams.home.names.long;
      const awayTeam = event.teams.away.names.long;

      // Skip non-D1 NCAAB teams (D2/D3/NAIA have no grading source)
      if (league === 'ncaab') {
        const [homeD1, awayD1] = await Promise.all([isNcaabD1(homeTeam), isNcaabD1(awayTeam)]);
        if (!homeD1 || !awayD1) continue;
      }

      // Resolve canonical event ID
      const canonicalId = await resolveCanonicalEventId(
        event.eventID, homeTeam, awayTeam, startsAt
      );
      event.eventID = canonicalId;

      // Check existing forecast
      const cached = await getCachedForecast(canonicalId);
      const hasForecast = !!(cached && cached.forecast_data?.summary);

      // Determine action
      const { action, runType } = determineAction(league, startsAt, hasForecast, nowET);
      if (!action || !runType) continue;

      // Idempotency check
      if (await alreadyRanToday(canonicalId, runType)) {
        skipped++;
        continue;
      }

      const label = `${awayTeam} @ ${homeTeam} (${league.toUpperCase()})`;
      const logId = await logStart(canonicalId, action, runType);

      try {
        if (action === 'generate') {
          console.log(`  [SCHED-GENERATE] ${label} (${runType})`);
          const result = await generateForEvent(event, league, `[SCHED] ${label}`);
          await logComplete(logId, { confidence: result.confidence, compositeConfidence: result.compositeConfidence });
          actions++;
        } else if (action === 'refresh' && cached) {
          console.log(`  [SCHED-REFRESH] ${label} (${runType})`);

          // Snapshot pre-refresh state
          const preOdds = cached.odds_data;
          const preConfidence = cached.composite_confidence || cached.confidence_score;

          // Execute refresh
          const refreshResult = await refreshForEvent(event, league, cached, `[SCHED] ${label}`);

          // Detect material changes
          const materialResult = detectMaterialChange({
            preOdds,
            postOdds: refreshResult.newOdds,
            preConfidence,
            postConfidence: refreshResult.newConfidence,
          });
          const refreshType = refreshResult.refreshMode === 'full_rebuild'
            ? `${runType}_rebuild`
            : runType;

          // Update forecast cache with refresh tracking + material change data
          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,
              cached.id,
            ]
          );

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

          await logComplete(logId, {
            confDelta: refreshResult.confDelta,
            materialChange: materialResult.isMaterial,
            bannerText: materialResult.bannerText,
          });
          actions++;
        }
      } catch (err: any) {
        console.error(`  [SCHED-FAIL] ${label} (${runType}): ${err.message}`);
        await logFailed(logId, err.message);
      }

      // Rate limit: 3s between events
      await sleep(3000);
    }
  }

  const elapsed = Math.round((Date.now() - cycleStart) / 1000);
  console.log(`[scheduler] Cycle done: ${actions} actions, ${skipped} skipped, ${elapsed}s`);
}

function formatEtDateKey(nowET: Date): string {
  return `${nowET.getFullYear()}-${String(nowET.getMonth() + 1).padStart(2, '0')}-${String(nowET.getDate()).padStart(2, '0')}`;
}

async function runMaintenance(nowET: Date): Promise<void> {
  const nowMs = Date.now();

  if (nowMs - lastBenchmarkResolveAt >= BENCHMARK_RESOLVE_INTERVAL_MS) {
    lastBenchmarkResolveAt = nowMs;
    try {
      const resolved = await resolveAllPending();
      console.log(`[scheduler] Benchmark resolver processed ${resolved} forecasts`);
    } catch (err: any) {
      console.error(`[scheduler] Benchmark resolver failed: ${err.message}`);
    }
  }

  if (nowMs - lastClvResolveAt >= CLV_RESOLVE_INTERVAL_MS) {
    lastClvResolveAt = nowMs;
    try {
      const summary = await resolveClv({ refreshViews: true });
      console.log(`[scheduler] CLV resolver scanned ${summary.scanned}, updated ${summary.resolved}, errors ${summary.errors}`);
    } catch (err: any) {
      console.error(`[scheduler] CLV resolver failed: ${err.message}`);
    }
  }

  if (nowMs - lastArchiveSettlementAt >= ARCHIVE_SETTLE_INTERVAL_MS) {
    lastArchiveSettlementAt = nowMs;
    try {
      await runArchiveSettlementBackfill({ refreshBuckets: true });
      console.log('[scheduler] Archive settlement backfill completed');
    } catch (err: any) {
      console.error(`[scheduler] Archive settlement failed: ${err.message}`);
    }
  }

  if (process.env.THE_ODDS_BACKFILL_ENABLED === 'true' && nowMs - lastTheOddsBackfillAt >= THE_ODDS_BACKFILL_INTERVAL_MS) {
    lastTheOddsBackfillAt = nowMs;
    try {
      const summary = await backfillTheOddsPlayerPropOdds({ refreshViews: true });
      console.log(`[scheduler] TheOdds backfill scanned ${summary.rowsScanned}, updated ${summary.rowsUpdated}`);
    } catch (err: any) {
      console.error(`[scheduler] TheOdds backfill failed: ${err.message}`);
    }
  }

  const etDateKey = formatEtDateKey(nowET);
  const afterNightlyWindow =
    nowET.getHours() > NIGHTLY_SCORE_RESOLVER_HOUR_ET
    || (nowET.getHours() === NIGHTLY_SCORE_RESOLVER_HOUR_ET && nowET.getMinutes() >= NIGHTLY_SCORE_RESOLVER_MINUTE_ET);
  if (afterNightlyWindow && lastNightlyScoreResolverDate !== etDateKey) {
    try {
      await runNightlyScoreResolver();
      lastNightlyScoreResolverDate = etDateKey;
      console.log('[scheduler] Nightly score resolver completed');
    } catch (err: any) {
      console.error(`[scheduler] Nightly score resolver failed: ${err.message}`);
    }
  }
}

// ── Main loop ──
let shuttingDown = false;
process.on('SIGTERM', () => { console.log('[scheduler] SIGTERM received, finishing cycle...'); shuttingDown = true; });
process.on('SIGINT', () => { console.log('[scheduler] SIGINT received, finishing cycle...'); shuttingDown = true; });

async function main() {
  console.log('='.repeat(60));
  console.log('RAINMAKER FORECAST SCHEDULER');
  console.log(`Started: ${new Date().toISOString()}`);
  console.log(`Poll interval: ${SCHEDULER_POLL_MS / 1000}s`);
  console.log('='.repeat(60));

  while (!shuttingDown) {
    try {
      await schedulerCycle();
      await runMaintenance(getNowET());
    } catch (err: any) {
      console.error(`[scheduler] Cycle error: ${err.message}`);
    }
    if (!shuttingDown) await sleep(SCHEDULER_POLL_MS);
  }
  console.log('[scheduler] Graceful shutdown complete');
  process.exit(0);
}

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