/* global React */
const { useEffect, useState, useRef } = React;

// =============================================================
// RetroDisplay — headless CRT pane
// =============================================================
// Adapted from the RetroFit UI Engine (uploads/remix_-[ip]-trouble-ui).
// Stripped of: outer device frame, mobile dimensions, control panel,
// Tailwind, Lucide, virtual keyboard. Engine now FILLS its parent —
// the chassis PNG is our frame.
//
// Decision points (per user, m0069):
//   1. Typewriter is owned by the state machine; engine renders text
//      INSTANTLY when typewriterSpeed === 0 (or just respects the prop).
//      We default to 0 so the state machine drives character-by-character.
//   2. Fonts: Share Tech Mono + EXEPixelPerfect are loaded via @font-face
//      in index.html.
//
// =============================================================

// SVG noise for grain
const NOISE_SVG = "data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='nf'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23nf)' opacity='1'/%3E%3C/svg%3E";

// Default params (transcribed from RetroFit "Default Look" preset)
window.RETRO_DEFAULT_PARAMS = {
  fontFamily: "EXEPixelPerfect",
  fontSize: 48,
  lineHeight: 0.75,
  textColor: "#ffcdd0",
  bloomColor: "#e00d4e",
  backgroundColor: "#101010",
  bloomStrength: 0.1,
  bloomOscillator: { enabled: true, min: 0.05, max: 0.1, frequency: 0.1, noise: 0.04, waveType: "bezier" },
  contrast: 1, brightness: 1.1, sepia: 0,
  grainOpacity: 0.02,
  grainOscillator: { enabled: false, min: 0, max: 0.1, frequency: 0.5, noise: 0.02, waveType: "sine" },
  grainSpeed: 50,
  scanlineIntensity: 0.05,
  scanlineOscillator: { enabled: false, min: 0, max: 0.2, frequency: 0.1, noise: 0.01, waveType: "sine" },
  scanlineDensity: 12,
  scanlineDriftSpeed: 57,
  chromaticAberration: 4,
  chromaticAberrationOscillator: { enabled: true, min: 1, max: 2, frequency: 0.3, noise: 0.12, waveType: "sine" },
  screenCurvature: 0.2,
  flickerIntensity: 0.01,
  jitterStrength: 0,
  maxFps: 20,                   // FX clock cap; 0 = display rate (120Hz on ProMotion)
  typewriterSpeed: 0,           // OWNED BY STATE MACHINE — engine renders instantly
  typewriterSpeedOscillator: { enabled: false, min: 5, max: 30, frequency: 0.5, noise: 0.2, waveType: "sine" },
  pauseDuration: 600,
  halationStrength: 0.2,
  halationSpread: 4,
  // Decision-option (ChoiceCard) typography
  choiceFontScale: 0.92,        // multiplier of base fontSize (em-equivalent)
  choiceLineHeight: 1.45,
};

// ── Build engine params from flat eng_* tweak keys ──────────────────
// Lives here (not in engine-tweaks.jsx) on purpose: device.jsx needs it to
// derive the CRT look from TWEAK_DEFAULTS on every render, and the dev-tools
// scripts are stripped from the page in production — this file always loads.
window.engineParamsFromTweaks = function engineParamsFromTweaks(t) {
  const osc = (prefix) => ({
    enabled:   !!t[`eng_${prefix}_enabled`],
    waveType:  t[`eng_${prefix}_waveType`] || "sine",
    min:       Number(t[`eng_${prefix}_min`]) || 0,
    max:       Number(t[`eng_${prefix}_max`]) || 0,
    frequency: Number(t[`eng_${prefix}_freq`]) || 0,
    noise:     Number(t[`eng_${prefix}_noise`]) || 0,
  });
  return {
    fontFamily: "EXEPixelPerfect",
    fontSize: t.eng_fontSize,
    lineHeight: t.eng_lineHeight,
    textColor: t.eng_textColor,
    bloomColor: t.eng_bloomColor,
    backgroundColor: t.eng_backgroundTransparent ? "transparent" : t.eng_backgroundColor,
    contrast: t.eng_contrast,
    brightness: t.eng_brightness,
    sepia: t.eng_sepia,
    scanlineDensity: t.eng_scanlineDensity,
    scanlineDriftSpeed: t.eng_scanlineDriftSpeed,
    screenCurvature: t.eng_screenCurvature,
    flickerIntensity: t.eng_flickerIntensity,
    jitterStrength: t.eng_jitterStrength,
    maxFps: t.eng_maxFps ?? 20, // stale tweak sets predate the key — don't lose the cap
    pauseDuration: t.eng_pauseDuration,
    halationStrength: t.eng_halationStrength,
    halationSpread: t.eng_halationSpread,
    choiceFontScale: t.eng_choiceFontScale,
    choiceLineHeight: t.eng_choiceLineHeight,

    bloomStrength: t.eng_bloomStrength,
    bloomOscillator: osc("bloom"),
    chromaticAberration: t.eng_chromaticAberration,
    chromaticAberrationOscillator: osc("aber"),
    scanlineIntensity: t.eng_scanlineIntensity,
    scanlineOscillator: osc("scan"),
    grainOpacity: t.eng_grainOpacity,
    grainOscillator: osc("grain"),
    grainSpeed: t.eng_grainSpeed,
    typewriterSpeed: t.eng_typewriterSpeed,
    typewriterSpeedOscillator: osc("tw"),
  };
};

const getWaveValue = (waveType, t, freq) => {
  if (waveType === "bezier") {
    const phase = Math.abs((t * freq) % 1);
    const tr = phase < 0.5 ? phase * 2 : 2 - (phase * 2);
    const v = tr * tr * (3 - 2 * tr);
    return v * 2 - 1;
  }
  return Math.sin(t * freq * Math.PI * 2);
};

// ── Dynamic style strings ────────────────────────────────────────────
// One builder per style, shared by the initial React render AND the frame
// writer below, so the two can never drift apart.
const toEm = (v) => `${v / 32}em`; // design px (32px base) → em, scales with fontSize
const buildTextShadow = (bloom, aberration, p) =>
  bloom > 0 || Math.abs(aberration) > 0.1 || p.halationStrength > 0
    ? `${toEm(aberration)} 0 ${toEm(2)} rgba(255,0,0,0.5), ${toEm(-aberration)} 0 ${toEm(2)} rgba(0,0,255,0.5),`
      + ` 0 0 ${toEm(p.halationSpread)} color-mix(in srgb, ${p.bloomColor} ${p.halationStrength * 100}%, transparent),`
      + ` 0 0 ${toEm(bloom * 10)} ${p.bloomColor}, 0 0 ${toEm(bloom * 20)} ${p.bloomColor}`
    : "none";
const buildScanlineMask = (scanline) =>
  `linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(0,0,0,${1 - scanline}) 50%)`;

// Single rAF loop per display. Samples the oscillators into a ref and
// writes the handful of dynamic styles STRAIGHT to the DOM — React hears
// nothing per frame. (The previous version force-re-rendered the whole
// component at ~50fps even over static text; on the phone that steady
// reconciliation was a top heat source.) When params make every dynamic
// value a constant, the loop doesn't run at all: a static CRT costs zero
// frames. `paramsRef` carries the latest params into the loop so dev-panel
// knob drags stream in without re-arming the effect.
function useDynamicValues(params, paramsRef, els) {
  const ref = useRef({
    bloom: params.bloomStrength,
    aberration: params.chromaticAberration,
    scanline: params.scanlineIntensity,
    grain: params.grainOpacity,
    scanOffset: 0,
    grainX: 0, grainY: 0,
    flicker: 1,
  });

  // Can anything change frame to frame? Disabled oscillators sample to
  // constants, so this is knowable up front from params alone.
  const animated = !!(
    params.bloomOscillator?.enabled ||
    params.chromaticAberrationOscillator?.enabled ||
    params.scanlineOscillator?.enabled ||
    params.grainOscillator?.enabled ||
    params.flickerIntensity > 0 ||
    params.jitterStrength > 0 ||
    (params.scanlineDriftSpeed > 0 && params.scanlineIntensity > 0) ||
    (params.grainSpeed > 0 && params.grainOpacity > 0)
  );

  useEffect(() => {
    // Noise scaling differs per oscillator (matches RetroFit engine):
    //   "raw"  → ±0.5 * noise            (bloom, scanlines, grain — 0–1 domain)
    //   "px20" → ±0.5 * noise * 20       (chromatic aberration — pixel offsets)
    //   "amp"  → ±0.5 * noise * (max-min)(typewriter speed — proportional to range)
    const sample = (osc, fallback, t, mode = "raw") => {
      if (!osc?.enabled) return fallback;
      const center = (osc.min + osc.max) / 2;
      const amp = (osc.max - osc.min) / 2;
      const wave = getWaveValue(osc.waveType, t, osc.frequency);
      const n = osc.noise || 0;
      const scale =
        mode === "px20" ? 20 :
        mode === "amp"  ? (osc.max - osc.min) :
        1;
      const noise = (Math.random() - 0.5) * n * scale;
      return Math.max(0, center + wave * amp + noise);
    };
    // Last string handed to each style property — identical frames (and the
    // static parts of a frame, like an un-drifting mask image) skip the DOM.
    const applied = {};
    const write = (now) => {
      const p = paramsRef.current;
      const t = now / 1000;
      const r = ref.current;
      r.bloom      = sample(p.bloomOscillator,               p.bloomStrength,        t, "raw");
      r.aberration = sample(p.chromaticAberrationOscillator, p.chromaticAberration,  t, "px20");
      r.scanline   = sample(p.scanlineOscillator,            p.scanlineIntensity,    t, "raw");
      r.grain      = sample(p.grainOscillator,               p.grainOpacity,         t, "raw");
      if (p.scanlineDriftSpeed > 0) r.scanOffset = (r.scanOffset + p.scanlineDriftSpeed * 0.1) % 1000;
      if (p.grainSpeed > 0 && Math.random() < p.grainSpeed / 100) {
        r.grainX = Math.random() * 100; r.grainY = Math.random() * 100;
      }
      r.flicker = 1 - Math.random() * p.flickerIntensity;

      const fx = els.fx.current;
      if (fx) {
        const ts = buildTextShadow(r.bloom, r.aberration, p);
        if (ts !== applied.textShadow) { applied.textShadow = ts; fx.style.textShadow = ts; }
        if (r.flicker !== applied.flicker) { applied.flicker = r.flicker; fx.style.opacity = r.flicker; }
        if (p.jitterStrength > 0) {
          const jx = (Math.random() - 0.5) * p.jitterStrength * 10;
          const jy = (Math.random() - 0.5) * p.jitterStrength * 10;
          fx.style.transform = `translate(${jx}px, ${jy}px)`;
        }
      }
      const mask = els.mask.current;
      if (mask) {
        const on = r.scanline > 0;
        const img  = on ? buildScanlineMask(r.scanline) : "none";
        const size = on ? `100% ${100 / p.scanlineDensity}px` : "auto";
        const pos  = on ? `0px ${r.scanOffset}px` : "0 0";
        if (img !== applied.maskImg)  { applied.maskImg = img;   mask.style.webkitMaskImage = img;  mask.style.maskImage = img; }
        if (size !== applied.maskSize){ applied.maskSize = size; mask.style.webkitMaskSize = size;  mask.style.maskSize = size; }
        if (pos !== applied.maskPos)  { applied.maskPos = pos;   mask.style.webkitMaskPosition = pos; mask.style.maskPosition = pos; }
      }
      const g = els.grain.current;
      if (g) {
        if (r.grain !== applied.grain) { applied.grain = r.grain; g.style.opacity = r.grain; }
        const gp = `${r.grainX}px ${r.grainY}px`;
        if (gp !== applied.grainPos) { applied.grainPos = gp; g.style.backgroundPosition = gp; }
      }
    };
    // One honest pass on mount / param change — keeps a fresh param visible
    // even when the loop below never runs.
    write(performance.now());
    if (!animated) return; // static display: no loop, no frames, no heat
    // FPS cap: every write() lands real style changes (the oscillator noise
    // guarantees fresh strings), and each one repaints the whole glyph layer
    // — multi-shadow text is the most expensive paint on the page. Ambient
    // CRT noise reads the same at ~20fps as at display rate (120Hz on
    // ProMotion phones); skipped ticks cost one time check.
    let raf, last = 0;
    const tick = (now) => {
      raf = requestAnimationFrame(tick);
      const fps = Number(paramsRef.current.maxFps) || 0;
      if (fps > 0) {
        const interval = 1000 / fps;
        if (now - last < interval) return;
        last = now - ((now - last) % interval); // keep the average rate honest
      }
      write(now);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [
    animated,
    params.bloomOscillator, params.chromaticAberrationOscillator,
    params.scanlineOscillator, params.grainOscillator,
    params.bloomStrength, params.chromaticAberration,
    params.scanlineIntensity, params.grainOpacity,
    params.scanlineDriftSpeed, params.grainSpeed, params.flickerIntensity,
    params.jitterStrength,
  ]);

  return ref.current;
}

function RetroDisplay({
  text = "",
  params: userParams,
  // optional: render a children overlay INSIDE the CRT (e.g. the choice card)
  children,
  // optional: render a flex-fixed footer at the bottom of the CRT body —
  // does NOT scroll with body content; sits above the scrollable region but
  // inside the same scanline/curvature/textShadow cascade.
  footer,
  // optional: ref forwarded to the scrollable body container
  scrollRef,
}) {
  const params = { ...window.RETRO_DEFAULT_PARAMS, ...(userParams || {}) };

  // Latest params for the frame writer (streams dev-knob changes into the
  // loop without re-arming it) + the elements it writes to.
  const paramsRef = useRef(params);
  paramsRef.current = params;
  const fxRef = useRef(null);    // text layer: textShadow / flicker / jitter
  const maskRef = useRef(null);  // filter layer: scanline mask drift
  const grainRef = useRef(null); // grain overlay: opacity / position

  const dyn = useDynamicValues(params, paramsRef, { fx: fxRef, mask: maskRef, grain: grainRef });
  const bloom = dyn.bloom;
  const aberration = dyn.aberration;
  const scanline = dyn.scanline;
  const grain = dyn.grain;
  const scanOffset = dyn.scanOffset;
  const grainOff = { x: dyn.grainX, y: dyn.grainY };
  const flickerOpacity = dyn.flicker;

  // typewriter (engine-side) — only used when state machine isn't pre-typing
  // text. Default speed=0 means render whatever string we receive instantly.
  const [shown, setShown] = useState(text);
  useEffect(() => {
    if (!params.typewriterSpeed) { setShown(text); return; }
    let i = 0, current = "", to;
    const tick = () => {
      if (i >= text.length) return;
      if (text.substring(i, i + 6) === "/pause") { i += 6; to = setTimeout(tick, params.pauseDuration); return; }
      current += text[i]; setShown(current); i++;
      to = setTimeout(tick, params.typewriterSpeed);
    };
    setShown(""); tick();
    return () => clearTimeout(to);
  }, [text, params.typewriterSpeed, params.pauseDuration]);

  // Composite styles — first-paint values only; every frame after this the
  // writer in useDynamicValues updates the same properties directly on the
  // DOM nodes (same builders, so the strings can never disagree).
  const textShadow = buildTextShadow(bloom, aberration, params);

  const screenFilter = `contrast(${params.contrast * 100}%) brightness(${params.brightness * 100}%) sepia(${params.sepia * 100}%)`;

  const scanlineMask = buildScanlineMask(scanline);
  const scanlineSize = `100% ${100 / params.scanlineDensity}px`;

  let jitter = {};
  if (params.jitterStrength > 0) {
    const jx = (Math.random() - 0.5) * params.jitterStrength * 10;
    const jy = (Math.random() - 0.5) * params.jitterStrength * 10;
    jitter = { transform: `translate(${jx}px, ${jy}px)` };
  }

  return (
    <div style={{
      position: "absolute",
      inset: 0,
      // Allow "transparent" / "none" / empty string to fall through to whatever
      // is painted underneath (e.g. baked-in bezel gradient).
      background: (!params.backgroundColor || params.backgroundColor === "transparent" || params.backgroundColor === "none")
        ? "transparent"
        : params.backgroundColor,
      pointerEvents: "auto",
      overflow: "hidden",
      // screen curvature — inset shadow darkens corners like a CRT vignette
      boxShadow: params.screenCurvature > 0
        ? `inset 0 0 ${params.screenCurvature * 100}px rgba(0,0,0,0.9)`
        : "none",
    }}>
      <div ref={maskRef} style={{
        position: "absolute", inset: 0,
        filter: screenFilter,
        WebkitMaskImage: scanline > 0 ? scanlineMask : "none",
        WebkitMaskSize: scanline > 0 ? scanlineSize : "auto",
        WebkitMaskPosition: scanline > 0 ? `0px ${scanOffset}px` : "0 0",
        maskImage: scanline > 0 ? scanlineMask : "none",
        maskSize: scanline > 0 ? scanlineSize : "auto",
        maskPosition: scanline > 0 ? `0px ${scanOffset}px` : "0 0",
      }}>
        <div ref={fxRef} style={{
          position: "absolute", inset: 0,
          padding: "5% 5% 5% 5%",
          fontFamily: `"${params.fontFamily}", ui-monospace, monospace`,
          fontSize: `${params.fontSize}px`,
          lineHeight: params.lineHeight,
          color: params.textColor,
          textShadow,
          opacity: flickerOpacity,
          boxSizing: "border-box",
          display: "flex",
          flexDirection: "column",
          ...jitter,
        }}>
          {/* Scrollable body region — flex-grow takes all space the footer
              doesn't claim. The user can scroll back through prior narrative
              context here even while a footer (ChoiceCard) is pinned below. */}
          <div className="crt-body" ref={scrollRef} style={{
            flex: "1 1 auto",
            minHeight: 0,
            whiteSpace: "pre-wrap",
            wordBreak: "break-word",
            overflowY: "auto",
            overflowX: "hidden",
            scrollbarWidth: "none",
            msOverflowStyle: "none",
          }}>
            {shown}
            {children}
          </div>
          {/* Footer slot — flex-fixed; never scrolls. Inherits textShadow,
              fontFamily and color from the parent so bloom/aberration cascade
              applies. Footer content sets its own font sizing as needed. */}
          {footer != null && (
            <div style={{ flex: "0 0 auto", paddingTop: "1.2em" }}>
              {footer}
            </div>
          )}
        </div>

        {/* Mounted whenever grain CAN show (static opacity or oscillator);
            the frame writer drives opacity, so a sampled dip to 0 is just an
            invisible div — same pixels as unmounting it, none of the churn. */}
        {(params.grainOpacity > 0 || params.grainOscillator?.enabled) && (
          <div ref={grainRef} style={{
            position: "absolute", inset: 0,
            backgroundImage: `url("${NOISE_SVG}")`,
            opacity: grain,
            backgroundPosition: `${grainOff.x}px ${grainOff.y}px`,
            mixBlendMode: "overlay",
            pointerEvents: "none",
          }} />
        )}
      </div>
    </div>
  );
}

window.RetroDisplay = RetroDisplay;
