// GameProvider — React context holding the latest RenderState + request lifecycle.
// The frontend is a PURE renderer: every field here comes from the server's RenderState.
// THE SEAMS the HUD phase plugs into are the context fields listed in useGame() below.

import React, {
  createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
} from 'react';
import { turn as apiTurn, gameModel, announce } from './api.js';
import { audio } from './audio.js';

const GameContext = createContext(null);

let toastSeq = 0;

export function GameProvider({ children }) {
  const [state, setState] = useState(null);          // latest RenderState (authoritative)
  const [thinking, setThinking] = useState(false);   // a /turn POST is in flight
  const [toasts, setToasts] = useState([]);          // [{id, text, kind}] 409s + errors
  const [armedCard, setArmedCard] = useState(null);  // chaos card armed for station targeting
  const [cardsPlayed, setCardsPlayed] = useState(0); // staged this round (UI-side cap mirror)
  const [model, setModelState] = useState({ on: true, model: null, models: [], tts: false });
  const [aiLog, setAiLog] = useState([]);            // dispatcher history, newest first
  const [started, setStarted] = useState(false);     // START splash dismissed?
  const [peakSplash, setPeakSplash] = useState(false); // PEAK RUSH announcement overlay showing?
  // Voice-announcement mute (separate from the global game mute): when true the dispatcher's
  // VoxCPM TTS is NOT requested at all — the @spaces.GPU call never fires, so it costs no quota.
  const [announceMuted, setAnnounceMuted] = useState(() => {
    try { return localStorage.getItem('mlp_announce_muted') === '1'; } catch { return false; }
  });

  // refs so stable callbacks (used by the non-React Pixi scene) always see fresh values
  const stateRef = useRef(null);
  const armedRef = useRef(null);
  const thinkingRef = useRef(false);
  const cardsPlayedRef = useRef(0);
  const announceMutedRef = useRef(false);
  const modelRef = useRef(null);
  stateRef.current = state;
  armedRef.current = armedCard;
  thinkingRef.current = thinking;
  cardsPlayedRef.current = cardsPlayed;
  announceMutedRef.current = announceMuted;
  modelRef.current = model;

  const pushToast = useCallback((text, kind = 'error') => {
    const id = ++toastSeq;
    setToasts((t) => [...t, { id, text, kind }]);
    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
  }, []);

  const applyState = useCallback((next) => {
    const prev = stateRef.current;
    if (!prev || next.round !== prev.round || (prev.over && !next.over)) {
      setCardsPlayed(0); // new round (or new game) resets the per-round card stage count
    }
    // AI log history: one entry per dispatcher turn (state.ai is "the last turn").
    const ai = next.ai || {};
    const acted = ai.noop || ai.priority || (ai.actions || []).length > 0;
    if (acted && (!prev || next.round !== prev.round)) {
      setAiLog((log) => [{ round: next.round, ...ai }, ...log].slice(0, 60));
    }
    setState(next);
  }, []);

  const doTurn = useCallback(async (body) => {
    setThinking(true);
    try {
      const next = await apiTurn(body);
      applyState(next);
      return next;
    } catch (err) {
      pushToast(err.message || 'request failed');
      return null;
    } finally {
      setThinking(false);
    }
  }, [applyState, pushToast]);

  const newGame = useCallback((seed = null) => {
    setArmedCard(null);
    setCardsPlayed(0);
    setAiLog([]);
    setPeakSplash(false);
    return doTurn({ action: 'new_game', seed });
  }, [doTurn]);

  const dismissPeakSplash = useCallback(() => setPeakSplash(false), []);

  /** Toggle the dispatcher voice announcements. Muted => the TTS request is never made, so the
   *  VoxCPM @spaces.GPU call never fires and the player spends no GPU quota on audio. */
  const toggleAnnounceMute = useCallback(() => {
    setAnnounceMuted((m) => {
      const next = !m;
      try { localStorage.setItem('mlp_announce_muted', next ? '1' : '0'); } catch { /* private mode */ }
      return next;
    });
  }, []);

  /** START splash dismissal. Reuses the booted game unless a seed was requested. */
  const start = useCallback((seed = null) => {
    setStarted(true);
    audio.playSfx('click');
    if (seed != null || !stateRef.current) return newGame(seed);
    return Promise.resolve(stateRef.current);
  }, [newGame]);

  const nextTurn = useCallback(() => {
    if (thinkingRef.current) return Promise.resolve(null);
    setArmedCard(null);
    return doTurn({ action: 'next_turn' });
  }, [doTurn]);

  /** Cards-per-round cap: always 1 (peak adds 2 trains for the AI instead of a 2nd card). */
  const cardCap = 1;

  const playCard = useCallback(async (card, location) => {
    if (thinkingRef.current) return null;
    const cap = 1;
    if (cardsPlayedRef.current >= cap) {
      pushToast('card cap reached (1/round)', 'warn');
      return null;
    }
    setThinking(true);
    try {
      // 1) stage the card — the server validates (available / station free / energy / cap) and
      //    returns 409 on reject. play_card does NOT advance the sim; it only stages chaos.
      const staged = await apiTurn({ action: 'play_card', card, location });
      const played = cardsPlayedRef.current + 1;
      setCardsPlayed(played);
      setArmedCard(null); // success: disarm
      audio.playSfx('card_play');
      // 2) a successful card play IS the player's move for the round. Once they've used their
      //    allotment (1 normally, 2 in peak), run the turn automatically — no Next Turn click.
      //    In peak the FIRST of two cards still waits, so they can play a 2nd (or press Next Turn).
      //    thinking stays true across both POSTs, so the HUD shows "dispatcher thinking…".
      if (played >= cap) {
        const advanced = await apiTurn({ action: 'next_turn' });
        applyState(advanced);
        return advanced;
      }
      applyState(staged); // peak, first card: reflect the staged state, keep waiting for the 2nd
      return staged;
    } catch (err) {
      // 409 = rules engine rejected it (station busy / unaffordable / unavailable) — toast and
      // keep the card armed so the player can retarget another station. No turn advances.
      pushToast(err.message || 'card rejected');
      return null;
    } finally {
      setThinking(false);
    }
  }, [applyState, pushToast]);

  const armCard = useCallback((card) => {
    setArmedCard((cur) => {
      const next = cur === card ? null : card; // click again to disarm
      if (next) audio.playSfx('click');
      return next;
    });
  }, []);

  /** Canvas -> React: a station was tapped. Armed card targets it; otherwise station SFX hook. */
  const handleStationTap = useCallback((stationName) => {
    if (armedRef.current) {
      playCard(armedRef.current, stationName);
    } else {
      audio.playStationClip(stationName); // unarmed click = rotating station clip (audio seam)
    }
  }, [playCard]);

  const setModel = useCallback(async ({ on, model: m }) => {
    try {
      const body = {};
      if (on !== undefined) body.on = on;
      if (m !== undefined) body.model = m;
      const res = await gameModel(body);
      setModelState({ on: res.on, model: res.model, models: res.models || [], tts: !!res.tts });
      return res;
    } catch (err) {
      pushToast(err.message || 'model switch failed');
      return null;
    }
  }, [pushToast]);

  // boot: start a game (and learn the model config) on first mount
  useEffect(() => {
    newGame(null);
    setModel({}).catch(() => {});
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ---- audio reactions (the audio.js seam — never touches the canvas) ----
  const audioPrevRef = useRef(null); // { phase, over, notifSig }
  useEffect(() => {
    if (!state) return;
    const prev = audioPrevRef.current;
    const notifSig = `${state.round}|${(state.notifications || [])
      .map((n) => `${n.kind}:${n.text}`).join(';')}`;
    audioPrevRef.current = { phase: state.phase, over: state.over, notifSig, round: state.round };

    // dispatcher VOICE announcement (VoxCPM2 on the Space) — fire once per NEW round, only when the
    // server has TTS enabled AND the player hasn't muted announcements. The /announce call IS the
    // GPU trigger, so a muted player never hits the @spaces.GPU endpoint (spends no quota). Async:
    // it never blocks the turn — the clip just plays when it's ready.
    const ann = (state.ai?.announcement || '').trim();
    if (ann && (!prev || prev.round !== state.round)
        && modelRef.current?.tts && !announceMutedRef.current) {
      announce(state.round)
        .then((res) => { if (res?.audio) audio.playAnnouncementBase64(res.audio); })
        .catch(() => { /* voice is best-effort ambience */ });
    }

    // monsoon bed follows visual.rain (ducks louder during peak)
    audio.setRain(!!state.visual?.rain, state.phase === 'peak');
    // peak entry: sting + the one-time PEAK RUSH announcement overlay
    if (prev && prev.phase !== 'peak' && state.phase === 'peak') {
      audio.playSfx('peak_sting');
      setPeakSplash(true);
    }
    // win/loss sting — player-perspective: you (chaos) WIN when the line goes down (!state.won)
    if ((!prev || !prev.over) && state.over) audio.playSfx(state.won ? 'loss' : 'win');
    // station-tied notifications -> that station's rotating clip (staggered, capped)
    if (!prev || prev.notifSig !== notifSig) {
      const stations = [...new Set(
        (state.notifications || []).map((n) => n.station).filter(Boolean),
      )].slice(0, 3);
      stations.forEach((s, i) => setTimeout(() => audio.playStationClip(s), 250 + i * 400));
    }
  }, [state]);

  const value = useMemo(() => ({
    // ---- HUD SEAMS (stable contract for the HUD phase) ----
    state,            // latest RenderState (null until first new_game returns)
    thinking,         // true while a /turn POST is in flight ("dispatcher thinking…")
    toasts,           // [{id, text, kind}] auto-expiring queue (409s land here)
    pushToast,        // (text, kind?) -> add a toast
    armedCard,        // chaos card id currently armed, or null
    armCard,          // (card) toggle-arm a card
    cardsPlayed,      // cards staged this round (UI cap mirror)
    cardCap,          // 2 in peak else 1
    newGame,          // (seed?) -> POST new_game
    nextTurn,         // () -> POST next_turn
    playCard,         // (card, location) -> POST play_card
    handleStationTap, // canvas station-click entry (armed -> play, unarmed -> station SFX)
    model,            // {on, model, models[]} dispatcher config
    setModel,         // ({on?, model?}) -> POST /model
    audio,            // the audio bus (mute toggle etc.)
    // ---- HUD-phase additions ----
    aiLog,            // [{round, priority, actions[], announcement, noop, confidence}] newest first
    started,          // START splash dismissed?
    start,            // (seed?) dismiss splash (newGame if seed given / no boot state)
    peakSplash,       // PEAK RUSH announcement overlay showing?
    dismissPeakSplash,// () -> close the PEAK RUSH overlay
    announceMuted,    // voice announcements muted? (gates the VoxCPM TTS request)
    toggleAnnounceMute,// () -> flip announcement mute (persisted)
  }), [state, thinking, toasts, pushToast, armedCard, armCard, cardsPlayed, cardCap,
       newGame, nextTurn, playCard, handleStationTap, model, setModel, aiLog, started, start,
       peakSplash, dismissPeakSplash, announceMuted, toggleAnnounceMute]);

  return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
}

export function useGame() {
  const ctx = useContext(GameContext);
  if (!ctx) throw new Error('useGame must be used inside <GameProvider>');
  return ctx;
}
