import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
} from 'react';
import StartGame from './game/main.ts';
import type * as Phaser from 'phaser';
import { eventBus } from './game/event-bus';
import { appStore } from './store';
import { installFirstInteractionGestureHandler } from './ui/begin';
import {
  openSaveDB,
  requestPersistence,
  wrap,
  unwrap,
  migrate,
  CURRENT_SCHEMA_VERSION,
  registerSaveLifecycleHooks,
  buildPayloadFromStore,
  hydrateStoreFromPayload,
  type V1Payload,
  type LifecycleHooksHandle,
} from './save';
import {
  wallClock,
  FakeClock,
  drainTicks,
  computeOfflineCatchup,
  type Clock,
} from './sim/scheduler';
import { simulateOneTick, type SimContext } from './sim/garden';
import type { SimState } from './sim/state';
import { fragments as allFragments } from './content';

/**
 * Plan 02-05 — boot path + clock selection + save lifecycle wiring.
 *
 * This component is the binding layer between React, Phaser, and the
 * save layer. It runs in two useLayoutEffect blocks:
 *
 *   1. Clock selection (URL flag or wallClock). Stores the chosen clock
 *      on `window.__tlgClock` so the Garden scene + Playwright e2e can
 *      both read it. ?devtime=fake activates FakeClock; production
 *      builds (import.meta.env.PROD) silently ignore the flag.
 *
 *   2. Boot path:
 *      - Read save record from IDB (or LocalStorage fallback).
 *      - If save exists: unwrap (CRC verify) → migrate (chain to current
 *        schema) → hydrate store → computeOfflineCatchup → drainTicks
 *        in silent mode → if cappedMs ≥ 5min, open the letter overlay
 *        with the accumulated offlineEvents block.
 *      - If no save: first-run init (rosemary unlocked).
 *      - requestPersistence() — set showPersistenceToast iff denied.
 *      - Start Phaser AFTER hydration so the Garden scene reads correct
 *        initial state.
 *      - registerSaveLifecycleHooks (UX-10) — visibilitychange + beforeunload.
 *
 * Per BLOCKER 1 (PLAN W2): the boot path runs unwrap THEN migrate.
 * Per BLOCKER 3: saveSync writes lastTickAt = clock.now() (wall-clock ms).
 * The sim never writes lastTickAt; it writes tickCount.
 *
 * Per W5: lifecycle.detach() is held in a ref so the OUTER useLayoutEffect
 * cleanup can call it — the async IIFE that registers the hooks cannot
 * return its own cleanup to the effect.
 */

const ABSENCE_LETTER_THRESHOLD_MS = 5 * 60 * 1000;

export interface IRefPhaserGame {
  game: Phaser.Game | null;
  scene: Phaser.Scene | null;
}

interface IProps {
  currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
}

/** Module-level type narrowing for the dev-time window slots. */
interface DevTimeWindow {
  __tlgClock?: Clock;
  __tlgFakeClock?: FakeClock;
  __tlgStore?: typeof appStore;
}

export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(
  props,
  ref,
) {
  const game = useRef<Phaser.Game | null>(null);
  const sceneRef = useRef<Phaser.Scene | null>(null);
  // W5 — lifecycle handle held in a ref so the OUTER cleanup can detach.
  const lifecycleRef = useRef<LifecycleHooksHandle | null>(null);

  // Clock selection. Runs once. ?devtime=fake activates FakeClock in
  // non-prod builds only. Production guard: import.meta.env.PROD.
  useLayoutEffect(() => {
    const isProd =
      typeof import.meta.env !== 'undefined' &&
      (import.meta.env as { PROD?: boolean }).PROD === true;
    const params = new URLSearchParams(window.location.search);
    const devtime = params.get('devtime');
    const useFake = !isProd && devtime === 'fake';
    const w = window as unknown as DevTimeWindow;
    if (useFake) {
      const fake = new FakeClock(Date.now());
      w.__tlgClock = fake;
      w.__tlgFakeClock = fake;
      // Plan 02-05 — expose the store on window for Playwright PIPE-07.
      // Production-guarded by the same isProd check; the e2e spec uses
      // `?devtime=fake` so this path only fires in dev/test builds.
      w.__tlgStore = appStore;
    } else {
      w.__tlgClock = wallClock;
    }
  }, []);

  // Boot path. Runs once. Reads save → migrate → catch up offline →
  // maybe open letter → start Phaser → register lifecycle hooks.
  useLayoutEffect(() => {
    if (game.current !== null) return;
    let cancelled = false;

    void (async () => {
      const w = window as unknown as DevTimeWindow;
      const clock: Clock = w.__tlgClock ?? wallClock;
      const nowMs = clock.now();
      let dbRef: Awaited<ReturnType<typeof openSaveDB>> | null = null;

      try {
        const db = await openSaveDB();
        if (cancelled) return;
        dbRef = db;
        const record = await db.get('saves', 'main');

        if (record) {
          // Returning player path. BLOCKER 1 — unwrap (CRC verify) first,
          // then migrate to bring the payload up to CURRENT_SCHEMA_VERSION.
          const env = record.envelope;
          let payload: V1Payload;
          try {
            const raw = unwrap(env);
            const result = migrate(raw, env.schemaVersion);
            payload = result.payload as V1Payload;
          } catch (err) {
            console.error(
              '[boot] save unwrap/migrate failed; starting fresh',
              err,
            );
            // Fall through to first-run init below.
            initFirstRun();
            await postBootRequestPersistence();
            startPhaserAndRegisterHooks(db, clock);
            return;
          }

          // D-22 — returning player skips Begin gate.
          appStore.getState().dismissBeginGate();
          hydrateStoreFromPayload(appStore.getState(), payload);

          // Offline catchup (CONTEXT D-10 + D-11; CORE-03 + CORE-11).
          const off = computeOfflineCatchup(payload.lastTickAt, nowMs);
          if (off.willRunCatchup) {
            const ctx: SimContext = {
              fragments: allFragments,
              currentSeason: 1,
              silent: true,
            };
            // V1Payload is structurally a SimState (state.ts mirrors
            // migrations.ts) — pass it through with a starting tickCount.
            let runningTick = payload.tickCount ?? 0;
            const seedSimState = payload as unknown as SimState;
            const result = drainTicks(
              seedSimState,
              off.cappedMs,
              (state, _dtMs, silent) => {
                runningTick += 1;
                return simulateOneTick(state, runningTick, [], {
                  ...ctx,
                  silent,
                });
              },
              true,
            );
            const finalState = result.state;
            // Push the post-catchup state back into the store.
            const postPayload: V1Payload = {
              ...payload,
              garden: finalState.garden,
              harvestedFragmentIds: finalState.harvestedFragmentIds,
              tickCount: finalState.tickCount,
              unlockedPlantTypes: finalState.unlockedPlantTypes,
              luraBeatProgress: finalState.luraBeatProgress,
              lastTickAt: nowMs, // wall-clock anchor at boot
              offlineEvents:
                (finalState.offlineEvents as V1Payload['offlineEvents']) ??
                null,
            };
            hydrateStoreFromPayload(appStore.getState(), postPayload);

            // D-20 — open letter when absence ≥5min.
            if (
              off.cappedMs >= ABSENCE_LETTER_THRESHOLD_MS &&
              postPayload.offlineEvents
            ) {
              appStore
                .getState()
                .openLetter(postPayload.offlineEvents);
            }
          }
        } else {
          // First-run path.
          initFirstRun();
        }

        await postBootRequestPersistence();
        startPhaserAndRegisterHooks(db, clock);
      } catch (err) {
        console.error('[boot] save load failed; starting fresh', err);
        initFirstRun();
        // Best-effort: try to register hooks against whatever DB we
        // managed to open. If openSaveDB itself failed, dbRef is null and
        // saveSync's IDB write becomes a no-op (LocalStorage path still
        // fires below).
        if (dbRef) startPhaserAndRegisterHooks(dbRef, clock);
        else startPhaserAndRegisterHooksWithoutDb(clock);
      }
    })();

    function initFirstRun(): void {
      if (appStore.getState().unlockedPlantTypes.length === 0) {
        appStore.setState({ unlockedPlantTypes: ['rosemary'] });
      }
    }

    async function postBootRequestPersistence(): Promise<void> {
      try {
        const result = await requestPersistence();
        if (cancelled) return;
        // D-30 — show toast iff denied AND not previously shown.
        if (
          result.apiAvailable &&
          !result.granted &&
          !appStore.getState().persistenceToastShown
        ) {
          appStore.getState().setShowPersistenceToast(true);
        }
      } catch (err) {
        console.warn('[boot] requestPersistence failed', err);
      }
    }

    function startPhaserAndRegisterHooks(
      db: Awaited<ReturnType<typeof openSaveDB>>,
      clock: Clock,
    ): void {
      if (cancelled) return;
      // Start Phaser AFTER state hydration so the Garden scene's create()
      // reads the correct initial tickCount + tiles.
      game.current = StartGame('game-container');
      if (typeof ref === 'function') {
        ref({ game: game.current, scene: null });
      } else if (ref) {
        ref.current = { game: game.current, scene: null };
      }

      // UX-10 — register lifecycle hooks (visibilitychange + beforeunload).
      // saveSync MUST be synchronous (Pitfall 7) — beforeunload won't await.
      // Synchronous LocalStorage write fires unconditionally; IDB best-
      // effort (the put() promise resolves out of band but the LS write
      // already captured the state).
      lifecycleRef.current = registerSaveLifecycleHooks({
        saveSync: () => {
          try {
            const state = appStore.getState();
            // BLOCKER 3 — saveSync writes lastTickAt = clock.now()
            // (wall-clock ms via the injected clock). The sim NEVER
            // writes lastTickAt; this is the canonical write site.
            const payload: V1Payload = buildPayloadFromStore(
              state,
              clock.now(),
            );
            const env = wrap(payload, CURRENT_SCHEMA_VERSION);
            // Synchronous LocalStorage path (Pitfall 7 — no await).
            try {
              localStorage.setItem('tlg.saves.main', JSON.stringify({
                id: 'main',
                envelope: env,
                savedAt: new Date().toISOString(),
              }));
            } catch {
              /* localStorage may be unavailable in private mode */
            }
            // Best-effort IDB write. Promise fires out of band; the LS
            // write above captured the state synchronously.
            void db.put('saves', {
              id: 'main',
              envelope: env,
              savedAt: new Date().toISOString(),
            });
          } catch (e) {
            console.warn('[saveSync] failed', e);
          }
        },
      });
    }

    function startPhaserAndRegisterHooksWithoutDb(clock: Clock): void {
      if (cancelled) return;
      game.current = StartGame('game-container');
      if (typeof ref === 'function') {
        ref({ game: game.current, scene: null });
      } else if (ref) {
        ref.current = { game: game.current, scene: null };
      }
      // Degenerate path — no DB available. saveSync still writes to
      // LocalStorage so the player can recover via Settings → Import.
      lifecycleRef.current = registerSaveLifecycleHooks({
        saveSync: () => {
          try {
            const state = appStore.getState();
            const payload: V1Payload = buildPayloadFromStore(
              state,
              clock.now(),
            );
            const env = wrap(payload, CURRENT_SCHEMA_VERSION);
            try {
              localStorage.setItem('tlg.saves.main', JSON.stringify({
                id: 'main',
                envelope: env,
                savedAt: new Date().toISOString(),
              }));
            } catch {
              /* private mode; nothing to do */
            }
          } catch (e) {
            console.warn('[saveSync] failed (no-DB path)', e);
          }
        },
      });
    }

    return () => {
      cancelled = true;
      lifecycleRef.current?.detach();
      lifecycleRef.current = null;
      if (game.current) {
        game.current.destroy(true);
        game.current = null;
      }
    };
  }, [ref]);

  useEffect(() => {
    const onSceneReady = (scene: Phaser.Scene): void => {
      sceneRef.current = scene;
      props.currentActiveScene?.(scene);
    };
    eventBus.on('scene-ready', onSceneReady);

    // Install the first-interaction gesture handler unconditionally —
    // it is a one-shot that bootstraps audio on the first click /
    // touch / keypress whether the Begin screen handled it or not.
    installFirstInteractionGestureHandler();

    return () => {
      eventBus.off('scene-ready', onSceneReady);
    };
  }, [props]);

  useImperativeHandle(ref, () => ({
    game: game.current,
    scene: sceneRef.current,
  }));

  return <div id="game-container" />;
});
