/**
 * LANE-SCREEN — THE SCREEN compositor.
 *
 * The sky-display everything ships to. Starts sterile, ends deranged. Shipped items
 * feed a corruption ledger (corruptionMap.ts) whose blend drives the 8 tuned glitch
 * params of the ported GLYTCH shader stack (glitchStack.ts).
 *
 * Design contract with the player: WHAT you ship decides WHICH artifact; HOW MUCH you
 * have ever shipped decides the intensity. Shipments arrive as waves, not switches —
 * every param eases toward its target.
 */
import type { GameData, ItemDef, ScreenFX, SimEvent, SimSnapshot } from '../contracts';
import { createBaseFeed } from './baseTexture';
import { createGlitchStack, type GlitchStack } from './glitchStack';
import { createOverlays } from './overlays';
import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
import { computeStrain } from './strain';
import { freshness } from './boredom';
import { ERA_ORDER, highestEra, type Era } from './eras';
import { getSubtitle } from '../audio/subtitleBus';

/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2;
/** Brownouts hit fast and lift fast — the factory's pain should be legible immediately. */
const BROWNOUT_TAU = 0.12;
/** Above this total corruption, params start cross-modulating so a mature SCREEN looks alive. */
const WOBBLE_THRESHOLD = 0.8;
const WOBBLE_DEPTH = 0.12;
/** Distinct slow rates per param so the wobble never reads as one global pulse. */
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, 0.05];
/** Seconds a shipment stamp stays on the feed. */
const STAMP_LIFE = 0.4;
/** The repair placard lingers a touch longer than a shipment stamp — a signature, read at leisure. */
const REPAIR_LIFE = 0.55;
/** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */
const STAMP_QUEUE_MAX = 2;
/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */
const SCRAM_FRAMES = 3;
/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */
const STRAIN_TAU = 0.35;
const FEVER_TAU = 1.5;
/** Pallor is chronic, so it should drift rather than react. */
const PALLOR_TAU = 2.0;
/**
 * The ratification card is timed in SECONDS, not frames: it's a broadcast interruption, so it
 * must last one second whatever the refresh rate. (The scram wipe is frame-counted on purpose —
 * that one is a tape artifact, and tape artifacts are quantized to frames.)
 */
const CARD_SECONDS = 1;
/** Boot title: hold the burn-in, then dissolve to the NOTHING IS WRONG feed. */
const TITLE_HOLD = 1.5;
const TITLE_FADE = 0.6;
/** Round 5: the awakening is no longer one frame but a ~0.5s full-canvas moment. */
const FLASH_SECONDS = 0.5;

interface Stamp { name: string; count: number; color: string; age: number }
interface Repair { name: string; age: number }
interface Swarm { pos: { x: number; y: number }; size: number }

/**
 * Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given
 * run always grows the same maze, while different runs grow it somewhere else. Kept off the
 * edges so the growth reads as spreading rather than creeping in from off-screen.
 */
function seedFromTick(tick: number): [number, number] {
  const h = (n: number) => {
    const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
    return x - Math.floor(x);
  };
  return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5];
}

/** Swarms live on the factory floor; hash their world position to a stable spot on the sky. */
function seedFromPos(pos: { x: number; y: number }): [number, number] {
  const h = (n: number) => {
    const x = Math.sin(n) * 43758.5453;
    return x - Math.floor(x);
  };
  return [0.2 + h(pos.x * 12.9898 + pos.y * 78.233) * 0.6, 0.2 + h(pos.x * 39.3468 + pos.y * 11.135) * 0.6];
}

const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
function smoothstep(e0: number, e1: number, x: number): number {
  const t = clamp01((x - e0) / (e1 - e0));
  return t * t * (3 - 2 * t);
}

/** Boot title: hold at full, then dissolve. Returns 0 once the sequence is over. */
function titleOpacity(age: number): number {
  if (age <= TITLE_HOLD) return 1;
  return 1 - smoothstep(TITLE_HOLD, TITLE_HOLD + TITLE_FADE, age);
}

/**
 * The awakening. A blown-out hold then a fast decay — the sky is stunned for a moment rather
 * than blinking once. Returns 0 when not flashing (age < 0).
 */
function flashEnvelope(age: number): number {
  if (age < 0) return 0;
  if (age >= FLASH_SECONDS) return 0;
  if (age < 0.12) return 1;
  return 1 - smoothstep(0.12, FLASH_SECONDS, age);
}

export function createScreenFX(): ScreenFX {
  let stack: GlitchStack | null = null;
  const overlays = createOverlays();
  const items = new Map<string, ItemDef>();
  const tally = new Map<string, number>();
  const eased: ParamSet = zeroParams();
  const out: ParamSet = zeroParams();

  let targets: ParamSet = zeroParams();
  let total = 0;
  let units = 0;
  let ledgerDirty = false;

  let brownout = 0;
  let brownoutTarget = 0;
  let hasShipped = false;

  const stampQueue: Stamp[] = [];
  let stamp: Stamp | null = null;
  let stampDirty = false;
  const repairQueue: Repair[] = [];
  let repair: Repair | null = null;
  let repairDirty = false;
  let scramFrames = 0;
  let scramY = 0.5;

  let strain = 0;
  let fever = 0;
  let pallor = 0;
  let moireSeed: [number, number] = [0.5, 0.44];
  let moireSeeded = false;

  let lastShipMs = 0;
  let markShip = false;
  let fresh = 1;

  let cardAge = -1; // <0 = no card on screen
  let cardDirty = false;
  const eras = new Map<string, Era>();

  // Part A round 5:
  let era: Era = ERA_ORDER[0]; // the world starts in the deepest stratum (reel beds)
  let forcedEra: Era | null = null; // debug hook override
  let wildlife = 0; // eased regional-shimmer amount
  let wildlifeSeed: [number, number] = [0.5, 0.5];
  const swarms = new Map<number, Swarm>(); // event-tracked fallback when no snapshot
  let mite = 0; // eased hospital-white sterile creep amount
  let miteSeed: [number, number] = [0.5, 0.5];
  const mites = new Map<number, Swarm>(); // event-tracked parity-mite fallback
  let titleAge = 0; // boot title sequence clock
  let titleDirty = true; // painted once at stack creation; upload on the first frame
  let flashAge = -1; // <0 = not flashing

  let lastMs = 0;
  let frameCost = 0; // rolling avg ms of this module's CPU work

  function recompute() {
    const r = resolveLedger(tally);
    targets = r.targets;
    total = r.total;
    units = r.units;
    ledgerDirty = false;
  }

  function ship(item: string, count: number, tick = 0) {
    if (count <= 0) return;
    tally.set(item, (tally.get(item) ?? 0) + count);
    ledgerDirty = true;
    markShip = true; // the sky stops forgetting the moment anything lands
    if (!hasShipped) {
      hasShipped = true;
      flashAge = 0; // the awakening — a ~0.5s moment, ramped in frame()
    }
    // The crystal takes root where and when it first arrived, and grows from there.
    if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) {
      moireSeed = seedFromTick(tick);
      moireSeeded = true;
    }
    // One stamp per EVENT, not per unit: a 40-unit shipment is one big stamp.
    const def = items.get(item);
    stampQueue.push({
      name: def?.name ?? item.toUpperCase(),
      count,
      color: def?.color ?? '#d8ffd8',
      age: 0,
    });
    while (stampQueue.length > STAMP_QUEUE_MAX) stampQueue.shift();
  }

  let liveCanvas: HTMLCanvasElement | null = null;

  const api: ScreenFX = {
    init(canvas: HTMLCanvasElement, data: GameData) {
      liveCanvas = canvas;
      const base = createBaseFeed();
      stack = createGlitchStack(canvas, base, overlays);
      for (const it of data.items) items.set(it.id, it);
      for (const t of data.tech) eras.set(t.id, t.era);

      const unmapped = unmappedItems(data);
      if (unmapped.length) {
        console.info(
          `[screen] ${unmapped.length} item(s) have no corruption profile, using generic noise: ${unmapped.join(', ')}`,
        );
      }

      // Debug/authoring hook — dev-only (round 2 orders), stripped from prod builds.
      // Console: __fktryScreen.ship('macroblock-bricks', 40)
      if (!import.meta.env.DEV) return;
      (window as any).__fktryScreen = {
        ship,
        brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
        scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; },
        setEra: (e: Era) => { forcedEra = e; },
        skipTitle: () => { titleAge = TITLE_HOLD + TITLE_FADE + 1; },
        swarm: (on: boolean, id = 1, pos = { x: 3, y: -2 }) => {
          if (on) swarms.set(id, { pos, size: 0.7 });
          else swarms.delete(id);
        },
        mite: (on: boolean, id = 1, pos = { x: -4, y: 5 }, size = 0.7) => {
          if (on) mites.set(id, { pos, size });
          else mites.delete(id);
        },
        repair: (item: string, count = 1) => {
          for (let i = 0; i < count; i++) {
            const cur = tally.get(item) ?? 0;
            if (cur > 0) { tally.set(item, cur - 1); ledgerDirty = true; }
            const def = items.get(item);
            repairQueue.push({ name: def?.name ?? item.toUpperCase(), age: 0 });
            while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
          }
        },
        reset: () => {
          tally.clear();
          ledgerDirty = true;
          hasShipped = false;
          stamp = null;
          stampQueue.length = 0;
          moireSeeded = false;
          moireSeed = [0.5, 0.44];
          strain = 0;
          fever = 0;
          pallor = 0;
          cardAge = -1;
          flashAge = -1;
          lastShipMs = 0;
          fresh = 1;
          forcedEra = null;
          swarms.clear();
          wildlife = 0;
          mites.clear();
          mite = 0;
          repair = null;
          repairQueue.length = 0;
          for (const p of PARAMS) eased[p] = 0;
        },
        research: (tech: string) => {
          overlays.paintCard(eras.get(tech) ?? 'reel', tech);
          cardDirty = true;
          cardAge = 0;
        },
        stats: () => ({
          units: +units.toFixed(2),
          total: +total.toFixed(3),
          displayTotal: +(total * fresh).toFixed(3),
          freshness: +fresh.toFixed(3),
          idle: !hasShipped,
          era,
          strain: +strain.toFixed(3),
          fever: +fever.toFixed(3),
          pallor: +pallor.toFixed(3),
          wildlife: +wildlife.toFixed(3),
          mite: +mite.toFixed(3),
          repair: repair ? 1 : 0,
          card: cardAge >= 0 ? 1 : 0,
          title: +titleOpacity(titleAge).toFixed(3),
          flash: +flashEnvelope(flashAge).toFixed(3),
          moireSeed: moireSeed.map((n) => +n.toFixed(3)),
          brownout: +brownout.toFixed(2),
          msPerFrame: +frameCost.toFixed(3),
          tally: Object.fromEntries(tally),
          params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
        }),
        escalationItems: ESCALATION_ITEMS,
        // Verification driver: a hidden harness tab throttles rAF, so pump the REAL module with
        // a synthetic timebase and read pixels in the same JS task (prior-round contact-sheet
        // trick — a hidden tab clears the WebGL buffer after compositing otherwise).
        frame: (ms: number, snap?: SimSnapshot) => api.frame(ms, snap),
        canvas: () => liveCanvas,
      };
    },

    onEvents(events: SimEvent[]) {
      for (const e of events) {
        if (e.kind === 'shipped') ship(e.item, e.count, e.tick);
        else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
        else if (e.kind === 'scram' && e.on) {
          // A machine scrammed: the tape loses tracking. Bar position is derived from the
          // event rather than random, so the same run always wipes the same way.
          scramFrames = SCRAM_FRAMES;
          scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7;
        } else if (e.kind === 'researched') {
          // The Correction takes the sky for a second, every time the player does science.
          overlays.paintCard(eras.get(e.tech) ?? 'reel', e.tech);
          cardDirty = true;
          cardAge = 0;
        } else if (e.kind === 'wildlife' && e.wildlife === 'mosquito-swarm') {
          // Event-tracked swarms are the fallback when no snapshot.wildlife is available
          // (debug hook). When a snapshot IS present, frame() reads it as the source of truth.
          if (e.on === 'spawned') swarms.set(e.id, { pos: e.pos, size: 0.6 });
          else swarms.delete(e.id);
        } else if (e.kind === 'wildlife' && e.wildlife === 'parity-mite') {
          // Parity-mites drive the sterile creep. Same fallback contract as the swarms above:
          // snapshot.wildlife is truth when present, these events cover the no-snapshot case.
          if (e.on === 'spawned') mites.set(e.id, { pos: e.pos, size: 0.6 });
          else mites.delete(e.id);
        } else if (e.kind === 'repaired') {
          // The Correction corrected one unit of your work. This is THEFT, not decay: ease the
          // LEDGER down by exactly that item's per-unit contribution (a real, permanent removal —
          // unlike boredom, which only fades the display and leaves the tally intact). Then let
          // it sign the job with a small green-tick placard. We deliberately do NOT refresh the
          // boredom clock: a factory being quietly eaten is not a factory that is producing.
          const cur = tally.get(e.item) ?? 0;
          if (cur > 0) {
            tally.set(e.item, cur - 1);
            ledgerDirty = true;
          }
          const def = items.get(e.item);
          repairQueue.push({ name: def?.name ?? e.item.toUpperCase(), age: 0 });
          while (repairQueue.length > STAMP_QUEUE_MAX) repairQueue.shift();
        }
      }
    },

    frame(timeMs: number, snap?: SimSnapshot) {
      if (!stack) return;
      const t0 = performance.now();
      // Clamp BOTH ends. A negative dt (clock rewind, a paused tab resuming oddly, a test
      // harness restarting its timebase) turns ease()'s 1-exp(-dt/tau) into a huge negative
      // multiplier and throws every eased value into orbit. The ceiling stops long stalls
      // from snapping params; the floor stops that.
      const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000));
      lastMs = timeMs;

      if (ledgerDirty) recompute();
      brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);

      // v3: read-only peek. Collapsed to two scalars immediately — the snapshot is never
      // retained across frames, per the contract invariant.
      const target = computeStrain(snap);
      strain = ease(strain, target.tremor, dt, STRAIN_TAU);
      fever = ease(fever, target.fever, dt, FEVER_TAU);
      pallor = ease(pallor, target.pallor, dt, PALLOR_TAU);

      // The substrate ages with the highest ratified era. Corruption stays era-agnostic —
      // only what it is painted ONTO changes.
      era = forcedEra ?? (snap?.research ? highestEra(snap.research.unlocked, (t) => eras.get(t)) : era);

      // Jammed-rot: a factory stuck solid rots faster than one resting. Ratio of jammed
      // machines, so a single stuck belt in a big base barely registers.
      let rot = 0;
      if (snap && snap.entities.length) {
        let jammed = 0;
        for (const e of snap.entities) if (e.jammed) jammed++;
        rot = jammed / snap.entities.length;
      }

      // Boredom: the DISPLAY decays, the ledger does not. `fresh` scales what's shown; the
      // tally underneath is untouched, so resuming production snaps the targets straight back
      // and the easing walks the picture home in about a second.
      if (markShip) {
        lastShipMs = timeMs;
        markShip = false;
      }
      fresh = hasShipped ? freshness((timeMs - lastShipMs) / 1000, rot) : 1;

      // Wildlife shimmer: drive the region from the WORST swarm — one legible infestation
      // beats an averaged smear. Snapshot is truth when present; events are the fallback.
      let worst: Swarm | null = null;
      const live = snap?.wildlife;
      if (live) {
        for (const w of live) {
          if (w.kind !== 'mosquito-swarm') continue;
          if (!worst || w.size > worst.size) worst = { pos: w.pos, size: w.size };
        }
      } else {
        for (const w of swarms.values()) if (!worst || w.size > worst.size) worst = w;
      }
      if (worst) wildlifeSeed = seedFromPos(worst.pos);
      wildlife = ease(wildlife, worst ? clamp01(worst.size) : 0, dt, 1.0);

      // Mite sterility: same worst-of pattern, but for parity-mites. The sterile creep grows
      // around the heaviest infestation; snapshot is truth when present, events are the fallback.
      let worstMite: Swarm | null = null;
      if (live) {
        for (const w of live) {
          if (w.kind !== 'parity-mite') continue;
          if (!worstMite || w.size > worstMite.size) worstMite = { pos: w.pos, size: w.size };
        }
      } else {
        for (const m of mites.values()) if (!worstMite || m.size > worstMite.size) worstMite = m;
      }
      if (worstMite) miteSeed = seedFromPos(worstMite.pos);
      mite = ease(mite, worstMite ? clamp01(worstMite.size) : 0, dt, 1.0);

      const timeSec = timeMs / 1000;
      const displayTotal = total * fresh;
      const wobble =
        displayTotal > WOBBLE_THRESHOLD
          ? (displayTotal - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD)
          : 0;

      for (let i = 0; i < PARAMS.length; i++) {
        const p = PARAMS[i];
        eased[p] = ease(eased[p], targets[p] * fresh, dt, EASE_TAU);
        // Wobble rides on top of the eased value; it never feeds back into the ledger.
        const w =
          wobble > 0
            ? Math.sin(timeSec * WOBBLE_RATE[i] * Math.PI * 2 + i) * WOBBLE_DEPTH * wobble
            : 0;
        out[p] = Math.min(1, Math.max(0, eased[p] + w));
      }

      // Stamp lifecycle: promote the next queued shipment once the current one clears.
      if (!stamp && stampQueue.length) {
        stamp = stampQueue.shift()!;
        overlays.paintStamp(stamp.name, stamp.count, stamp.color);
        stampDirty = true;
      }
      let stampAmt = 0;
      let stampScale = 1;
      if (stamp) {
        const a = stamp.age / STAMP_LIFE;
        // Punch in hard, hold, then fade — a stamp, not a crossfade.
        stampAmt = Math.min(1, a / 0.12) * (1 - smoothstep(0.5, 1, a));
        // Bigger shipments stamp bigger, with a slight overshoot on impact.
        stampScale = (0.72 + 0.28 * clamp01(stamp.count / 40)) * (1.18 - 0.18 * Math.min(1, a / 0.12));
        stamp.age += dt;
        if (stamp.age >= STAMP_LIFE) stamp = null;
      }

      // Repair placard lifecycle — its own slot so it never fights the shipment stamp. Same
      // punch-in-hold-fade shape, held a touch longer because it is a signature to be read.
      if (!repair && repairQueue.length) {
        repair = repairQueue.shift()!;
        overlays.paintRepair(repair.name);
        repairDirty = true;
      }
      let repairAmt = 0;
      let repairScale = 1;
      if (repair) {
        const a = repair.age / REPAIR_LIFE;
        repairAmt = Math.min(1, a / 0.1) * (1 - smoothstep(0.6, 1, a));
        repairScale = 1.12 - 0.12 * Math.min(1, a / 0.1); // small, perfect, slight impact overshoot
        repair.age += dt;
        if (repair.age >= REPAIR_LIFE) repair = null;
      }

      // The card holds at full opacity and then cuts — no fade. The corruption crashes back in
      // rather than seeping back; a dissolve would make it look negotiable.
      let card = 0;
      if (cardAge >= 0) {
        card = 1;
        cardAge += dt;
        if (cardAge >= CARD_SECONDS) cardAge = -1;
      }

      // Boot title, then the awakening — both are timed moments, not single frames.
      const title = titleOpacity(titleAge);
      if (title > 0) titleAge += dt;
      const flash = flashEnvelope(flashAge);
      if (flashAge >= 0) {
        flashAge += dt;
        if (flashAge >= FLASH_SECONDS) flashAge = -1;
      }

      // The chyron: a radio caption outranks the idle placard — when THE SPEAKER is talking,
      // the sky subtitles it. Suppressed under the boot title (which covers the feed anyway).
      const subtitle = getSubtitle();
      const chyron = title > 0.99 ? '' : subtitle || (hasShipped ? '' : 'NOTHING IS WRONG');

      stack.draw({
        params: out, timeSec, drift: 1, brownout, flash, era, chyron,
        strain, fever, pallor, moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
        stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty,
        repair: repairAmt, repairScale, repairDirty,
        card, cardDirty, title, titleDirty,
      });
      if (scramFrames > 0) scramFrames--;
      stampDirty = false;
      repairDirty = false;
      cardDirty = false;
      titleDirty = false;

      frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
    },
  };

  return api;
}
