// Код, реально исполняющийся в Web Worker (модуль-воркер, загружается через
// `new Worker(url, { type: "module" })` из NetrunnerMatrix.tsx). Здесь и
// только здесь есть доступ к OffscreenCanvas/WebGL2-контексту, полученному
// через transferControlToOffscreen(), и к живому экземпляру WASM MatrixEngine.
// Все входящие команды приходят через self.onmessage (см. в конце файла) —
// это приёмная сторона message-протокола, описанного в NetrunnerMatrix.tsx.
import {
  vsMatrix,
  vsOverlay,
  vsGlitch,
  fsMatrix,
  fsGlitch,
  fsBackground,
  postProcessSource,
} from "./shaders.js";
import { compileShader, createProgram } from "./gl-utils.js";

let engine, wasm;
let gl;
let atlasCanvas, atlasCtx, atlasTexture;
let width, height;
let fontSize = 16;
let isMobile = false,
  isVpnOn = false,
  isDarkMode = true;
let lastDrawTime = 0;
let pixelScale = 1.0;
let isLowQuality = false;
let frameCounter = 0;
let eyeScale = 1.5;
let interactionTimer = 0;

let isPaused = false;
let rAF_ID = null;
let showEyes = true;

let fpsHistory = [];
let lastFrameTime = 0;
let scaleCheckTimer = 0;
let currentPixelScale = 1.0;
let maxPixelScale = 1.0;
// Значение здесь не важно — build-wasm.sh/Dockerfile перезаписывают эту
// строку (и добавляют ?v=... к импортам shaders.js/gl-utils.js ниже)
// значением ENGINE_VERSION из NetrunnerMatrix.tsx при сборке деплоя, чтобы
// cache-busting воркера, его импортов и wasm-glue были синхронизированы
// одним источником версии. Держим это значение в source просто не пустым
// для локальной разработки без сборки.
const version = "3.1.5";

let vpnTimer = 0.0;
let uVpnTimerLoc;

let mouseX = 0;
let mouseY = 0;
let uMouseLoc;

let postProgram, matrixProgram, glitchProgram, bgProgram;
let postFramebuffer, postTexture;
let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer;
let vaoMatrix, vaoOverlay, vaoGlitch, vaoBg;
let uResLoc, uFontLoc, uAtlasSizeLoc;
let uBgTimeLoc, uBgResLoc, uBgDarkLoc, uBgVpnLoc, uBgLowQualityLoc;
let instanceBufferCapacity = 0;

let uPostEyePosLLoc,
  uPostEyePosRLoc,
  uPostEyeClosednessLoc,
  uPostIsVpnOnLoc,
  uPostEyeActiveLoc,
  uShockwaveLoc,
  uPostGazeLoc,
  uPostPixelScaleLoc;

const chars =
  "0101アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホ마미무메모야유요라릴레ро와원>_±÷×=≠≈≡≤≥";

// Собирает 3 шейдерные программы (символы матрицы, глитч-блоки,
// полноэкранный пост-процесс) и настраивает GPU-инстансинг: один статический
// quad-буфер (2 треугольника) переиспользуется для всех капель/глитч-блоков,
// а per-instance данные (позиция, символ, масштаб и т.д.) идут вторым
// vertex-буфером с vertexAttribDivisor(loc, 1) — так тысячи капель рисуются
// одним draw-вызовом (drawArraysInstanced) вместо тысяч отдельных.
// postFramebuffer/postTexture — промежуточный кадр: матрица и глитчи сначала
// рендерятся в текстуру, а не сразу на экран, чтобы пост-процесс-шейдер мог
// применить к готовой картинке линзы вокруг глаз/CRT-дисторсию/хроматическую
// аберрацию как один общий проход поверх всего изображения.
function initWebGL() {
  matrixProgram = createProgram(gl, vsMatrix, fsMatrix);
  postProgram = createProgram(gl, vsOverlay, postProcessSource);
  glitchProgram = createProgram(gl, vsGlitch, fsGlitch);
  bgProgram = createProgram(gl, vsOverlay, fsBackground);

  quadBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
    gl.STATIC_DRAW,
  );

  vaoMatrix = gl.createVertexArray();
  gl.bindVertexArray(vaoMatrix);
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
  const aQuadLoc = gl.getAttribLocation(matrixProgram, "a_quad");
  gl.enableVertexAttribArray(aQuadLoc);
  gl.vertexAttribPointer(aQuadLoc, 2, gl.FLOAT, false, 0, 0);

  instanceBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
  instanceBufferCapacity = 10000;
  gl.bufferData(gl.ARRAY_BUFFER, instanceBufferCapacity * 4, gl.DYNAMIC_DRAW);
  const strideBytes = 5 * 4;

  const aPosLoc = gl.getAttribLocation(matrixProgram, "a_position");
  gl.enableVertexAttribArray(aPosLoc);
  gl.vertexAttribPointer(aPosLoc, 2, gl.FLOAT, false, strideBytes, 0);
  gl.vertexAttribDivisor(aPosLoc, 1);

  const aCharIdxLoc = gl.getAttribLocation(matrixProgram, "a_charIdx");
  gl.enableVertexAttribArray(aCharIdxLoc);
  gl.vertexAttribPointer(aCharIdxLoc, 1, gl.FLOAT, false, strideBytes, 2 * 4);
  gl.vertexAttribDivisor(aCharIdxLoc, 1);

  const aAtlasRowLoc = gl.getAttribLocation(matrixProgram, "a_atlasRow");
  gl.enableVertexAttribArray(aAtlasRowLoc);
  gl.vertexAttribPointer(aAtlasRowLoc, 1, gl.FLOAT, false, strideBytes, 3 * 4);
  gl.vertexAttribDivisor(aAtlasRowLoc, 1);

  const aScaleLoc = gl.getAttribLocation(matrixProgram, "a_scale");
  gl.enableVertexAttribArray(aScaleLoc);
  gl.vertexAttribPointer(aScaleLoc, 1, gl.FLOAT, false, strideBytes, 4 * 4);
  gl.vertexAttribDivisor(aScaleLoc, 1);

  vaoGlitch = gl.createVertexArray();
  gl.bindVertexArray(vaoGlitch);
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
  const aQuadGlitch = gl.getAttribLocation(glitchProgram, "a_quad");
  gl.enableVertexAttribArray(aQuadGlitch);
  gl.vertexAttribPointer(aQuadGlitch, 2, gl.FLOAT, false, 0, 0);

  glitchInstanceBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, glitchInstanceBuffer);
  const strideG = 5 * 4;
  const aDataLoc = gl.getAttribLocation(glitchProgram, "a_glitchData");
  gl.enableVertexAttribArray(aDataLoc);
  gl.vertexAttribPointer(aDataLoc, 4, gl.FLOAT, false, strideG, 0);
  gl.vertexAttribDivisor(aDataLoc, 1);

  const aColorLoc = gl.getAttribLocation(glitchProgram, "a_colorType");
  gl.enableVertexAttribArray(aColorLoc);
  gl.vertexAttribPointer(aColorLoc, 1, gl.FLOAT, false, strideG, 4 * 4);
  gl.vertexAttribDivisor(aColorLoc, 1);

  vaoOverlay = gl.createVertexArray();
  gl.bindVertexArray(vaoOverlay);
  overlayQuadBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, overlayQuadBuffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
    gl.STATIC_DRAW,
  );
  const aPosLocOver = gl.getAttribLocation(postProgram, "a_position");
  gl.enableVertexAttribArray(aPosLocOver);
  gl.vertexAttribPointer(aPosLocOver, 2, gl.FLOAT, false, 0, 0);

  // Отдельный VAO для фонового прохода (глубина/дорога): та же геометрия
  // fullscreen-quad, что и у постпроцесса, но локация атрибута запрашивается
  // у bgProgram отдельно — WebGL не гарантирует одинаковые location для
  // одноимённого атрибута между разными слинкованными программами.
  vaoBg = gl.createVertexArray();
  gl.bindVertexArray(vaoBg);
  gl.bindBuffer(gl.ARRAY_BUFFER, overlayQuadBuffer);
  const aPosLocBg = gl.getAttribLocation(bgProgram, "a_position");
  gl.enableVertexAttribArray(aPosLocBg);
  gl.vertexAttribPointer(aPosLocBg, 2, gl.FLOAT, false, 0, 0);

  gl.bindVertexArray(null);

  uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution");
  uFontLoc = gl.getUniformLocation(matrixProgram, "u_fontSize");
  uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize");
  uMouseLoc = gl.getUniformLocation(matrixProgram, "u_mouse");

  uBgTimeLoc = gl.getUniformLocation(bgProgram, "u_time");
  uBgResLoc = gl.getUniformLocation(bgProgram, "u_resolution");
  uBgDarkLoc = gl.getUniformLocation(bgProgram, "u_isDarkMode");
  uBgVpnLoc = gl.getUniformLocation(bgProgram, "u_isVpnOn");
  uBgLowQualityLoc = gl.getUniformLocation(bgProgram, "u_isLowQuality");

  atlasTexture = gl.createTexture();

  uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave");
  uPostEyePosLLoc = gl.getUniformLocation(postProgram, "u_eyePosL");
  uPostEyePosRLoc = gl.getUniformLocation(postProgram, "u_eyePosR");
  uPostEyeClosednessLoc = gl.getUniformLocation(postProgram, "u_eyeClosedness");
  uPostIsVpnOnLoc = gl.getUniformLocation(postProgram, "u_isVpnOn");
  uPostEyeActiveLoc = gl.getUniformLocation(postProgram, "u_eyeActive");
  uPostGazeLoc = gl.getUniformLocation(postProgram, "u_gaze");
  uVpnTimerLoc = gl.getUniformLocation(postProgram, "u_vpnTimer");
  uPostPixelScaleLoc = gl.getUniformLocation(postProgram, "u_pixelScale");

  postTexture = gl.createTexture();
  postFramebuffer = gl.createFramebuffer();
  gl.bindTexture(gl.TEXTURE_2D, postTexture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  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.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}

// Адаптивный pixel ratio: раз в 60 кадров смотрит на среднее время кадра
// (fpsHistory) и подстраивает currentPixelScale — физическое разрешение
// канваса относительно логических CSS-пикселей. Если рендер тормозит
// (avgDt > 25ms, то есть < 40 FPS) — снижает разрешение на шаг 0.1, отдавая
// приоритет плавности над чёткостью. Если, наоборот, есть запас (avgDt < 15ms,
// > 66 FPS) — плавно поднимает разрешение обратно до maxPixelScale (исходного
// devicePixelRatio с оглядкой на мобильный кап 1.25). Это дешёвая замена
// полноценному adaptive-quality пайплайну: вместо переключения качества
// эффектов просто меняется размер canvas и связанной с ним post-текстуры.
function updateDynamicScaling(now) {
  if (lastFrameTime === 0) lastFrameTime = now;
  const dt = now - lastFrameTime;
  lastFrameTime = now;
  fpsHistory.push(dt);
  if (fpsHistory.length > 60) fpsHistory.shift();
  scaleCheckTimer++;
  if (scaleCheckTimer >= 60) {
    const avgDt = fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length;
    let needsResize = false;
    if (avgDt > 25.0 && currentPixelScale > 0.75) {
      currentPixelScale -= 0.1;
      needsResize = true;
    } else if (avgDt < 15.0 && currentPixelScale < maxPixelScale) {
      currentPixelScale = Math.min(maxPixelScale, currentPixelScale + 0.25);
      needsResize = true;
    }
    if (needsResize) {
      gl.canvas.width = width * currentPixelScale;
      gl.canvas.height = height * currentPixelScale;
      gl.bindTexture(gl.TEXTURE_2D, postTexture);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.canvas.width,
        gl.canvas.height,
        0,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        null,
      );
    }
    scaleCheckTimer = 0;
  }
}

// Рисует текстурный атлас символов на OffscreenCanvas через обычный 2D-контекст
// (один раз при инициализации/ресайзе/смене темы — не каждый кадр). Атлас
// устроен как сетка chars.length колонок на 11 строк:
//   строки 0..9  — один и тот же набор символов, но с разной альфой/яркостью
//                  цвета темы (10 градаций "свечения" капли, атлас хранит
//                  готовый цвет пикселя, а не альфу отдельно — так фрагментный
//                  шейдер обходится одной выборкой текстуры без доп. блендинга);
//   строка 10    — те же символы, но полностью белым цветом — это "голова"
//                  капли (самый яркий пиксель, как в оригинальном Matrix rain).
// Такое разделение на 11 строк, а не 10 текстур, позволяет Rust-стороне
// выбирать нужный вариант символа одним числом atlas_row (см. STRIDE в
// lib.rs) и одной текстурной выборкой в fsMatrix, без создания отдельных
// текстур/юниформ на каждый уровень яркости.
function buildAtlas() {
  atlasCanvas = new OffscreenCanvas(chars.length * fontSize, fontSize * 11);
  atlasCtx = atlasCanvas.getContext("2d", { alpha: true });
  atlasCtx.clearRect(0, 0, atlasCanvas.width, atlasCanvas.height);
  let r, g, b;
  if (isVpnOn) {
    r = isDarkMode ? 178 : 106;
    g = isDarkMode ? 51 : 0;
    b = isDarkMode ? 255 : 209;
  } else {
    r = isDarkMode ? 0 : 0;
    g = isDarkMode ? 230 : 119;
    b = isDarkMode ? 255 : 182;
  }
  atlasCtx.font = `bold ${fontSize}px monospace`;
  atlasCtx.textBaseline = "top";
  atlasCtx.textAlign = "center";
  const halfFs = fontSize / 2;
  for (let row = 0; row <= 9; row++) {
    const alphaBase = isLowQuality ? 1.0 : (row + 1) / 10;
    const alpha = isDarkMode ? alphaBase : alphaBase * 0.8;
    atlasCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
    for (let i = 0; i < chars.length; i++)
      atlasCtx.fillText(chars[i], i * fontSize + halfFs, row * fontSize);
  }
  atlasCtx.fillStyle = "rgb(255, 255, 255)";
  for (let i = 0; i < chars.length; i++)
    atlasCtx.fillText(chars[i], i * fontSize + halfFs, 10 * fontSize);
  if (gl) {
    gl.bindTexture(gl.TEXTURE_2D, atlasTexture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      atlasCanvas,
    );
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  }
}

// Главный rAF-луп воркера. За кадр: (1) engine.tick() пересчитывает всю
// физику в WASM и заполняет буферы, (2) матрица+глитч-блоки рендерятся в
// offscreen postFramebuffer (первый проход), (3) экран очищается и через
// postProgram применяется полноэкранный пост-процесс к готовой текстуре
// (второй проход) — линзы вокруг глаз, CRT-дисторсия, хроматическая
// аберрация, VPN-тир-глитч. Кадр вручную троттлится до ~60 FPS (`now -
// lastDrawTime < 16` мс), а requestAnimationFrame планируется САМЫМ ПЕРВЫМ
// делом — так пропуск кадра из-за троттлинга не останавливает rAF-луп.
function render(currentTime) {
  if (isPaused) {
    rAF_ID = null;
    return;
  }
  rAF_ID = requestAnimationFrame(render);

  if (!engine || !wasm || !gl) return;
  const now = currentTime || performance.now();
  if (now - lastDrawTime < 16) return;
  lastDrawTime = now;
  updateDynamicScaling(now);
  engine.tick();

  const bgR = isDarkMode ? 10 / 255 : 250 / 255;
  const bgG = isDarkMode ? 10 / 255 : 250 / 255;
  const bgB = isDarkMode ? 12 / 255 : 252 / 255;

  gl.bindFramebuffer(gl.FRAMEBUFFER, postFramebuffer);
  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
  gl.clearColor(bgR, bgG, bgB, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  // Фоновая сцена глубины (город/дорога/машины) рисуется ПЕРВОЙ, полностью
  // непрозрачной, поверх clear-цвета — капли дождя рисуются следующим шагом
  // поверх неё с уже включённым альфа-блендингом (см. initWebGL), так что
  // дождь остаётся видуально таким же, но в промежутках между символами
  // теперь видна сцена вместо плоского фона.
  gl.useProgram(bgProgram);
  gl.bindVertexArray(vaoBg);
  gl.uniform1f(uBgTimeLoc, now / 1000);
  gl.uniform2f(uBgResLoc, width, height);
  gl.uniform1f(uBgDarkLoc, isDarkMode ? 1.0 : 0.0);
  gl.uniform1f(uBgVpnLoc, isVpnOn ? 1.0 : 0.0);
  gl.uniform1f(uBgLowQualityLoc, isLowQuality ? 1.0 : 0.0);
  gl.drawArrays(gl.TRIANGLES, 0, 6);

  const renderLen = engine.render_len();
  if (renderLen > 0) {
    const instanceCount = renderLen / 5;
    gl.useProgram(matrixProgram);
    gl.bindVertexArray(vaoMatrix);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, atlasTexture);
    gl.uniform1i(gl.getUniformLocation(matrixProgram, "u_atlas"), 0);
    gl.uniform2f(uResLoc, width, height);
    gl.uniform1f(uFontLoc, fontSize);
    gl.uniform2f(uAtlasSizeLoc, atlasCanvas.width, atlasCanvas.height);
    gl.uniform2f(uMouseLoc, mouseX / width, mouseY / height);

    // Приёмная сторона протокола памяти WASM<->JS (см. doc-комментарий
    // MatrixEngine в lib.rs): Float32Array строится ПРЯМО поверх линейной
    // памяти WASM без копирования данных, используя указатель и длину,
    // отданные engine.render_ptr()/render_len(). Этот TypedArray валиден
    // только до следующего вызова engine.tick() (рост Vec в Rust может
    // переместить память), поэтому view создаётся заново каждый кадр и сразу
    // же скармливается в bufferSubData — не сохраняется между кадрами.
    const renderDataView = new Float32Array(
      wasm.memory.buffer,
      engine.render_ptr(),
      renderLen,
    );
    gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
    if (renderLen > instanceBufferCapacity) {
      instanceBufferCapacity = Math.max(renderLen * 2, 20000);
      gl.bufferData(
        gl.ARRAY_BUFFER,
        instanceBufferCapacity * 4,
        gl.DYNAMIC_DRAW,
      );
    }
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, renderDataView);
    gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, instanceCount);
  }

  const glLen = engine.glitch_len();
  if (glLen > 0) {
    const glitchInstanceCount = glLen / 5;
    gl.useProgram(glitchProgram);
    gl.bindVertexArray(vaoGlitch);
    let tr, tg, tb;
    if (isVpnOn && isDarkMode) {
      tr = 178 / 255;
      tg = 51 / 255;
      tb = 255 / 255;
    } else if (!isVpnOn && isDarkMode) {
      tr = 0;
      tg = 230 / 255;
      tb = 255 / 255;
    } else if (isVpnOn && !isDarkMode) {
      tr = 106 / 255;
      tg = 0;
      tb = 209 / 255;
    } else {
      tr = 0;
      tg = 119 / 255;
      tb = 182 / 255;
    }

    gl.uniform3f(
      gl.getUniformLocation(glitchProgram, "u_themeMain"),
      tr,
      tg,
      tb,
    );
    gl.uniform3f(
      gl.getUniformLocation(glitchProgram, "u_themeBg"),
      bgR,
      bgG,
      bgB,
    );
    gl.uniform2f(
      gl.getUniformLocation(glitchProgram, "u_resolution"),
      width,
      height,
    );

    const glDataView = new Float32Array(
      wasm.memory.buffer,
      engine.glitch_ptr(),
      glLen,
    );
    gl.bindBuffer(gl.ARRAY_BUFFER, glitchInstanceBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, glDataView, gl.DYNAMIC_DRAW);
    gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, glitchInstanceCount);
  }

  const eyeLen = engine.eye_len();
  let eyeActive = 0.0,
    eyeClosedness = 1.0,
    posLX = 0,
    posLY = 0,
    posRX = 0,
    posRY = 0,
    normGazeX = 0,
    normGazeY = 0;
  if (eyeLen >= 6 && showEyes) {
    const eyeData = new Float32Array(
      wasm.memory.buffer,
      engine.eye_ptr(),
      eyeLen,
    );
    eyeClosedness = eyeData[5];
    eyeActive = 1.0;

    let dynamicGap;
    if (width <= 768) {
      // На мобилках (узких экранах) используем проценты,
      // но ограничиваем, чтобы не слипались совсем
      dynamicGap = Math.max(50, width * 0.18);
    } else {
      // На десктопе используем фиксированный комфортный отступ
      dynamicGap = 110;
    }
    posLX = eyeData[0] - dynamicGap;
    posLY = eyeData[1];
    posRX = eyeData[0] + dynamicGap;
    posRY = eyeData[1];

    normGazeX = eyeData[2] / 15.0;
    normGazeY = eyeData[3] / -10.0;
  }

  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
  gl.useProgram(postProgram);
  gl.bindVertexArray(vaoOverlay);
  gl.activeTexture(gl.TEXTURE2);
  gl.bindTexture(gl.TEXTURE_2D, postTexture);
  gl.uniform1i(gl.getUniformLocation(postProgram, "u_mainTex"), 2);
  gl.uniform1f(gl.getUniformLocation(postProgram, "u_time"), now / 1000);
  gl.uniform2f(
    gl.getUniformLocation(postProgram, "u_resolution"),
    width,
    height,
  );
  gl.uniform1f(gl.getUniformLocation(postProgram, "u_distortion"), 18.0);
  gl.uniform1f(
    gl.getUniformLocation(postProgram, "u_isDarkMode"),
    isDarkMode ? 1.0 : 0.0,
  );
  gl.uniform1f(
    gl.getUniformLocation(postProgram, "u_isLowQuality"),
    isLowQuality ? 1.0 : 0.0,
  );
  gl.uniform1f(uPostEyeActiveLoc, eyeActive);
  gl.uniform1f(uPostPixelScaleLoc, currentPixelScale);

  if (eyeActive > 0.5) {
    gl.uniform2f(uPostEyePosLLoc, posLX, posLY);
    gl.uniform2f(uPostEyePosRLoc, posRX, posRY);
    gl.uniform1f(uPostEyeClosednessLoc, eyeClosedness);
    gl.uniform1f(uPostIsVpnOnLoc, isVpnOn ? 1.0 : 0.0);
    gl.uniform2f(uPostGazeLoc, normGazeX, normGazeY);
  }

  if (vpnTimer > 0.0) vpnTimer = Math.max(0.0, vpnTimer - 0.015);
  gl.uniform1f(uVpnTimerLoc, vpnTimer);

  let sw_x = -1.0,
    sw_y = -1.0,
    sw_r = 0.0;
  if (engine.has_active_shockwave && engine.has_active_shockwave()) {
    sw_x = engine.get_shockwave_x() / width;
    sw_y = engine.get_shockwave_y() / height;
    sw_r = engine.get_shockwave_radius() / width;
  }
  gl.uniform3f(uShockwaveLoc, sw_x, 1.0 - sw_y, sw_r);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

// Приёмная сторона message-протокола Main thread -> Worker (типы сообщений
// и их назначение описаны в doc-комментарии NetrunnerMatrix.tsx). INIT —
// единственная точка, где создаётся WebGL-контекст и грузится WASM-модуль;
// все остальные типы предполагают, что INIT уже отработал (для большинства
// есть проверка `if (engine)`/`if (!engine) return`).
self.onmessage = async (e) => {
  const { type, payload } = e.data;

  if (type === "INIT") {
    gl = payload.canvas.getContext("webgl2", {
      alpha: false,
      antialias: false,
      powerPreference: "high-performance",
    });
    width = payload.logicalWidth;
    height = payload.logicalHeight;
    pixelScale = payload.pixelScale;
    maxPixelScale = pixelScale;
    currentPixelScale = pixelScale;
    isMobile = payload.isMobile;
    isLowQuality = payload.isLowQuality;
    eyeScale = isMobile ? 1.5 : 1.8;
    isVpnOn = payload.isVpnOn;
    isDarkMode = payload.isDarkMode;
    fontSize = payload.fontSize || 16;
    showEyes = payload.showEyes !== false;

    // ВАЖНО: Устанавливаем физический размер канваса ПЕРЕД инициализацией текстур
    gl.canvas.width = width * currentPixelScale;
    gl.canvas.height = height * currentPixelScale;

    initWebGL();

    // Инициализируем текстуру пост-процессинга ПРАВИЛЬНЫМ размером сразу
    gl.bindTexture(gl.TEXTURE_2D, postTexture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      gl.canvas.width,
      gl.canvas.height,
      0,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      null,
    );
    gl.bindFramebuffer(gl.FRAMEBUFFER, postFramebuffer);
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_2D,
      postTexture,
      0,
    );

    buildAtlas();

    try {
      const cleanPath = payload.assetsPath.endsWith("/")
        ? payload.assetsPath
        : `${payload.assetsPath}/`;
      const module = await import(`${cleanPath}matrix_engine.js?v=${version}`);
      wasm = await module.default({
        module_or_path: `${cleanPath}matrix_engine_bg.wasm?v=${version}`,
      });
      engine = new module.MatrixEngine(width, height, fontSize);
      if (typeof engine.set_mobile === "function") engine.set_mobile(isMobile);
      if (typeof engine.set_vpn_status === "function")
        engine.set_vpn_status(isVpnOn);
      if (payload.eyeY && typeof engine.set_eye_anchor === "function")
        engine.set_eye_anchor(payload.eyeY);

      for (let i = 0; i < 300; i++) engine.tick();
      if (!isPaused) rAF_ID = requestAnimationFrame(render);
      self.postMessage({ type: "READY" });
    } catch (err) {
      console.error("Critical failure loading WASM core:", err);
    }
  } else if (type === "PAUSE") {
    isPaused = true;
    if (rAF_ID !== null) {
      cancelAnimationFrame(rAF_ID);
      rAF_ID = null;
    }
  } else if (type === "RESUME") {
    if (isPaused) {
      isPaused = false;
      const now = performance.now();
      lastDrawTime = now;
      lastFrameTime = now;
      if (rAF_ID === null && engine) rAF_ID = requestAnimationFrame(render);
    }
  } else if (type === "RESIZE") {
    if (!engine) return;
    width = payload.logicalWidth;
    height = payload.logicalHeight;
    currentPixelScale = payload.pixelScale;
    maxPixelScale = currentPixelScale;
    gl.canvas.width = width * currentPixelScale;
    gl.canvas.height = height * currentPixelScale;
    gl.bindTexture(gl.TEXTURE_2D, postTexture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      gl.canvas.width,
      gl.canvas.height,
      0,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      null,
    );
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
    engine.resize(width, height);
    buildAtlas();
  } else if (type === "THEME") {
    isVpnOn = payload.isVpnOn;
    isDarkMode = payload.isDarkMode;
    vpnTimer = 1.0;
    if (engine) {
      if (typeof engine.set_vpn_status === "function")
        engine.set_vpn_status(isVpnOn);
      buildAtlas();
    }
  } else if (type === "MOUSE_MOVE") {
    mouseX = payload.x;
    mouseY = payload.y;
    if (engine) engine.update_mouse(payload.x, payload.y);
  } else if (type === "DRAW_START") {
    if (engine) {
      engine.set_drawing(true);
      if (Math.random() > 0.5) engine.trigger_glitch(payload.x, payload.y);
      else engine.trigger_shockwave(payload.x, payload.y);
    }
  } else if (type === "DRAW_END") {
    if (engine) engine.set_drawing(false);
  } else if (type === "SHOCKWAVE") {
    if (engine) engine.trigger_shockwave(payload.x, payload.y);
  } else if (type === "TILT") {
    if (engine && typeof engine.set_tilt === "function")
      engine.set_tilt(payload.x, payload.y);
  }
};
