import { TournamentState, type LiveMatch, type Source } from '../tournament';
import { pollDelay, fallbackAllowed } from './cadence';
import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
import { fetchEspnOdds } from './espnOdds';
import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
import { fetchMatchWeather } from './weather';
import { fetchScorerProps } from './espnProps';
import type { EspnSummary } from './espnNormalize';
import type { Fixture } from '../../../src/lib/types';

// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
// live + enrichment source (works from the VPS); football-data.org and SofaScore
// are fallbacks. Everything persists to SQLite; the UI reads the DB, so a blocked
// source only means staler data.

const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000;
const ENRICH_MS = 5 * 60_000;
const ODDS_MS = 3 * 60 * 60 * 1000; // odds sweep cadence (line-movement history)
const ODDS_HORIZON_MS = 14 * 24 * 60 * 60 * 1000;

function dateUTC(iso: string): string {
  const d = new Date(iso);
  return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
}

export function startScheduler(
  state: TournamentState,
  onChange: () => void,
  onTick?: () => void,
): () => void {
  const token = process.env.FOOTBALL_DATA_TOKEN?.trim();
  const sofa = process.env.ENABLE_SOFASCORE === 'true';
  let stopped = false;
  let liveTimer: ReturnType<typeof setTimeout> | undefined;
  let enrichTimer: ReturnType<typeof setTimeout> | undefined;

  console.log(`[ingest] scheduler — espn:on football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'}`);

  // ---- one-time: map every fixture to its ESPN event id (across all dates).
  // Mappings persist in source_map, so on restarts we only fetch dates that
  // still contain unmapped fixtures — boots get fast, odds capture starts early.
  const mapAllFixtures = async (): Promise<void> => {
    const byDate = new Map<string, Fixture[]>();
    for (const f of state.allFixtures()) {
      const d = dateUTC(f.kickoff);
      if (!byDate.has(d)) byDate.set(d, []);
      byDate.get(d)!.push(f);
    }
    const dates = [...byDate.keys()].filter((d) => byDate.get(d)!.some((f) => !getSourceMap(f.num, 'espn')));
    let mapped = 0;
    for (const date of dates) {
      if (stopped) return;
      const t0 = Date.now();
      try {
        const evs = await fetchEspnScoreboard(date);
        for (const m of evs) {
          const num = state.matchFixtureNum(m.home, m.away, 'group', m.kickoff);
          if (num) { setSourceMap(num, 'espn', m.eventId); mapped++; }
        }
        logIngest('espn', 'map', true, Date.now() - t0, `${date}: ${evs.length} events`);
      } catch (e) {
        logIngest('espn', 'map', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
      }
    }
    console.log(`[ingest] mapped ${mapped} fixtures to ESPN event ids`);
  };

  // ---- live scoreboard ----
  let lastFdAttempt = 0; // throttles the fallback independently of tick cadence
  const liveTick = async (): Promise<void> => {
    const t0 = Date.now();
    let matches: LiveMatch[] = [];
    let source: Source = 'seed';
    try {
      const espn = await fetchEspnScoreboard();
      matches = espn;
      source = 'espn';
      for (const m of espn) {
        const num = state.matchFixtureNum(m.home, m.away, 'group', m.kickoff);
        if (num) setSourceMap(num, 'espn', m.eventId);
      }
      logIngest('espn', 'scoreboard', true, Date.now() - t0, `${espn.length} events`);
    } catch (e) {
      logIngest('espn', 'scoreboard', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
    }
    if (!matches.length && token && fallbackAllowed(lastFdAttempt, Date.now())) {
      lastFdAttempt = Date.now();
      try { matches = await fetchFootballData(token); source = 'football-data'; } catch { /* logged via health */ }
    }
    if (!matches.length && sofa) {
      try { matches = await fetchSofascore(); source = 'sofascore'; } catch { /* expected block */ }
    }
    if (matches.length) {
      const changed = state.mergeLive(matches, source);
      if (changed.length) {
        for (const f of changed) saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
        console.log(`[ingest] ${changed.length} fixture(s) updated via ${source}`);
        onChange();
      }
    }
    onTick?.(); // e.g. freeze pre-kickoff forecast snapshots

    // Keep live matches' stats + event timeline fresh at the live cadence.
    for (const f of state.allFixtures()) {
      if (f.status !== 'live') continue;
      const eid = getSourceMap(f.num, 'espn');
      if (!eid) continue;
      try { setMatchExt(f.num, await fetchEspnSummary(eid, true)); } catch { /* logged via health */ }
    }
  };

  // Self-healing results backfill: any PAST match day whose fixtures still
  // look 'scheduled' (fresh DB, or results lost before persistence existed)
  // gets its date-scoped ESPN scoreboard re-fetched and merged.
  const resultsBackfill = async (): Promise<void> => {
    const now = Date.now();
    const dates = new Set<string>();
    for (const f of state.allFixtures()) {
      const k = new Date(f.kickoff).getTime();
      // a started-but-forgotten fixture: kickoff is past, state still 'scheduled'
      if (k > now - 2 * 60 * 60 * 1000 || f.status !== 'scheduled' || !f.home.team || !f.away.team) continue;
      // ESPN's ?dates= is US-Eastern-based: a late-UTC kickoff files under the
      // previous calendar day there — fetch both candidates
      dates.add(dateUTC(f.kickoff));
      dates.add(dateUTC(new Date(k - 24 * 60 * 60 * 1000).toISOString()));
    }
    for (const date of dates) {
      if (stopped) return;
      const t0 = Date.now();
      try {
        const matches = await fetchEspnScoreboard(date);
        const changed = state.mergeLive(matches, 'espn');
        for (const f of changed) saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
        if (changed.length) onChange();
        logIngest('espn', 'results-backfill', true, Date.now() - t0, `${date}: ${changed.length} restored`);
      } catch (e) {
        logIngest('espn', 'results-backfill', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
      }
    }
  };

  // One-time backfill: re-store summaries saved by an older normalizer (e.g.
  // before structured scorers existed), so started matches stay fully usable.
  const backfillExt = async (): Promise<void> => {
    for (const f of state.allFixtures()) {
      if (stopped) return;
      if (f.status === 'scheduled') continue;
      const ext = getMatchExt<EspnSummary>(f.num);
      if (ext && ext.data.v === 2) continue;
      const eid = getSourceMap(f.num, 'espn');
      if (!eid) continue;
      try {
        setMatchExt(f.num, await fetchEspnSummary(eid, true)); // live TTL → skips the stale cache
        console.log(`[ingest] backfilled summary for M${f.num}`);
      } catch { /* health-tracked */ }
    }
  };

  // ---- enrichment: ESPN summary for imminent/live fixtures ----
  const enrichTick = async (): Promise<void> => {
    const now = Date.now();
    const relevant = state.allFixtures().filter((f) => {
      const k = new Date(f.kickoff).getTime();
      return f.status === 'live' || (k > now - LIVE_WINDOW_MS && k < now + 3 * 24 * 60 * 60 * 1000);
    });
    for (const f of relevant) {
      if (stopped) return;
      const eid = getSourceMap(f.num, 'espn');
      if (!eid) continue;
      const t0 = Date.now();
      try {
        const summary = await fetchEspnSummary(eid);
        setMatchExt(f.num, summary);
        logIngest('espn', 'summary', true, Date.now() - t0, `M${f.num}`);
      } catch (e) {
        logIngest('espn', 'summary', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
      }
      // kickoff-hour weather for fixtures inside the forecast horizon
      if (f.status === 'scheduled' && new Date(f.kickoff).getTime() - now < 2 * 24 * 60 * 60 * 1000) {
        try {
          const w = await fetchMatchWeather(f.venue, f.kickoff);
          if (w) setMatchProvider(f.num, 'weather', w);
        } catch { /* health-tracked */ }
      }
    }
  };

  // ---- FotMob: xG / shotmaps / ratings — capture-first (unofficial source,
  // could lock again any day). Own gentle cadence: 90s while live, 30m idle.
  const FOTMOB_SETTLE_MS = 3 * 60 * 60 * 1000; // refetch finished until FT+3h (settled ratings)
  const fotmobTick = async (): Promise<void> => {
    const now = Date.now();
    // map: sweep dates (now ±3d) that still contain unmapped fixtures
    const dates = new Set<string>();
    for (const f of state.allFixtures()) {
      const k = new Date(f.kickoff).getTime();
      if (Math.abs(k - now) > 3 * 24 * 60 * 60 * 1000) continue;
      if (!getSourceMap(f.num, 'fotmob')) dates.add(dateUTC(f.kickoff));
    }
    for (const date of dates) {
      if (stopped) return;
      const t0 = Date.now();
      try {
        const refs = await fetchFotmobMatches(date);
        for (const r of refs) {
          const num = state.matchFixtureNum(r.home, r.away, 'group', r.utcTime || `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T12:00:00Z`);
          if (num && !getSourceMap(num, 'fotmob')) setSourceMap(num, 'fotmob', r.matchId);
        }
        logIngest('fotmob', 'map', true, Date.now() - t0, `${date}: ${refs.length} matches`);
      } catch (e) {
        logIngest('fotmob', 'map', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
      }
    }
    // details: live fixtures at the live TTL; finished ones until settled
    for (const f of state.allFixtures()) {
      if (stopped) return;
      const id = getSourceMap(f.num, 'fotmob');
      if (!id) continue;
      const live = f.status === 'live';
      if (!live && f.status !== 'finished') continue;
      if (f.status === 'finished') {
        const row = getMatchProvider<{ v?: number }>(f.num, 'fotmob');
        const settled = row && row.data.v === 2 && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS;
        if (settled) continue;
      }
      const t0 = Date.now();
      try {
        setMatchProvider(f.num, 'fotmob', await fetchFotmobDetails(id, live));
        logIngest('fotmob', live ? 'live' : 'final', true, Date.now() - t0, `M${f.num}`);
      } catch (e) {
        logIngest('fotmob', 'details', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
      }
    }
  };

  let fotmobTimer: ReturnType<typeof setTimeout> | undefined;
  const scheduleFotmob = (): void => {
    if (stopped) return;
    fotmobTimer = setTimeout(async () => { await fotmobTick().catch(() => {}); scheduleFotmob(); }, state.hasLive() ? 90_000 : 30 * 60_000);
  };

  // ---- FIFA official: confirmed lineups, XY timelines, attendance/officials.
  const fifaTick = async (): Promise<void> => {
    const now = Date.now();
    const DAY_MS = 24 * 60 * 60 * 1000;
    // calendar sweep (one cached call covers ±3d): id map + official match info
    const t0 = Date.now();
    try {
      const from = new Date(now - 3 * DAY_MS).toISOString().slice(0, 10);
      const to = new Date(now + 3 * DAY_MS).toISOString().slice(0, 10);
      const refs = await fetchFifaCalendar(`${from}T00:00:00Z`, `${to}T00:00:00Z`);
      for (const r of refs) {
        const num = state.matchFixtureNum(r.home, r.away, 'group', r.date);
        if (!num) continue;
        if (!getSourceMap(num, 'fifa')) setSourceMap(num, 'fifa', `${r.idStage}|${r.idMatch}`);
        setMatchProvider(num, 'fifa-info', { attendance: r.attendance, officials: r.officials, stadium: r.stadium, date: r.date });
      }
      logIngest('fifa', 'calendar', true, Date.now() - t0, `${refs.length} matches`);
    } catch (e) {
      logIngest('fifa', 'calendar', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
    }
    // lineups + timeline: live fixtures hot, finished until settled
    for (const f of state.allFixtures()) {
      if (stopped) return;
      const mapId = getSourceMap(f.num, 'fifa');
      if (!mapId) continue;
      const [idStage, idMatch] = mapId.split('|');
      if (!idStage || !idMatch) continue;
      const live = f.status === 'live';
      const imminent = f.status === 'scheduled' && new Date(f.kickoff).getTime() - now < 90 * 60_000 && new Date(f.kickoff).getTime() > now;
      if (!live && !imminent && f.status !== 'finished') continue;
      if (f.status === 'finished') {
        const row = getMatchProvider(f.num, 'fifa');
        if (row && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS) continue;
      }
      const t1 = Date.now();
      try {
        const [liveData, timeline] = [await fetchFifaLive(idStage, idMatch, live), await fetchFifaTimeline(idStage, idMatch, live)];
        setMatchProvider(f.num, 'fifa', { v: 1, fetchedAt: Date.now(), live: liveData, timeline });
        logIngest('fifa', live ? 'live' : imminent ? 'lineups' : 'final', true, Date.now() - t1, `M${f.num}`);
      } catch (e) {
        logIngest('fifa', 'match', false, Date.now() - t1, e instanceof Error ? e.message : String(e));
      }
    }
  };

  let fifaTimer: ReturnType<typeof setTimeout> | undefined;
  const scheduleFifa = (): void => {
    if (stopped) return;
    fifaTimer = setTimeout(async () => { await fifaTick().catch(() => {}); scheduleFifa(); }, state.hasLive() ? 2 * 60_000 : 30 * 60_000);
  };

  // ---- odds capture: every upcoming fixture inside the horizon, plus imminent
  // ones near kickoff. Insert-if-changed keeps a clean line-movement history.
  const oddsTick = async (): Promise<void> => {
    const now = Date.now();
    const relevant = state.allFixtures().filter((f) => {
      if (f.status === 'finished') return false;
      const k = new Date(f.kickoff).getTime();
      return k > now - 3 * 60 * 60 * 1000 && k < now + ODDS_HORIZON_MS;
    });
    let captured = 0;
    const t0 = Date.now();
    for (const f of relevant) {
      if (stopped) return;
      const eid = getSourceMap(f.num, 'espn');
      if (!eid) continue;
      try {
        for (const o of await fetchEspnOdds(eid)) {
          if (recordOdds({
            fixture_num: f.num, provider: o.provider,
            home_ml: o.homeMl, draw_ml: o.drawMl, away_ml: o.awayMl,
            over_under: o.overUnder, spread: o.spread,
          })) captured++;
        }
      } catch { /* breaker/health track it */ }
      // scorer props (anytime/first/last goalscorer lines) — only near kickoff,
      // where the lines are most meaningful and the budget stays sane
      if (new Date(f.kickoff).getTime() - now < 2 * 24 * 60 * 60 * 1000) {
        try {
          let propMoves = 0;
          for (const p of await fetchScorerProps(eid)) {
            if (recordScorerProp({ fixture_num: f.num, provider: 'DraftKings', market: p.market, athlete: p.athleteName, odds: p.american })) propMoves++;
          }
          if (propMoves) logIngest('espn-odds', 'props', true, 0, `M${f.num}: ${propMoves} prop line(s)`);
        } catch { /* breaker/health track it */ }
      }
    }
    logIngest('espn-odds', 'sweep', true, Date.now() - t0, `${relevant.length} fixtures, ${captured} new lines`);
    if (captured) console.log(`[ingest] odds: ${captured} new line(s) captured`);
  };

  let oddsTimer: ReturnType<typeof setTimeout> | undefined;
  const scheduleOdds = (): void => {
    if (stopped) return;
    oddsTimer = setTimeout(async () => { await oddsTick().catch(() => {}); scheduleOdds(); }, ODDS_MS);
  };

  // Daily housekeeping: prune keeps the faster polling from growing
  // kv_cache/ingest_log forever; backupDb snapshots to the archive dir
  // (the storage box in prod) so history survives anything.
  const housekeeping = (): void => {
    try {
      const n = prune();
      if (n) console.log(`[db] pruned ${n} stale cache/log rows`);
    } catch { /* non-fatal */ }
    backupDb();
  };
  const pruneTimer = setInterval(housekeeping, 24 * 60 * 60 * 1000);

  const scheduleLive = (): void => {
    if (stopped) return;
    const delay = pollDelay(state.hasLive(), state.hasLiveActivity(LIVE_WINDOW_MS));
    liveTimer = setTimeout(async () => { await liveTick().catch(() => {}); scheduleLive(); }, delay);
  };
  const scheduleEnrich = (): void => {
    if (stopped) return;
    enrichTimer = setTimeout(async () => { await enrichTick().catch(() => {}); scheduleEnrich(); }, ENRICH_MS);
  };

  // boot: map ids, then LIVE FIRST — the odds sweep can take minutes and a
  // restart mid-match must not blank the live scores meanwhile. Odds use their
  // own rate-limit key ('espn-odds'), so the sweeps interleave politely.
  void (async () => {
    await mapAllFixtures().catch(() => {});
    await liveTick().catch(() => {});
    scheduleLive(); // live cadence starts now; slow one-time sweeps follow
    await resultsBackfill().catch(() => {}); // heal any forgotten past match days
    // persist whatever the current scoreboard window knows — results learned
    // before this table existed would otherwise never be written
    for (const f of state.allFixtures()) {
      if (f.status !== 'scheduled') saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
    }
    await fotmobTick().catch(() => {}); // capture-first: unofficial source, harvest while it lasts
    scheduleFotmob();
    await fifaTick().catch(() => {});
    scheduleFifa();
    await oddsTick().catch(() => {});
    await backfillExt().catch(() => {});
    await enrichTick().catch(() => {});
    scheduleEnrich();
    scheduleOdds();
    backupDb(); // boot snapshot — the daily timer covers the rest
  })();

  return () => {
    stopped = true;
    if (liveTimer) clearTimeout(liveTimer);
    if (enrichTimer) clearTimeout(enrichTimer);
    if (oddsTimer) clearTimeout(oddsTimer);
    if (fotmobTimer) clearTimeout(fotmobTimer);
    if (fifaTimer) clearTimeout(fifaTimer);
    clearInterval(pruneTimer);
  };
}
