/**
 * The GLYTCH shader stack, ported from the WIMVEE GLYTCH prototype (../index.html).
 *
 * The 8 original glitch params and their maths are TUNED — the prototype is the reference
 * and the magic numbers are respected verbatim. Everything else here is additive:
 *   u_drift    - slow sinusoidal pan of the clean feed (subtle motion to chew on)
 *   u_brownout - the factory's pain: freeze-frame + dropout bars + desync judder
 *   u_flash    - one-frame full-white awakening on the first shipment ever
 *   u_block/u_ring/u_moire (round 2) - artifact classes the original 8 cannot express
 *   u_stamp + u_scram uniforms (r2)  - transient overlays composited in screen space
 *
 * Raw WebGL, not three.js: this is one fullscreen triangle. Pulling three.js in for that
 * would cost more than the whole effect is allowed to cost.
 */
import { BASE_H, BASE_W, type BaseFeed } from './baseTexture';
import type { Era } from './eras';
import type { Overlays } from './overlays';
import { PARAMS, type Param, type ParamSet } from './params';

const VS = `
attribute vec2 p;
varying vec2 vUv;
void main(){ vUv = p*0.5+0.5; vUv.y = 1.0-vUv.y; gl_Position = vec4(p,0.,1.); }`;

const FS = `
precision mediump float;
varying vec2 vUv;
uniform sampler2D tex;
uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_block, u_ring, u_moire;
uniform float u_drift, u_brownout, u_flash;
uniform float u_strain, u_fever, u_pallor;
uniform vec2 u_moireSeed;
uniform float u_wildlife;
uniform vec2 u_wildlifeSeed;
uniform float u_mite;
uniform vec2 u_miteSeed;
uniform sampler2D u_stampTex, u_scramTex, u_cardTex, u_titleTex, u_repairTex;
uniform float u_stampAmt, u_stampScale, u_scram, u_scramY, u_card, u_title;
uniform float u_repair, u_repairScale;

float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
  vec2 i=floor(p), f=fract(p); f=f*f*(3.-2.*f);
  return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),
             mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
}
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }

// REUSABLE SPATIAL-ARTIFACT TEMPLATE (round 2 proposal, round 3 build).
// An irregular growth front creeping outward from the seed point. grow=0 is nothing, grow=1 has
// swallowed the frame. The fbm term makes the front ragged and organic instead of a
// tidy circle, so the artifact spreads like the codex's maze walls rather than a spotlight.
// Any future artifact that should GROW FROM A PLACE rather than fade in everywhere should
// multiply its amount by this.
float growthMask(vec2 p, vec2 seed, float grow){
  vec2 d = p - seed;
  d.x *= 1.78;                             // 16:9 correction: grow circles, not ellipses
  float r = length(d);
  float ragged = (fbm(d*3.2 + seed*7.0) - 0.5) * 0.38;
  float front = grow * 1.35;               // reach past the corners at grow=1
  return smoothstep(front + 0.10, front - 0.10, r + ragged);
}

void main(){
  // freeze quantizes the timebase everything else animates on.
  // A brownout slams the whole timebase into a hard stutter regardless of freeze.
  float fz = clamp(max(u_freeze, u_brownout*0.85)*1.4, 0., 1.);
  float tt = mix(t, floor(t*2.5)/2.5, fz);
  vec2 uv = vUv;

  // slow drift of the clean feed: bounded sinusoid, so no wrap seam
  uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift;

  // signal strain: the reserve is draining. A tremor, not a seizure — deliberately about a
  // quarter of the brownout's amplitude, so dread reads as dread and the brownout still lands.
  if(u_strain > 0.001){
    float js = floor(tt*9.0);
    uv.x += (hash(vec2(js, 17.0))-0.5) * u_strain * 0.017;
    uv.y += (hash(vec2(js, 23.0))-0.5) * u_strain * 0.011;
  }

  // fever: the decoders are running hot. Slow heat-haze shimmer, no judder.
  if(u_fever > 0.001){
    uv.x += sin(uv.y*38.0 + tt*1.7) * u_fever * 0.0045;
    uv.y += sin(uv.x*26.0 + tt*1.1) * u_fever * 0.0030;
  }

  // desync judder: the frame itself loses its footing during a brownout
  if(u_brownout > 0.001){
    float j = floor(tt*12.0);
    uv.x += (hash(vec2(j, 3.0))-0.5) * u_brownout * 0.07;
    uv.y += (hash(vec2(j, 8.0))-0.5) * u_brownout * 0.04;
  }

  // v-hold roll
  uv.y = fract(uv.y + u_roll * tt * 0.35);

  // scanline tears: bands shove sideways
  float band = floor(uv.y * 36.0);
  float tr = hash(vec2(band, floor(tt*7.0)));
  if(tr > 1.0 - u_tear*0.85){
    uv.x += (hash(vec2(band, floor(tt*7.0)+9.0)) - 0.5) * u_tear * 0.5;
  }

  // datamosh: macroblocks pick up stale offsets
  vec2 blk = floor(uv * vec2(16.0, 12.0));
  float mb = hash(blk + floor(tt*3.0));
  if(mb > 1.0 - u_mosh*0.9){
    vec2 off = vec2(hash(blk+1.7), hash(blk+4.2)) - 0.5;
    uv += off * u_mosh * 0.35;
  }

  // melt: columns drip downward
  float drip = pow(vnoise(vec2(uv.x*30.0, floor(tt*1.5))), 3.0);
  uv.y -= u_melt * drip * uv.y * 0.9;

  uv = fract(uv);

  // chroma split
  float ca = u_chroma * 0.03;
  vec3 col;
  col.r = texture2D(tex, uv + vec2( ca, 0.)).r;
  col.g = texture2D(tex, uv).g;
  col.b = texture2D(tex, uv - vec2( ca, ca*0.6)).b;

  // ---- macroblock quantization: the quantizer's confession, 8x8 and LEGIBLE.
  // The lattice is locked to screen space (vUv) so it stays a rigid grid while melt and
  // mosh drag the picture underneath it — that rigidity is what makes it read as blocks.
  if(u_block > 0.001){
    vec2 g = vec2(80.0, 45.0);                    // 8x8 source pixels at 640x360
    vec2 cell = (floor(vUv*g)+0.5)/g;
    vec3 bcol = texture2D(tex, uv + (cell - vUv)).rgb;  // one flat sample per cell
    float levels = mix(24.0, 3.0, u_block);       // flat areas starve as it bites
    bcol = floor(bcol*levels + 0.5)/levels;
    col = mix(col, bcol, u_block);
    vec2 f = fract(vUv*g);
    float seam = max(step(f.x, 0.07), step(f.y, 0.07));
    col = mix(col, col*0.55, seam*u_block*0.85);
  }

  // ---- ringing halos: Gibbs oscillation ringing OUT from every contrast edge.
  // Narrow differences only ever light up the 1px boundary, which reads as "slightly
  // sharper" rather than as haloing — so the gradient is sampled WIDE (out to 7px) to give
  // the halo room to ring, and the oscillation runs parallel to the edge it came from.
  if(u_ring > 0.001){
    vec2 px = vec2(1.0/640.0, 1.0/360.0);
    vec3 blur = 0.25*(texture2D(tex, uv+vec2(1.6*px.x,0.)).rgb + texture2D(tex, uv-vec2(1.6*px.x,0.)).rgb
                    + texture2D(tex, uv+vec2(0.,1.6*px.y)).rgb + texture2D(tex, uv-vec2(0.,1.6*px.y)).rgb);
    float n1 = lu(texture2D(tex, uv+vec2(3.0*px.x,0.)).rgb) - lu(texture2D(tex, uv-vec2(3.0*px.x,0.)).rgb);
    float n2 = lu(texture2D(tex, uv+vec2(0.,3.0*px.y)).rgb) - lu(texture2D(tex, uv-vec2(0.,3.0*px.y)).rgb);
    float n3 = lu(texture2D(tex, uv+vec2(7.0*px.x,0.)).rgb) - lu(texture2D(tex, uv-vec2(7.0*px.x,0.)).rgb);
    float n4 = lu(texture2D(tex, uv+vec2(0.,7.0*px.y)).rgb) - lu(texture2D(tex, uv-vec2(0.,7.0*px.y)).rgb);
    vec2 grad = vec2(n1 + n3*0.75, n2 + n4*0.75);
    float edge = clamp(length(grad)*2.2, 0., 1.);
    vec2 dir = normalize(grad + vec2(1e-4));
    float proj = dot(dir, vec2(vUv.x*640.0, vUv.y*360.0));
    float rings = sin(proj*1.15 - tt*5.0);         // ethereal, faintly vibrating
    col += vec3(0.82,0.90,1.0) * rings * edge * u_ring * 0.85;
    col += (col - blur) * u_ring * 1.5;            // overshoot at the edge itself
  }

  // ---- moire: two near-identical lattices beating against the sensor grid.
  // REGIONAL: the crystal GROWS from a seeded place and creeps outward with the tally, rather
  // than fading in across the whole frame. Intensity saturates early while AREA keeps growing,
  // so a big shipment reads as "the maze has spread", not "everything got slightly shimmery".
  if(u_moire > 0.001){
    float mo = clamp(u_moire*2.2, 0.0, 1.0) * growthMask(vUv, u_moireSeed, u_moire);
    if(mo > 0.001){
      float ang = 0.06 + 0.02*sin(tt*0.07);
      vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang));
      float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0);
      float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25);
      float m = a*b;                                 // the beat: swirling maze lattice
      vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03));
      col = mix(col, col*0.4 + sheen*0.7, mo*clamp(abs(m)*1.5, 0., 1.));
    }
  }

  // static
  float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
  col = mix(col, vec3(sn), u_noise * 0.7);

  // ---- wildlife shimmer: a mosquito swarm degrading a REGION of the sky. Same growth-mask
  // template as moire, seeded from the swarm's world position — high-frequency crawling
  // luminance noise (literal mosquito noise) rather than the flat static above.
  if(u_wildlife > 0.001){
    float wm = growthMask(vUv, u_wildlifeSeed, u_wildlife*0.7) * clamp(u_wildlife*1.6, 0.0, 1.0);
    if(wm > 0.001){
      float sk = hash(floor(vUv*vec2(320.0,180.0)) + floor(tt*20.0));  // crawls fast, per-frame
      col = mix(col, col*0.6 + vec3(sk)*0.5, wm*0.7);
    }
  }

  // freeze dropout: grey flash lines
  float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
  col = mix(col, vec3(0.45), dropo * u_freeze);

  // brownout dropout: heavy black tape-dropout bars with hot rims
  if(u_brownout > 0.001){
    float row = floor(vUv.y*70.0);
    float d = hash(vec2(row, floor(tt*8.0)));
    float bar = step(1.0 - u_brownout*0.38, d);
    col = mix(col, vec3(0.03), bar*u_brownout);
    float rim = step(1.0 - u_brownout*0.06, hash(vec2(row+31.0, floor(tt*8.0))));
    col = mix(col, vec3(0.85,0.9,0.85), rim*u_brownout*0.8);
  }

  // film burn: fbm blotch eats the frame, hot rim then black
  float b = fbm(uv*3.0 + vec2(0., -tt*0.4));
  float edge = smoothstep(1.0 - u_burn*1.1, 1.0 - u_burn*1.1 + 0.12, b);
  vec3 rim2 = vec3(1.0, 0.55, 0.15);
  col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
  col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));

  // fever runs warm: the picture itself develops a temperature
  col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55);

  // ---- pallor: the second dread. Not urgency — malaise. An empty reserve drains the colour
  // out of the sky and closes the corners in. Codex §8: colour IS the resource, so a factory
  // with no cushion literally has less of it. Chronic, and it sits there in surplus too.
  if(u_pallor > 0.001){
    col = mix(col, vec3(lu(col)), u_pallor*0.5);
    vec2 pd = vUv - 0.5; pd.x *= 1.78;
    float vig = smoothstep(0.34, 0.95, length(pd));
    col *= 1.0 - vig*u_pallor*0.75;
  }

  // ---- mite sterility: The Correction's hospital-white cleanliness creeping outward from heavy
  // parity-mite activity. Same growth-mask template as moire/wildlife, but the INVERSE of
  // corruption — it desaturates and washes the region toward clinical white with a faint
  // manufactured tile grid. It sits on TOP of the corruption stack because it is erasure: your
  // mess is yours, this is your work being quietly deleted. Meant to feel WORSE than rot.
  if(u_mite > 0.001){
    float mmask = growthMask(vUv, u_miteSeed, u_mite*0.7) * clamp(u_mite*1.7, 0.0, 1.0);
    if(mmask > 0.001){
      vec3 sterile = mix(vec3(lu(col)), vec3(0.93,0.96,0.95), 0.72); // desaturate → cool white
      sterile = mix(sterile, vec3(0.98), 0.35);                       // lift toward clinical bright
      float tile = max(step(0.93, fract(vUv.x*64.0)), step(0.93, fract(vUv.y*36.0)));
      sterile = mix(sterile, sterile*0.9, tile*0.6);                  // faint manufactured grid
      col = mix(col, sterile, mmask*0.9);
    }
  }

  // faint scanlines for vibe (constant, not scored)
  col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);

  // ---- shipment stamp: screen space (vUv), so a melting feed never drags the placard
  if(u_stampAmt > 0.001){
    vec2 halfSize = vec2(0.30, 0.15)*u_stampScale;
    vec2 suv = (vUv - vec2(0.5, 0.44))/(2.0*halfSize) + 0.5;
    if(suv.x > 0.0 && suv.x < 1.0 && suv.y > 0.0 && suv.y < 1.0){
      vec4 s = texture2D(u_stampTex, suv);
      col = mix(col, s.rgb, s.a*u_stampAmt);
    }
  }

  // ---- repair placard: The Correction signs one unit off the ledger. Small, perfect, screen
  // space (uncorrupted), parked low-right so it never fights the shipment stamp for the centre.
  if(u_repair > 0.001){
    vec2 halfSize = vec2(0.150, 0.082)*u_repairScale;
    vec2 ruv = (vUv - vec2(0.80, 0.80))/(2.0*halfSize) + 0.5;
    if(ruv.x > 0.0 && ruv.x < 1.0 && ruv.y > 0.0 && ruv.y < 1.0){
      vec4 rp = texture2D(u_repairTex, ruv);
      col = mix(col, rp.rgb, rp.a*u_repair);
    }
  }

  // ---- scram: the tape loses tracking for exactly one frame
  if(u_scram > 0.5){
    float halfH = 0.085;
    float d = vUv.y - u_scramY;
    if(abs(d) < halfH){
      vec4 sb = texture2D(u_scramTex, vec2(vUv.x, (d + halfH)/(2.0*halfH)));
      col = mix(col, sb.rgb, sb.a);
    }
  }

  // the awakening: one frame of full white on the first shipment ever
  col = mix(col, vec3(1.0), u_flash);

  // ---- ratification: The Correction takes the sky. Composited dead last and in screen space,
  // so not one pixel of it is corrupted — that is the entire point of the joke.
  if(u_card > 0.001){
    col = mix(col, texture2D(u_cardTex, vUv).rgb, u_card);
  }

  // ---- boot title: WIMVEE FKTRY burn-in. Premultiplied-ish over black so it can dissolve.
  if(u_title > 0.001){
    vec4 ti = texture2D(u_titleTex, vUv);
    col = mix(col, ti.rgb, ti.a * u_title);
  }

  gl_FragColor = vec4(col, 1.0);
}`;

type UniformKey =
  | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'pallor' | 'moireSeed'
  | 'wildlife' | 'wildlifeSeed' | 'mite' | 'miteSeed'
  | 'stampTex' | 'scramTex' | 'cardTex' | 'titleTex' | 'repairTex'
  | 'stampAmt' | 'stampScale' | 'scram' | 'scramY' | 'card' | 'title' | 'repair' | 'repairScale';

export interface DrawState {
  params: ParamSet;
  timeSec: number;
  /** 0..1 amount of slow sinusoidal pan applied to the clean feed */
  drift: number;
  /** 0..1 brownout severity */
  brownout: number;
  /** 0..1 awakening flash (round 5: ramped over ~0.5s, no longer a single frame) */
  flash: number;
  /** current substrate era for the base feed */
  era: Era;
  /** chyron line for the base feed ('NOTHING IS WRONG', a radio subtitle, or '') */
  chyron: string;
  /** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */
  strain: number;
  /** 0..1 feverish shimmer from aggregate machine heat */
  fever: number;
  /** 0..1 pallor from an empty reserve — chronic, unlike tremor */
  pallor: number;
  /** where the moire crystal grows from, in screen uv */
  moireSeed: [number, number];
  /** 0..1 regional mosquito shimmer amount (wildlife swarms) */
  wildlife: number;
  /** where the swarm shimmer grows from, in screen uv */
  wildlifeSeed: [number, number];
  /** 0..1 hospital-white sterile creep around heavy parity-mite activity (the inverse of rot) */
  mite: number;
  /** where the sterile patch grows from, in screen uv */
  miteSeed: [number, number];
  /** 0..1 opacity of the ratification card */
  card: number;
  /** set when the card canvas has been repainted and needs re-upload */
  cardDirty: boolean;
  /** 0..1 opacity of the boot title card */
  title: number;
  /** set when the title canvas has been repainted and needs re-upload */
  titleDirty: boolean;
  /** 0..1 opacity of the shipment stamp */
  stampAmt: number;
  /** stamp size multiplier — bigger shipments stamp harder */
  stampScale: number;
  /** true for exactly one frame per scram event */
  scram: boolean;
  /** 0..1 vertical position of the scram bar */
  scramY: number;
  /** set when the stamp canvas has been repainted and needs re-upload */
  stampDirty: boolean;
  /** 0..1 opacity of the repair placard */
  repair: number;
  /** repair placard size multiplier (slight overshoot on impact, like the stamp) */
  repairScale: number;
  /** set when the repair canvas has been repainted and needs re-upload */
  repairDirty: boolean;
}

export interface GlitchStack {
  draw(s: DrawState): void;
}

function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
  const s = gl.createShader(type)!;
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
    throw new Error(`[screen] shader compile failed: ${gl.getShaderInfoLog(s)}`);
  }
  return s;
}

/** Returns null when WebGL is unavailable; caller degrades to a dead screen rather than crashing the game. */
export function createGlitchStack(
  canvas: HTMLCanvasElement,
  base: BaseFeed,
  overlays: Overlays,
): GlitchStack | null {
  const gl = canvas.getContext('webgl', { antialias: false, depth: false, alpha: false });
  if (!gl) {
    console.warn('[screen] no WebGL context — THE SCREEN stays dark');
    return null;
  }

  const prog = gl.createProgram()!;
  gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS));
  gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS));
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    throw new Error(`[screen] program link failed: ${gl.getProgramInfoLog(prog)}`);
  }
  gl.useProgram(prog);

  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const loc = gl.getAttribLocation(prog, 'p');
  gl.enableVertexAttribArray(loc);
  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

  // All three sources are non-power-of-two: CLAMP_TO_EDGE + LINEAR + no mips is mandatory
  // in WebGL1. Each texture keeps its own unit for the lifetime of the stack, so the hot
  // path never rebinds — it only re-uploads on the frames where a canvas actually changed.
  function makeTex(unit: number, src: TexImageSource): WebGLTexture {
    const t = gl!.createTexture()!;
    gl!.activeTexture(gl!.TEXTURE0 + unit);
    gl!.bindTexture(gl!.TEXTURE_2D, t);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_S, gl!.CLAMP_TO_EDGE);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_T, gl!.CLAMP_TO_EDGE);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MIN_FILTER, gl!.LINEAR);
    gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MAG_FILTER, gl!.LINEAR);
    gl!.texImage2D(gl!.TEXTURE_2D, 0, gl!.RGBA, gl!.RGBA, gl!.UNSIGNED_BYTE, src);
    return t;
  }

  base.update({ timeMs: 0, era: 'stream', chyron: 'NOTHING IS WRONG' });
  overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front
  overlays.paintCard('reel', '');
  overlays.paintTitle();
  overlays.paintRepair(''); // allocate the repair placard texture up front
  const baseTex = makeTex(0, base.canvas);
  const stampTex = makeTex(1, overlays.stampCanvas);
  makeTex(2, overlays.scramCanvas);
  const cardTex = makeTex(3, overlays.cardCanvas);
  const titleTex = makeTex(4, overlays.titleCanvas);
  const repairTex = makeTex(5, overlays.repairCanvas);

  const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
  uni.t = gl.getUniformLocation(prog, 't');
  uni.drift = gl.getUniformLocation(prog, 'u_drift');
  uni.brownout = gl.getUniformLocation(prog, 'u_brownout');
  uni.flash = gl.getUniformLocation(prog, 'u_flash');
  uni.strain = gl.getUniformLocation(prog, 'u_strain');
  uni.fever = gl.getUniformLocation(prog, 'u_fever');
  uni.pallor = gl.getUniformLocation(prog, 'u_pallor');
  uni.card = gl.getUniformLocation(prog, 'u_card');
  uni.title = gl.getUniformLocation(prog, 'u_title');
  uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed');
  uni.wildlife = gl.getUniformLocation(prog, 'u_wildlife');
  uni.wildlifeSeed = gl.getUniformLocation(prog, 'u_wildlifeSeed');
  uni.mite = gl.getUniformLocation(prog, 'u_mite');
  uni.miteSeed = gl.getUniformLocation(prog, 'u_miteSeed');
  uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt');
  uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale');
  uni.scram = gl.getUniformLocation(prog, 'u_scram');
  uni.scramY = gl.getUniformLocation(prog, 'u_scramY');
  uni.repair = gl.getUniformLocation(prog, 'u_repair');
  uni.repairScale = gl.getUniformLocation(prog, 'u_repairScale');
  for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);

  gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0);
  gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1);
  gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2);
  gl.uniform1i(gl.getUniformLocation(prog, 'u_cardTex'), 3);
  gl.uniform1i(gl.getUniformLocation(prog, 'u_titleTex'), 4);
  gl.uniform1i(gl.getUniformLocation(prog, 'u_repairTex'), 5);

  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.uniform1f(uni.drift, 1);

  return {
    draw({ params, timeSec, drift, brownout, flash, era, chyron, strain, fever, pallor,
           moireSeed, wildlife, wildlifeSeed, mite, miteSeed,
           stampAmt, stampScale, scram, scramY, stampDirty,
           repair, repairScale, repairDirty,
           card, cardDirty, title, titleDirty }) {
      // Re-upload the clean feed only when it actually changed (timecode tick / era / chyron).
      if (base.update({ timeMs: timeSec * 1000, era, chyron })) {
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, baseTex);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
      }
      if (stampDirty) {
        gl.activeTexture(gl.TEXTURE0 + 1);
        gl.bindTexture(gl.TEXTURE_2D, stampTex);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas);
      }
      if (cardDirty) {
        gl.activeTexture(gl.TEXTURE0 + 3);
        gl.bindTexture(gl.TEXTURE_2D, cardTex);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.cardCanvas);
      }
      if (titleDirty) {
        gl.activeTexture(gl.TEXTURE0 + 4);
        gl.bindTexture(gl.TEXTURE_2D, titleTex);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.titleCanvas);
      }
      if (repairDirty) {
        gl.activeTexture(gl.TEXTURE0 + 5);
        gl.bindTexture(gl.TEXTURE_2D, repairTex);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.repairCanvas);
      }
      gl.uniform1f(uni.t, timeSec);
      gl.uniform1f(uni.drift, drift);
      gl.uniform1f(uni.brownout, brownout);
      gl.uniform1f(uni.flash, flash);
      gl.uniform1f(uni.strain, strain);
      gl.uniform1f(uni.fever, fever);
      gl.uniform1f(uni.pallor, pallor);
      gl.uniform1f(uni.card, card);
      gl.uniform1f(uni.title, title);
      gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]);
      gl.uniform1f(uni.wildlife, wildlife);
      gl.uniform2f(uni.wildlifeSeed, wildlifeSeed[0], wildlifeSeed[1]);
      gl.uniform1f(uni.mite, mite);
      gl.uniform2f(uni.miteSeed, miteSeed[0], miteSeed[1]);
      gl.uniform1f(uni.stampAmt, stampAmt);
      gl.uniform1f(uni.stampScale, stampScale);
      gl.uniform1f(uni.scram, scram ? 1 : 0);
      gl.uniform1f(uni.scramY, scramY);
      gl.uniform1f(uni.repair, repair);
      gl.uniform1f(uni.repairScale, repairScale);
      for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    },
  };
}

export { BASE_W, BASE_H };
