'use strict';

// The Device — eye canvas (leaf). The living eye behind the plate's upper
// cutout. Phase 2: alive AT REST only — breathing, blinking, pupil tracking,
// micro-jitter. NO reactions to play/controls yet (Phase 4 drives the
// expression + the crossfade controller built here). Purely visual, fully
// independent of the audio engine.

const { useEffect, useRef } = React;

// ── Asset list ────────────────────────────────────────────────────────
// All six load now; Phase 2 only USES baseline/halfclosed/closed. The other
// three are decoded and ready so Phase 4 can crossfade to them instantly.
const EYE_STATES = [
  'eye-baseline', 'eye-halfclosed', 'eye-closed',
  'eye-dilated', 'eye-stressed', 'eye-cracked',
];
const EYE_BASE_EXPRESSION = 'eye-baseline'; // resting state (Phase 4 will vary this)

// ── Fit ───────────────────────────────────────────────────────────────
// Eye art aspect (~1.32) is wider than the cutout box (~1.13); at plain
// cover-fit the height binds exactly, leaving NO room for translations to
// hide behind. Overscan enlarges the art past cover so breathing + tracking
// + jitter never expose an edge inside the circular opening.
const EYE_OVERSCAN = 1.12;
// Offsets below are authored in CSS px at a reference eye height, then scaled
// to the eye's actual rendered size so aliveness feels the same on any screen.
const EYE_REF_PX = 300;

// ── Breathing (resting respiration — slow, indifferent) ───────────────
// Scale-only by default. The eye art carries its own socket flesh, so a
// vertical bob slid the whole socket inside the porthole — it read as the
// face shifting, not the eye breathing. A centered scale pulse swells in
// place without sliding the rim. BREATHE_TRANSLATE_PX kept (default 0) in
// case a hair of bob is wanted once the static socket shadow hides the rim.
const BREATHE_PERIOD_S    = 5.0;   // seconds per breath
const BREATHE_SCALE_AMP   = 0.018; // ±1.8% scale swell
const BREATHE_TRANSLATE_PX = 0;    // vertical bob, ref px (0 = pure scale)

// ── Blink ─────────────────────────────────────────────────────────────
const BLINK_INTERVAL_MIN_S = 4.0;  // randomized idle timer between blinks
const BLINK_INTERVAL_MAX_S = 9.0;
const BLINK_SEGMENT_MS     = 55;   // per crossfade leg; 4 legs ≈ 220ms total
const DOUBLE_BLINK_CHANCE  = 0.22; // chance a blink is a quick double
const DOUBLE_BLINK_GAP_MS  = 120;  // pause between the two blinks of a double

// ── Pupil tracking (heavy, slightly slow → reads as a large creature) ──
// Reduced from 13: with the whole image translating (socket included), a big
// offset slid the visible flesh. The static socket shadow (below) hides the
// rim, so the remaining motion reads as the eyeball glancing, not sliding.
const TRACK_MAX_OFFSET_PX  = 8;    // furthest the eye drifts from center, ref px
const TRACK_EASE           = 3.0;  // exp-smоothing rate (higher = snappier)
const TRACK_SATURATION_PX  = 420;  // pointer distance (client px) at which drift maxes

// ── Idle drift / autonomous saccades (when the pointer sits still) ─────
const IDLE_AFTER_S          = 2.2; // pointer-still time before the eye wanders on its own
const SACCADE_INTERVAL_MIN_S = 1.8;
const SACCADE_INTERVAL_MAX_S = 4.6;
const SACCADE_OFFSET_PX     = 4;   // how far autonomous glances wander, ref px

// ── Micro-jitter (never perfectly still, even when calm) ──────────────
const JITTER_AMP_PX = 0.35;        // ref px

// ── Static socket shadow ──────────────────────────────────────────────
// A fixed dark ring drawn ON TOP of the moving eye, in screen space (never
// transformed). It hides the eye art's flesh rim so motion near the cutout
// edge is invisible — only the center eyeball reads as alive. This is what
// reconciles a moving eye with the static flesh/plate around it. Radii are
// fractions of the eye canvas's min dimension; the cutout circle is ≈0.49.
const SOCKET_SHADOW_INNER    = 0.34; // clear (eyeball) out to here
const SOCKET_SHADOW_OUTER    = 0.52; // fully dark by here (past the rim)
const SOCKET_SHADOW_STRENGTH = 1.0;  // 0..1 opacity of the dark ring
const SOCKET_SHADOW_COLOR    = '8, 5, 6'; // rgb of the socket gloom

// ── Reactions (Phase 4) — the expression rides ON TOP of idle life ────
// The eye crossfades between baseline / dilated / stressed / cracked driven
// by control + loop state (read from DeviceControls.getReaction each frame).
// HUNGER (dilated) grows with overdub layers; pain (stressed→cracked) comes
// from the wrong switch + high resonance; pain OVERRIDES hunger.
const RES_STRESS_THRESH   = 0.5;   // resonance above this starts to strain the eye
const RES_STRESS_MAX      = 0.45;  // max stress from resonance alone
const RECORD_HUNGER_BONUS = 0.18;  // extra dilation while actively being fed
const WRONG_STRESS_RAMP_MS = 600;  // wrong switch held → full stressed
const CRACK_DELAY_MS       = 900;  // held this long before cracking begins
const CRACK_RAMP_MS        = 1600; // then ramps toward full crack
const REACT_ATTACK_EASE    = 6.0;  // fast onset of distress/hunger
const REACT_RELEASE_EASE   = 1.3;  // slow, reluctant recovery (~2.3s) after sustained strain
const HUNGER_EASE          = 3.0;  // dilation eases at a middling pace
const FLINCH_DUR_MS        = 340;  // clear-button flinch (fast close + reopen)

function rand(min, max) { return min + Math.random() * (max - min); }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }

// Blink / flinch envelope: a 0→1→0 lid amount over a duration. The lid is
// drawn as a halfclosed→closed overlay on top of whatever expression is
// active, so blinks read the same regardless of mood.
function makeBlink() {
  let start = -1, dur = 0;
  return {
    trigger(now, d) { start = now; dur = d; },
    active() { return start >= 0; },
    amount(now) {
      if (start < 0) return 0;
      const t = (now - start) / dur;
      if (t >= 1) { start = -1; return 0; }
      return Math.sin(t * Math.PI); // close then open
    },
  };
}

function EyeCanvas() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const g = canvas.getContext('2d');

    // ── load images ───────────────────────────────────────────────────
    // onload BEFORE src: a cached image can complete synchronously on src
    // assignment, so a handler attached after would never fire. The draw gate
    // checks naturalWidth (cache-proof) rather than a load counter anyway.
    const imgs = {};
    for (const name of EYE_STATES) {
      const im = new Image();
      im.onload = () => {};
      im.src = `/the-device/assets/${name}.png`;
      imgs[name] = im;
    }

    // ── backing-store sizing (mirrors scope-canvas) ───────────────────
    const fit = () => {
      const r = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      const w = Math.max(1, Math.round(r.width * dpr));
      const h = Math.max(1, Math.round(r.height * dpr));
      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
    };
    const ro = new ResizeObserver(fit);
    ro.observe(canvas);
    fit();

    // ── live state ────────────────────────────────────────────────────
    const blink = makeBlink();
    let nextBlinkAt = performance.now() + rand(BLINK_INTERVAL_MIN_S, BLINK_INTERVAL_MAX_S) * 1000;
    let pendingDouble = false;
    let doubleAt = 0;
    let prevFlinch = false;

    // eased expression weights (baseline is implicit = the remainder)
    const w = { dilated: 0, stressed: 0, cracked: 0 };

    const pointer = { x: 0, y: 0, lastMove: -1e9 };
    let trackX = 0, trackY = 0;       // current eased offset (ref px)
    let saccadeX = 0, saccadeY = 0;   // autonomous target (ref px)
    let nextSaccadeAt = performance.now() + rand(SACCADE_INTERVAL_MIN_S, SACCADE_INTERVAL_MAX_S) * 1000;

    const onPointerMove = (e) => {
      pointer.x = e.clientX; pointer.y = e.clientY; pointer.lastMove = performance.now();
    };
    // scene-wide: the eye watches the pointer anywhere on the device
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerdown', onPointerMove);

    let raf = 0, prev = performance.now();

    const draw = (now) => {
      raf = requestAnimationFrame(draw);
      const dt = Math.min(0.05, (now - prev) / 1000); prev = now;

      const W = canvas.width, H = canvas.height;
      const dpr = window.devicePixelRatio || 1;
      const cssH = H / dpr;
      const sizeScale = cssH / EYE_REF_PX; // ref-px → this screen's px

      g.clearRect(0, 0, W, H);

      // ── reaction state (drives expression; idle life rides on top) ──
      const react = (window.DeviceControls && window.DeviceControls.getReaction)
        ? window.DeviceControls.getReaction()
        : null;

      // ── blink + flinch scheduling ─────────────────────────────────
      const BLINK_DUR = BLINK_SEGMENT_MS * 4;
      if (react && react.flinch && !prevFlinch) blink.trigger(now, FLINCH_DUR_MS); // clear → flinch
      prevFlinch = react ? react.flinch : false;
      if (pendingDouble && now >= doubleAt) { pendingDouble = false; blink.trigger(now, BLINK_DUR); }
      if (now >= nextBlinkAt && !blink.active()) {
        blink.trigger(now, BLINK_DUR);
        if (Math.random() < DOUBLE_BLINK_CHANCE) { pendingDouble = true; doubleAt = now + BLINK_DUR + DOUBLE_BLINK_GAP_MS; }
        nextBlinkAt = now + rand(BLINK_INTERVAL_MIN_S, BLINK_INTERVAL_MAX_S) * 1000;
      }

      // ── pupil tracking target ─────────────────────────────────────
      const rect = canvas.getBoundingClientRect();
      const ecx = rect.left + rect.width / 2;
      const ecy = rect.top + rect.height / 2;
      const idle = (now - pointer.lastMove) > IDLE_AFTER_S * 1000;

      let tgtX, tgtY;
      if (idle) {
        if (now >= nextSaccadeAt) {
          const ang = rand(0, Math.PI * 2), mag = rand(0.2, 1) * SACCADE_OFFSET_PX;
          saccadeX = Math.cos(ang) * mag; saccadeY = Math.sin(ang) * mag;
          nextSaccadeAt = now + rand(SACCADE_INTERVAL_MIN_S, SACCADE_INTERVAL_MAX_S) * 1000;
        }
        tgtX = saccadeX; tgtY = saccadeY;
      } else {
        const dx = pointer.x - ecx, dy = pointer.y - ecy;
        const dist = Math.hypot(dx, dy) || 1;
        const sat = Math.min(1, dist / TRACK_SATURATION_PX);
        tgtX = (dx / dist) * sat * TRACK_MAX_OFFSET_PX;
        tgtY = (dy / dist) * sat * TRACK_MAX_OFFSET_PX;
      }
      // dt-based exponential smoothing → heavy, laggy follow
      const k = 1 - Math.exp(-TRACK_EASE * dt);
      trackX += (tgtX - trackX) * k;
      trackY += (tgtY - trackY) * k;

      // ── breathing ─────────────────────────────────────────────────
      const phase = (now / 1000) / BREATHE_PERIOD_S * Math.PI * 2;
      const breatheScale = 1 + Math.sin(phase) * BREATHE_SCALE_AMP;
      const breatheY = Math.sin(phase) * BREATHE_TRANSLATE_PX;

      // ── micro-jitter (summed incommensurate sines ≈ gentle tremor) ─
      const jx = (Math.sin(now * 0.0137 + 1.3) + Math.sin(now * 0.0241)) * 0.5 * JITTER_AMP_PX;
      const jy = (Math.sin(now * 0.0193 + 0.7) + Math.sin(now * 0.0311 + 2.1)) * 0.5 * JITTER_AMP_PX;

      // total content offset, ref px → device px
      const offX = (trackX + jx) * sizeScale * dpr;
      const offY = (trackY + jy + breatheY) * sizeScale * dpr;

      // cache-proof gate: wait for the base expression's pixels to exist
      if (!imgs[EYE_BASE_EXPRESSION].naturalWidth) return;

      // ── expression target weights from reaction ───────────────────
      let tgD = 0, tgS = 0, tgC = 0;
      if (react) {
        const hunger = Math.min(1, react.layerCount / react.maxLayers + (react.recording ? RECORD_HUNGER_BONUS : 0));
        const resStress = react.resonance > RES_STRESS_THRESH
          ? ((react.resonance - RES_STRESS_THRESH) / (1 - RES_STRESS_THRESH)) * RES_STRESS_MAX : 0;
        const wrongStress = react.wrong ? Math.min(1, react.wrongHeldMs / WRONG_STRESS_RAMP_MS) : 0;
        // The wrong switch is a TOGGLE: while held on, crack ramps to FULL and
        // HOLDS (sustained damage, no recovery while on). Resonance shortens the
        // ramp (accelerates toward cracked). On toggle off, react.wrong→false so
        // this drops to 0 and eases back slowly via REACT_RELEASE_EASE.
        const crackRamp = CRACK_RAMP_MS * (1 - 0.45 * react.resonance);
        const crack = react.wrong
          ? clamp01((react.wrongHeldMs - CRACK_DELAY_MS) / crackRamp) : 0;
        const distress = Math.max(wrongStress, resStress, crack);
        tgC = crack;
        tgS = Math.max(wrongStress, resStress) * (1 - tgC); // cracked overrides stressed
        tgD = hunger * (1 - distress);                      // pain overrides hunger
      }
      // ease: fast onset, slow recovery for distress; medium for hunger
      const easeTo = (cur, tg, eAtk, eRel) =>
        cur + (tg - cur) * (1 - Math.exp(-(tg >= cur ? eAtk : eRel) * dt));
      w.cracked  = easeTo(w.cracked,  tgC, REACT_ATTACK_EASE, REACT_RELEASE_EASE);
      w.stressed = easeTo(w.stressed, tgS, REACT_ATTACK_EASE, REACT_RELEASE_EASE);
      w.dilated  = easeTo(w.dilated,  tgD, HUNGER_EASE,       HUNGER_EASE);

      const blinkAmt = blink.amount(now);

      g.save();
      g.translate(W / 2 + offX, H / 2 + offY);
      g.scale(breatheScale, breatheScale);

      const drawEye = (name, alpha) => {
        if (alpha <= 0.001) return;
        const img = imgs[name];
        if (!img || !img.naturalWidth) return;
        const iw = img.naturalWidth, ih = img.naturalHeight;
        const cover = Math.max(W / iw, H / ih) * EYE_OVERSCAN;
        g.globalAlpha = alpha > 1 ? 1 : alpha;
        g.drawImage(img, -(iw * cover) / 2, -(ih * cover) / 2, iw * cover, ih * cover);
      };

      // expression stack: baseline underneath, mood states dissolved on top
      // (cracked drawn last → pain reads over pleasure), then the blink lid.
      drawEye(EYE_BASE_EXPRESSION, 1);
      drawEye('eye-dilated',  w.dilated);
      drawEye('eye-stressed', w.stressed);
      drawEye('eye-cracked',  w.cracked);
      drawEye('eye-halfclosed', clamp01(blinkAmt * 1.6));
      drawEye('eye-closed',     clamp01((blinkAmt - 0.5) * 2));

      g.globalAlpha = 1;
      g.restore();

      // ── static socket shadow (screen space — never transformed) ─────
      // Drawn after the eye so the moving flesh rim sinks into a fixed gloom;
      // only the center eyeball reads as alive against the static surround.
      if (SOCKET_SHADOW_STRENGTH > 0) {
        const cx = W / 2, cy = H / 2;
        const minDim = Math.min(W, H);
        const sh = g.createRadialGradient(
          cx, cy, minDim * SOCKET_SHADOW_INNER,
          cx, cy, minDim * SOCKET_SHADOW_OUTER
        );
        sh.addColorStop(0, `rgba(${SOCKET_SHADOW_COLOR}, 0)`);
        sh.addColorStop(1, `rgba(${SOCKET_SHADOW_COLOR}, ${SOCKET_SHADOW_STRENGTH})`);
        g.fillStyle = sh;
        g.fillRect(0, 0, W, H);
      }
    };
    raf = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerdown', onPointerMove);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%',
        display: 'block', pointerEvents: 'none',
      }}
    />
  );
}

window.EyeCanvas = EyeCanvas;
