import path from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
import type { EspnSummary } from './ingest/espnNormalize';
import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';

const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const MAX_WS = Number(process.env.MAX_WS) || 200;
const HEARTBEAT_MS = 30_000;

export function buildServer() {
  const app = Fastify({ trustProxy: true, bodyLimit: 256 * 1024 });
  // Tolerate body-less requests that still set content-type: application/json.
  app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
    const s = (body as string).trim();
    if (!s) return done(null, undefined);
    try { done(null, JSON.parse(s)); }
    catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
  });

  db(); // open + migrate SQLite before anything reads it
  const state = new TournamentState(STATIC_DIR);
  // Restore persisted results over the seed — the in-memory snapshot forgets
  // them on restart once ESPN's scoreboard window has moved past a match day.
  let restored = 0;
  for (const r of allFixtureResults()) {
    if (r.status === 'scheduled') continue;
    if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++;
  }
  if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`);
  const model = new ModelEngine(STATIC_DIR);
  model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
  model.recompute(state.allFixtures());
  const clients = new Set<WebSocket>();

  const snapshotMessage = (): string =>
    JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
  const predictionsMessage = (): string =>
    JSON.stringify({ t: 'predictions', model: model.current() } satisfies ServerMessage);

  const sendAll = (msg: string): void => {
    for (const ws of clients) {
      try { ws.send(msg); } catch { /* closed */ }
    }
  };

  /** Re-push the snapshot, and predictions too if the finished-result set moved. */
  const notifier = new PushNotifier();
  const broadcast = (): void => {
    sendAll(snapshotMessage());
    if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
    try { notifier.tick(state.allFixtures()); } catch { /* never block a broadcast */ }
  };

  // Record the in-play win probability per game state while matches run —
  // the momentum curve on match pages. Lambdas are the frozen pre-match
  // expectation (predictions only recompute when the finished set changes).
  const recordInplayTick = (): void => {
    const preds = new Map(model.current().matches.map((m) => [m.num, m]));
    for (const f of state.allFixtures()) {
      if (f.status !== 'live' || f.homeScore == null || f.awayScore == null) continue;
      const pred = preds.get(f.num);
      if (!pred) continue;
      const minute = f.minute ?? 0;
      const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore);
      recordInplay(f.num, {
        minute, home_score: f.homeScore, away_score: f.awayScore,
        p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4),
      });
    }
  };

  // ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
  app.get('/api/snapshot', async () => state.snapshot());
  app.get('/api/predictions', async () => model.current());
  app.get('/api/sources/health', async () => ({ sources: healthAll() }));
  // Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
  app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
  // In-play win-probability history — the momentum curve on match pages.
  app.get('/api/inplay/:num', async (req) => {
    const num = Number((req.params as { num: string }).num);
    return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
  });

  // Web Push (feature-flagged on VAPID env): key discovery + subscriptions.
  app.get('/api/push/key', async (_req, reply) =>
    pushEnabled ? { key: vapidPublicKey } : reply.code(404).send({ error: 'push disabled' }),
  );
  app.post('/api/push/subscribe', async (req, reply) => {
    if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
    const b = (req.body ?? {}) as { subscription?: { endpoint?: string }; team?: string };
    if (!b.subscription?.endpoint || !b.team) return reply.code(400).send({ error: 'subscription + team required' });
    pushSubscribe(b.subscription as { endpoint: string }, b.team);
    return { ok: true };
  });
  app.post('/api/push/unsubscribe', async (req, reply) => {
    if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
    const b = (req.body ?? {}) as { endpoint?: string };
    if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
    pushUnsubscribe(b.endpoint);
    return { ok: true };
  });

  // Rich match data captured from FotMob/FIFA/Open-Meteo, projected for the
  // match center: oriented shots with xG, momentum, POTM, official info, weather.
  app.get('/api/match-rich/:num', async (req, reply) => {
    const num = Number((req.params as { num: string }).num);
    if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
    interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string }
    interface FmLineupPlayer {
      name?: string;
      shirtNumber?: string | number;
      verticalLayout?: { x?: number; y?: number };
      performance?: { rating?: number; substitutionEvents?: { time?: number; type?: string }[]; events?: { type?: string }[] };
    }
    interface FmLineupTeam { formation?: string; coach?: { name?: string }; starters?: FmLineupPlayer[]; subs?: FmLineupPlayer[] }
    interface FmDetails {
      homeTeamId?: number | null;
      shots?: FmShot[];
      momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
      matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
      lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
    }
    const fm = getMatchProvider<FmDetails>(num, 'fotmob');
    const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
    const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather');

    const homeId = fm?.data.homeTeamId ?? null;
    const shots = (fm?.data.shots ?? []).map((s) => ({
      isHome: homeId != null ? s.teamId === homeId : null,
      player: s.playerName ?? '',
      x: s.x ?? null,
      y: s.y ?? null,
      min: s.min ?? null,
      xg: s.expectedGoals ?? null,
      xgot: s.expectedGoalsOnTarget ?? null,
      type: s.eventType ?? '',
      situation: s.situation ?? '',
    }));
    const xg = shots.reduce(
      (acc, s) => {
        if (s.isHome === true) acc.home += s.xg ?? 0;
        else if (s.isHome === false) acc.away += s.xg ?? 0;
        return acc;
      },
      { home: 0, away: 0 },
    );
    const potmRaw = fm?.data.matchFacts?.playerOfTheMatch;

    const subTime = (p: FmLineupPlayer, type: string): number | null => {
      const e = p.performance?.substitutionEvents?.find((s) => s.type === type);
      return typeof e?.time === 'number' ? e.time : null;
    };
    const trimPlayer = (p: FmLineupPlayer) => ({
      name: p.name ?? '',
      num: p.shirtNumber != null ? String(p.shirtNumber) : null,
      x: typeof p.verticalLayout?.x === 'number' ? p.verticalLayout.x : null,
      y: typeof p.verticalLayout?.y === 'number' ? p.verticalLayout.y : null,
      rating: typeof p.performance?.rating === 'number' ? p.performance.rating : null,
      goals: p.performance?.events?.filter((e) => e?.type === 'goal').length ?? 0,
      subOut: subTime(p, 'subOut'),
      subIn: subTime(p, 'subIn'),
    });
    // Only a complete, fully-positioned XI is worth drawing as a pitch.
    const trimTeam = (tm?: FmLineupTeam) => {
      const starters = (tm?.starters ?? []).map(trimPlayer);
      if (starters.length !== 11 || starters.some((p) => p.x == null || p.y == null || !p.name)) return null;
      return {
        formation: tm?.formation ?? null,
        coach: tm?.coach?.name ?? null,
        starters,
        subs: (tm?.subs ?? []).map(trimPlayer),
      };
    };
    const lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
    const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);

    return {
      shots,
      xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null,
      momentum: fm?.data.momentum?.main?.data ?? [],
      lineup: lineupHome && lineupAway ? { home: lineupHome, away: lineupAway } : null,
      potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null,
      info: fifaInfo
        ? {
            attendance: fifaInfo.data.attendance,
            stadium: fifaInfo.data.stadium,
            referee: fifaInfo.data.officials?.find((o) => o.TypeLocalized?.[0]?.Description === 'Referee')?.Name?.[0]?.Description ?? null,
          }
        : null,
      weather: weather ? weather.data : null,
    };
  });

  // Golden Boot: per-player goals aggregated from structured ESPN scoring
  // plays across every started match (own goals + shootout kicks excluded).
  app.get('/api/goldenboot', async () => {
    const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
    for (const f of state.allFixtures()) {
      if (f.status === 'scheduled') continue;
      const ext = getMatchExt<EspnSummary>(f.num);
      if (!ext) continue;
      const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
      for (const e of ext.data.events) {
        if (!e.scoring || e.shootout || !e.scorer) continue;
        if (/own goal/i.test(e.type)) continue;
        const team = (e.teamId && teamName.get(e.teamId)) ?? '';
        const key = `${e.scorer}|${team}`;
        const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff };
        cur.goals += 1;
        if (f.kickoff > cur.latest) cur.latest = f.kickoff;
        tally.set(key, cur);
      }
    }
    const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25);
    return { players };
  });
  app.get('/api/scoreboard', async () => buildScoreboard(state));
  // The data lake, quantified — counters for the /data page.
  app.get('/api/data/stats', async () => {
    const safe = <T,>(fn: () => T): T | null => { try { return fn(); } catch { return null; } };
    return {
      lake: safe(() => loadStatic('lakestats.json')),
      archive: safe(() => {
        const r = loadStatic<{ matchesProcessed: number; asOf: string }>('ratings.json');
        const h = loadStatic<Record<string, unknown>>('h2h.json');
        return { results: r.matchesProcessed, asOf: r.asOf, h2hPairings: Object.keys(h).length };
      }),
      fingerprints: safe(() => Object.keys(loadStatic<{ teams: Record<string, unknown> }>('fingerprints.json').teams).length),
      squadValues: safe(() => Object.keys(loadStatic<{ teams: Record<string, unknown> }>('squadvalues.json').teams).length),
      db: dbCounts(),
      sources: healthAll(),
    };
  });
  app.get('/api/odds/:num', async (req, reply) => {
    const num = Number((req.params as { num: string }).num);
    if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
    return { history: oddsFor(num) };
  });
  app.get('/api/preview/:num', async (req, reply) => {
    const num = Number((req.params as { num: string }).num);
    const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null;
    return preview ?? reply.code(404).send({ error: 'no such fixture' });
  });
  app.get('/api/team/:name', async (req, reply) => {
    const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
    const profile = buildTeamProfile(name, state, model);
    return profile ?? reply.code(404).send({ error: 'no such team' });
  });
  app.get('/healthz', async () => ({ ok: true }));

  // ---- dev-only: inject a score to exercise the live → standings → WS loop ----
  if (!IS_PROD) {
    app.post('/api/dev/score', async (req, reply) => {
      const b = (req.body ?? {}) as {
        num?: number; homeScore?: number; awayScore?: number; status?: MatchStatus; minute?: number;
      };
      if (typeof b.num !== 'number') return reply.code(400).send({ error: 'num required' });
      const status: MatchStatus = b.status ?? 'finished';
      const f = state.applyScore(
        b.num,
        b.homeScore ?? null,
        b.awayScore ?? null,
        status,
        b.minute ?? null,
        'football-data',
      );
      if (!f) return reply.code(404).send({ error: 'no such fixture' });
      saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
      snapshotCheck(state, model);
      recordInplayTick();
      broadcast();
      return { ok: true, fixture: f };
    });
  }

  // ---- WebSocket: push the snapshot on connect and on every change ----
  void app.register(fastifyWebsocket);
  void app.register(async (instance) => {
    instance.get('/ws', { websocket: true }, (socket: WebSocket, req: FastifyRequest) => {
      const origin = req.headers.origin;
      if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
        socket.close(1008, 'origin');
        return;
      }
      if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
      clients.add(socket);
      try { socket.send(snapshotMessage()); socket.send(predictionsMessage()); } catch { /* closed */ }

      let alive = true;
      socket.on('pong', () => { alive = true; });
      const heartbeat = setInterval(() => {
        if (!alive) { try { socket.terminate(); } catch { /* gone */ } return; }
        alive = false;
        try { socket.ping(); } catch { /* closed */ }
      }, HEARTBEAT_MS);

      socket.on('close', () => { clearInterval(heartbeat); clients.delete(socket); });
      socket.on('error', () => { clearInterval(heartbeat); clients.delete(socket); });
    });
  });

  // ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ----
  const stopIngest = startScheduler(state, () => broadcast(), () => {
    const taken = snapshotCheck(state, model);
    if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
    try { recordInplayTick(); } catch { /* non-fatal */ }
  });
  app.addHook('onClose', async () => stopIngest());

  // ---- nightly Team-DC refit (backtest-validated): learn from this tournament ----
  const refitTimer = setInterval(() => {
    if (model.refitTeamDc(state.allFixtures())) broadcast();
  }, 24 * 60 * 60 * 1000);
  // The boot refit ran before ingestion restored finished matches — run once
  // more after the boot sweep settles so a restart keeps tournament context.
  const refitOnce = setTimeout(() => {
    if (model.refitTeamDc(state.allFixtures())) broadcast();
  }, 5 * 60_000);
  app.addHook('onClose', async () => { clearInterval(refitTimer); clearTimeout(refitOnce); });

  // ---- static SPA (prod) with history fallback + per-route OG meta ----
  if (existsSync(STATIC_DIR)) {
    void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });

    // Shared links unfurl with the model's numbers: swap <title> and inject
    // og: meta for /match/:num and /team/:name before serving the SPA shell.
    const indexHtml = readFileSync(`${STATIC_DIR}/index.html`, 'utf8');
    const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
    const withMeta = (title: string, desc: string): string =>
      indexHtml.replace(
        /<title>[^<]*<\/title>/,
        `<title>${esc(title)}</title>\n    <meta property="og:title" content="${esc(title)}" />\n    <meta property="og:description" content="${esc(desc)}" />\n    <meta name="description" content="${esc(desc)}" />`,
      );
    const ogFor = (url: string): string | null => {
      try {
        const mMatch = /^\/match\/(\d+)(?:\?|$)/.exec(url);
        if (mMatch) {
          const f = state.allFixtures().find((x) => x.num === Number(mMatch[1]));
          if (!f) return null;
          const pred = model.current().matches.find((m) => m.num === f.num);
          const score = f.homeScore != null && f.status !== 'scheduled' ? ` ${f.homeScore}–${f.awayScore}` : '';
          const odds = pred ? ` · Model: ${Math.round(pred.probs.home * 100)}/${Math.round(pred.probs.draw * 100)}/${Math.round(pred.probs.away * 100)}` : '';
          return withMeta(
            `${f.home.label}${score || ' vs'} ${f.away.label} — Cup26`,
            `World Cup 2026 · ${f.venue}${odds} (model probabilities, not betting advice)`,
          );
        }
        const mTeam = /^\/team\/([^/?]+)(?:\?|$)/.exec(url);
        if (mTeam) {
          const team = decodeURIComponent(mTeam[1]!);
          const odds = model.current().odds.find((o) => o.team === team);
          if (!odds) return null;
          return withMeta(
            `${team} — Cup26`,
            `World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
          );
        }
      } catch { /* fall through to the plain shell */ }
      return null;
    };

    app.setNotFoundHandler((req, reply) => {
      const url = req.raw.url ?? '/';
      if (url.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
      if (url.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
      const html = ogFor(url);
      if (html) return reply.type('text/html; charset=utf-8').send(html);
      return reply.sendFile('index.html');
    });
  }

  return app;
}

if (process.env.NODE_ENV !== 'test') {
  const app = buildServer();
  app.listen({ port: PORT, host: '0.0.0.0' })
    .then((addr) => console.log(`cup26 server on ${addr} (static: ${existsSync(STATIC_DIR) ? STATIC_DIR : 'dev — vite serves UI'})`))
    .catch((e) => { console.error(e); process.exit(1); });
}
