'use strict';
// VK community cover generator — a pseudo-3D perspective view of an Эрудит board with the
// opening words ЭРУДИЦИЯ / ГРАМОТНОСТЬ / СОПЕРНИК / УМ already played.
//
// The board is a real plane in world space: the flat board texture and the flat tile-top
// texture are drawn once at high resolution and then warped by an exact per-scanline
// perspective transform (the camera has pitch only — no roll, no yaw — so every board-space
// horizontal line stays a screen-space horizontal line and the warp is a pure row remap).
// Tile thickness is drawn as real 3D quads between the two warped planes.
//
// Usage:
//   node build-cover.mjs                 # both covers -> cover-<id>.png
//   node build-cover.mjs night-spot      # one of them
//
// skia-canvas is not installed here: the render sidecar already vendors it and this is a
// one-off asset build, so we borrow that copy rather than adding a second native module.

import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const DIR = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { Canvas, Image, FontLibrary } = require(join(DIR, '..', '..', '..', 'renderer', 'node_modules', 'skia-canvas'));

FontLibrary.use('EruditBrand', [join(DIR, '..', '..', 'icons', 'brand', 'Spectral-Bold.ttf')]);

// ---------------------------------------------------------------------------- game state

/** BOARD_SIZE is the Эрудит board side in cells. */
const BOARD_SIZE = 15;

/** ERUDIT_BOARD is the Эрудит premium layout (ui/src/lib/premiums.ts): T/D word, t/d letter,
 *  '+' the non-doubling centre. */
const ERUDIT_BOARD = [
  'T..d...T...d..T',
  '.D...t...t...D.',
  '..D...d.d...D..',
  'd..D...d...D..d',
  '....D.....D....',
  '.t...t...t...t.',
  '..d...d.d...d..',
  'T..d...+...d..T',
  '..d...d.d...d..',
  '.t...t...t...t.',
  '....D.....D....',
  'd..D...d...D..d',
  '..D...d.d...D..',
  '.D...t...t...D.',
  'T..d...T...d..T',
];

/** VALUES maps a Cyrillic letter to its Эрудит tile value (scrabble-solver rules.Erudit). */
const VALUES = {
  А: 1, Б: 3, В: 2, Г: 3, Д: 2, Е: 1, Ж: 5, З: 5, И: 1, Й: 2, К: 2, Л: 2, М: 2, Н: 1, О: 1,
  П: 2, Р: 2, С: 2, Т: 2, У: 3, Ф: 10, Х: 5, Ц: 10, Ч: 5, Ш: 10, Щ: 10, Ъ: 10, Ы: 5, Ь: 5,
  Э: 10, Ю: 10, Я: 3,
};

/**
 * OPENINGS are the legal positions the cover can show. In both, ГРАМОТНОСТЬ opens through
 * the centre (7,7 — its Т) and every other word hooks onto a shared letter, with no
 * incidental cross-word anywhere.
 *
 * - `ladder` (default): ЭРУДИЦИЯ runs horizontally low, sharing СОПЕРНИК's И. Two long
 *   horizontals suit the 2.5:1 cover, and the bottom-left stays empty — which is where VK
 *   overlays the community avatar in its mobile app.
 * - `cross`: ЭРУДИЦИЯ and СОПЕРНИК both hang vertically off ГРАМОТНОСТЬ. More symmetric,
 *   but its left column runs into that avatar.
 */
const OPENINGS = {
  ladder: [
    { word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' },
    { word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' },
    { word: 'ЭРУДИЦИЯ', row: 12, col: 5, dir: 'H' },
    { word: 'УМ', row: 6, col: 5, dir: 'V' },
  ],
  cross: [
    { word: 'ГРАМОТНОСТЬ', row: 7, col: 2, dir: 'H' },
    { word: 'ЭРУДИЦИЯ', row: 6, col: 3, dir: 'V' },
    { word: 'СОПЕРНИК', row: 6, col: 9, dir: 'V' },
    { word: 'УМ', row: 6, col: 5, dir: 'V' },
  ],
};

/** PLAYS is the opening this run renders (LAYOUT=cross switches to the other one). */
const PLAYS = OPENINGS[process.env.LAYOUT ?? 'ladder'];

/** buildBoard lays PLAYS out on an empty grid and fails loudly on a contradiction, so a
 *  future edit of PLAYS cannot silently produce an illegal position. */
function buildBoard() {
  const g = Array.from({ length: BOARD_SIZE }, () => new Array(BOARD_SIZE).fill(null));
  for (const p of PLAYS) {
    const letters = Array.from(p.word);
    for (let i = 0; i < letters.length; i++) {
      const r = p.row + (p.dir === 'V' ? i : 0);
      const c = p.col + (p.dir === 'H' ? i : 0);
      if (r >= BOARD_SIZE || c >= BOARD_SIZE) throw new Error(`${p.word} runs off the board`);
      if (g[r][c] && g[r][c] !== letters[i]) {
        throw new Error(`${p.word} clashes at ${r},${c}: ${g[r][c]} vs ${letters[i]}`);
      }
      g[r][c] = letters[i];
    }
  }
  assertLegal(g);
  return g;
}

/**
 * assertLegal checks the finished grid the way the engine would: the centre must be covered,
 * and every maximal run of two or more letters — in either direction — must be one of the
 * words in PLAYS. That is what rules out an incidental two-letter cross-word appearing
 * beside a hooked word, which reading PLAYS alone cannot tell you.
 */
function assertLegal(g) {
  if (!g[7][7]) throw new Error('the opening does not cover the centre square');
  const declared = new Set(PLAYS.map((p) => p.word));
  const runs = [];
  for (let i = 0; i < BOARD_SIZE; i++) {
    for (const read of [(k) => g[i][k], (k) => g[k][i]]) {
      let run = '';
      for (let k = 0; k <= BOARD_SIZE; k++) {
        const ch = k < BOARD_SIZE ? read(k) : null;
        if (ch) run += ch;
        else {
          if (run.length > 1) runs.push(run);
          run = '';
        }
      }
    }
  }
  for (const w of runs) if (!declared.has(w)) throw new Error(`incidental word formed: ${w}`);
  for (const w of declared) if (!runs.includes(w)) throw new Error(`${w} is not on the board`);
}

// ------------------------------------------------------------------------------ geometry

/** World units are board cells: the 15x15 grid is centred on the origin, +x right, +y away
 *  from the camera, +z up out of the board. */
const HALF_GRID = BOARD_SIZE / 2;

/** FRAME_CELLS is the wooden border around the playfield, in cells. */
const FRAME_CELLS = 0.5;

/** HALF_BOARD is the half-side of the whole slab (playfield plus frame). */
const HALF_BOARD = HALF_GRID + FRAME_CELLS;

/**
 * makeCamera builds a pitch-only pinhole camera looking at the board centre from elevation
 * elevDeg at distance dist; focal is in output pixels and (cx, cy) is the screen point the
 * board centre lands on.
 */
function makeCamera({ elevDeg, dist, focal, cx, cy }) {
  const e = (elevDeg * Math.PI) / 180;
  const se = Math.sin(e);
  const ce = Math.cos(e);
  return {
    project(x, y, z) {
      const vy = y + dist * ce;
      const vz = z - dist * se;
      const Yc = vy * se + vz * ce;
      const Zc = vy * ce - vz * se;
      return [cx + (focal * x) / Zc, cy - (focal * Yc) / Zc, Zc];
    },
    /** boardYAt inverts a screen scanline back to the board y it came from, on plane z. */
    boardYAt(sy, z) {
      const k = z - dist * se;
      const t = (cy - sy) / focal;
      const den = t * ce - se;
      if (Math.abs(den) < 1e-9) return Infinity;
      return (k * (ce + t * se)) / den - dist * ce;
    },
  };
}

/**
 * frameCamera turns the art-direction knobs into a camera: elevDeg and dist set the look,
 * widthFrac says how much of the output width the board's NEAR edge — its widest point on
 * screen — should span, and
 * (aimY, aimXFrac, aimYFrac) put the board-space row aimY at that point of the frame, which
 * is how a variant keeps the played words centred while the board itself bleeds off-canvas.
 */
function frameCamera(W, H, c) {
  // Depth of a board-plane row: Zc(y, 0) = dist + y·cos(elev); the near edge is the widest.
  const nearZ = c.dist - HALF_BOARD * Math.cos((c.elevDeg * Math.PI) / 180);
  const focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;

  // With cy = 0 the projected y is exactly proportional to the focal length, so aiming is a
  // closed form: project the target row through a unit-focal camera and scale by the focal.
  const unit = makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal: 1, cx: 0, cy: 0 });
  const cy = (c.aimYFrac ?? 0.5) * H - focal * unit.project(0, c.aimY ?? 0, 0)[1];
  // aimX is a board column placed at aimXFrac, so a variant can slide the played words in
  // the frame without dragging the whole slab off one side.
  const cx = (c.aimXFrac ?? 0.5) * W - focal * unit.project(c.aimX ?? 0, c.aimY ?? 0, 0)[0];
  return makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal, cx, cy });
}

/**
 * warpPlane draws the flat texture src — which covers the board-space rectangle
 * x ∈ [-halfW, halfW], y ∈ [-halfW, halfW] with texture row 0 at the FAR edge (y = +halfW)
 * — onto ctx through cam, as the plane at height z. Output is written in bands of `step`
 * pixels; the source strip height follows the local compression so the receding rows are
 * filtered down rather than point-sampled.
 */
function warpPlane(ctx, src, cam, z, halfW, step, clipH) {
  const yTop = cam.project(0, halfW, z)[1];
  const yBot = cam.project(0, -halfW, z)[1];
  const span = 2 * halfW;
  const from = Math.max(-step, Math.floor(yTop));
  const to = Math.min(clipH + step, Math.ceil(yBot));
  for (let sy = from; sy <= to; sy += step) {
    const y0 = cam.boardYAt(sy, z);
    const y1 = cam.boardYAt(sy + step, z);
    if (!isFinite(y0) || !isFinite(y1) || y1 > y0) continue;
    const v0 = ((halfW - y0) / span) * src.height;
    const v1 = ((halfW - y1) / span) * src.height;
    if (v1 <= 0 || v0 >= src.height) continue;
    const top = Math.max(0, v0);
    const h = Math.min(src.height, v1) - top;
    if (h <= 0) continue;
    const xL = cam.project(-halfW, y0, z)[0];
    const xR = cam.project(halfW, y0, z)[0];
    ctx.drawImage(src, 0, top, src.width, h, xL, sy, xR - xL, step + 0.6);
  }
}

/** quad fills the screen-space polygon of the given world-space corners. */
function quad(ctx, cam, pts, fill) {
  ctx.beginPath();
  pts.forEach(([x, y, z], i) => {
    const [sx, sy] = cam.project(x, y, z);
    if (i === 0) ctx.moveTo(sx, sy);
    else ctx.lineTo(sx, sy);
  });
  ctx.closePath();
  ctx.fillStyle = fill;
  ctx.fill();
}

// ----------------------------------------------------------------------------- utilities

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

/** mix blends two #rrggbb colours, t = 0 → a, t = 1 → b. */
function mix(a, b, t) {
  const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
  const [ar, ag, ab] = p(a);
  const [br, bg, bb] = p(b);
  const h = (v) => Math.round(v).toString(16).padStart(2, '0');
  return `#${h(ar + (br - ar) * t)}${h(ag + (bg - ag) * t)}${h(ab + (bb - ab) * t)}`;
}

/**
 * freeze bakes a canvas into an immutable Image. warpPlane samples its source hundreds of
 * times per plane and skia re-snapshots a live Canvas source on every drawImage — two orders
 * of magnitude slower than sampling a decoded Image.
 */
function freeze(canvas) {
  const img = new Image();
  img.src = canvas.toBufferSync('png');
  return img;
}

/**
 * blurred renders draw(ctx) into an offscreen w×h canvas downscaled by `shrink`, blurs it in
 * a SINGLE pass and returns that small canvas. skia applies ctx.filter per drawing call, so
 * blurring N shapes one by one costs N full-canvas layers — minutes at cover resolution;
 * this keeps it to one blur over a small surface.
 */
function blurred(w, h, radius, shrink, draw) {
  const cv = new Canvas(Math.max(1, Math.round(w / shrink)), Math.max(1, Math.round(h / shrink)));
  const ctx = cv.getContext('2d');
  ctx.scale(1 / shrink, 1 / shrink);
  draw(ctx);
  const out = new Canvas(cv.width, cv.height);
  const octx = out.getContext('2d');
  octx.filter = `blur(${radius / shrink}px)`;
  octx.drawImage(cv, 0, 0);
  return out;
}

// ------------------------------------------------------------------------ flat  textures

/** TEX_CELL is the flat-texture resolution of one board cell, in texture pixels. */
const TEX_CELL = 128;

/** TEX_PAD is the frame width in texture pixels. */
const TEX_PAD = Math.round(FRAME_CELLS * TEX_CELL);

/** TEX_SIDE is the full flat-texture side (playfield plus both frame edges). */
const TEX_SIDE = BOARD_SIZE * TEX_CELL + 2 * TEX_PAD;

/** boardTexture renders the board surface — frame, premium squares, grid, centre star and
 *  the tiles' contact shadows — as seen straight down; row 0 sits at the texture top, which
 *  warpPlane maps to the far edge. */
function boardTexture(pal, grid) {
  const play = BOARD_SIZE * TEX_CELL;
  const cv = new Canvas(TEX_SIDE, TEX_SIDE);
  const ctx = cv.getContext('2d');

  // Wooden frame: a flat fill plus a diagonal light/shade so it does not read as cardboard.
  ctx.fillStyle = pal.frame;
  ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE);
  const fg = ctx.createLinearGradient(0, 0, TEX_SIDE, TEX_SIDE);
  fg.addColorStop(0, 'rgba(255,255,255,0.12)');
  fg.addColorStop(1, 'rgba(0,0,0,0.18)');
  ctx.fillStyle = fg;
  ctx.fillRect(0, 0, TEX_SIDE, TEX_SIDE);

  for (let r = 0; r < BOARD_SIZE; r++) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      const ch = ERUDIT_BOARD[r][c];
      const prem = { T: pal.prem.TW, D: pal.prem.DW, t: pal.prem.TL, d: pal.prem.DL }[ch];
      // A covered square is painted neutral, not premium: the tile face is inset and rounded,
      // so a bonus colour underneath leaks out as a saturated hairline around every tile.
      ctx.fillStyle = grid[r][c]
        ? mix(pal.cell, '#000000', 0.3)
        : (prem ?? ((r + c) % 2 === 1 ? pal.cellDark : pal.cell));
      ctx.fillRect(TEX_PAD + c * TEX_CELL, TEX_PAD + r * TEX_CELL, TEX_CELL, TEX_CELL);
    }
  }

  ctx.strokeStyle = pal.grid;
  ctx.lineWidth = Math.max(1, TEX_CELL * 0.018);
  for (let i = 0; i <= BOARD_SIZE; i++) {
    const p = TEX_PAD + i * TEX_CELL;
    ctx.beginPath();
    ctx.moveTo(p, TEX_PAD);
    ctx.lineTo(p, TEX_PAD + play);
    ctx.moveTo(TEX_PAD, p);
    ctx.lineTo(TEX_PAD + play, p);
    ctx.stroke();
  }
  if (!grid[7][7]) {
    ctx.fillStyle = pal.premText;
    ctx.globalAlpha = 0.5;
    ctx.font = `${TEX_CELL * 0.6}px EruditBrand`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('✻', TEX_PAD + 7.5 * TEX_CELL, TEX_PAD + 7.5 * TEX_CELL);
    ctx.globalAlpha = 1;
  }
  ctx.strokeStyle = 'rgba(0,0,0,0.3)';
  ctx.lineWidth = TEX_CELL * 0.05;
  ctx.strokeRect(TEX_PAD, TEX_PAD, play, play);

  // Contact shadows, cast down-right along the board plane (light from the upper left).
  const sh = blurred(TEX_SIDE, TEX_SIDE, TEX_CELL * 0.11, 2, (sctx) => {
    sctx.fillStyle = 'rgba(0,0,0,0.5)';
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        if (!grid[r][c]) continue;
        roundRect(
          sctx,
          TEX_PAD + c * TEX_CELL + TEX_CELL * 0.09,
          TEX_PAD + r * TEX_CELL + TEX_CELL * 0.16,
          TEX_CELL * 0.95,
          TEX_CELL * 0.95,
          TEX_CELL * 0.12,
        );
        sctx.fill();
      }
    }
  });
  ctx.drawImage(sh, 0, 0, TEX_SIDE, TEX_SIDE);
  return cv;
}

/** tileTexture renders only the tile faces (letter + value), aligned to the same board
 *  rectangle as boardTexture so it can be warped as the plane at tile height. */
function tileTexture(pal, grid) {
  const cv = new Canvas(TEX_SIDE, TEX_SIDE);
  const ctx = cv.getContext('2d');
  const inset = TEX_CELL * TILE_INSET;
  const w = TEX_CELL - 2 * inset;
  const rad = TEX_CELL * 0.12;

  for (let r = 0; r < BOARD_SIZE; r++) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      const letter = grid[r][c];
      if (!letter) continue;
      const x = TEX_PAD + c * TEX_CELL + inset;
      const y = TEX_PAD + r * TEX_CELL + inset;

      const g = ctx.createLinearGradient(x, y, x + w, y + w);
      g.addColorStop(0, mix(pal.tile, '#ffffff', 0.18));
      g.addColorStop(1, mix(pal.tile, '#000000', 0.07));
      ctx.fillStyle = g;
      roundRect(ctx, x, y, w, w, rad);
      ctx.fill();

      // Chamfer: a bright top-left rim and a dark bottom-right rim inside the face.
      ctx.save();
      roundRect(ctx, x, y, w, w, rad);
      ctx.clip();
      ctx.lineWidth = w * 0.06;
      ctx.strokeStyle = 'rgba(255,255,255,0.6)';
      ctx.beginPath();
      ctx.moveTo(x + rad * 0.4, y + w - rad * 0.4);
      ctx.lineTo(x + rad * 0.4, y + rad * 0.4);
      ctx.lineTo(x + w - rad * 0.4, y + rad * 0.4);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(0,0,0,0.22)';
      ctx.beginPath();
      ctx.moveTo(x + w - rad * 0.4, y + rad * 0.4);
      ctx.lineTo(x + w - rad * 0.4, y + w - rad * 0.4);
      ctx.lineTo(x + rad * 0.4, y + w - rad * 0.4);
      ctx.stroke();
      ctx.restore();

      // Letter in the top-left corner, value in the bottom-right — the in-game tile layout.
      // The glyph is placed by its INK box, not by the text origin, so every letter lines up
      // on the same corner regardless of its side bearings and ascent.
      ctx.fillStyle = pal.tileText;
      ctx.font = `${TEX_CELL * 0.74}px EruditBrand`;
      ctx.textAlign = 'left';
      ctx.textBaseline = 'alphabetic';
      const m = ctx.measureText(letter);
      ctx.fillText(letter, x + w * 0.1 + m.actualBoundingBoxLeft, y + w * 0.1 + m.actualBoundingBoxAscent);

      ctx.font = `600 ${TEX_CELL * 0.23}px DejaVu Sans`;
      ctx.textAlign = 'right';
      ctx.globalAlpha = 0.8;
      ctx.fillText(String(VALUES[letter] ?? 0), x + w * 0.91, y + w * 0.93);
      ctx.globalAlpha = 1;
    }
  }
  return cv;
}

// -------------------------------------------------------------------------------- scenes

/** TILE_INSET is the gap between a tile and its cell edge, as a fraction of the cell. */
const TILE_INSET = 0.02;

/** SLAB is the board's own thickness in cells. */
const SLAB = 0.55;

/**
 * drawScene composes one cover onto ctx at the given supersampled size: background, board
 * slab, warped board surface, extruded tile sides, warped tile faces, then the variant's
 * post effects. `step` is the warp band height in device pixels.
 */
function drawScene(ctx, W, H, v, grid, step) {
  const pal = v.palette;
  const cam = frameCamera(W, H, v.cam);

  if (process.env.DEBUG) {
    const far = cam.project(-HALF_BOARD, HALF_BOARD, 0);
    const near = cam.project(-HALF_BOARD, -HALF_BOARD, 0);
    const f = (n, d) => (n / d).toFixed(3);
    console.log(
      `  ${v.id}: board x ${f(near[0], W)}..${f(W - near[0], W)} y ${f(far[1], H)}..${f(near[1], H)}`,
    );
  }

  v.background(ctx, W, H, pal);

  // Ground shadow under the slab, then the two visible slab walls.
  const gs = blurred(W, H, W * 0.014, 8, (sctx) => {
    sctx.beginPath();
    const ring = [
      [-HALF_BOARD * 1.02, HALF_BOARD, -SLAB],
      [HALF_BOARD * 1.02, HALF_BOARD, -SLAB],
      [HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB],
      [-HALF_BOARD * 1.09, -HALF_BOARD * 1.06, -SLAB],
    ];
    ring.forEach(([x, y, z], i) => {
      const [sx, sy] = cam.project(x, y, z);
      if (i === 0) sctx.moveTo(sx, sy);
      else sctx.lineTo(sx, sy);
    });
    sctx.closePath();
    sctx.fillStyle = 'rgba(0,0,0,0.38)';
    sctx.fill();
  });
  ctx.drawImage(gs, 0, 0, W, H);

  quad(ctx, cam, [
    [-HALF_BOARD, -HALF_BOARD, 0],
    [HALF_BOARD, -HALF_BOARD, 0],
    [HALF_BOARD, -HALF_BOARD, -SLAB],
    [-HALF_BOARD, -HALF_BOARD, -SLAB],
  ], mix(pal.frame, '#000000', 0.45));
  for (const s of [-1, 1]) {
    quad(ctx, cam, [
      [s * HALF_BOARD, -HALF_BOARD, 0],
      [s * HALF_BOARD, HALF_BOARD, 0],
      [s * HALF_BOARD, HALF_BOARD, -SLAB],
      [s * HALF_BOARD, -HALF_BOARD, -SLAB],
    ], mix(pal.frame, '#000000', 0.3));
  }

  warpPlane(ctx, freeze(boardTexture(pal, grid)), cam, 0, HALF_BOARD, step, H);

  // Tile extrusion: the near wall always, plus whichever lateral wall faces the camera.
  const th = v.tileHeight ?? 0.17;
  for (let r = BOARD_SIZE - 1; r >= 0; r--) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      if (!grid[r][c]) continue;
      const x0 = -HALF_GRID + c + TILE_INSET;
      const x1 = -HALF_GRID + c + 1 - TILE_INSET;
      const y1 = HALF_GRID - r - TILE_INSET;
      const y0 = HALF_GRID - r - 1 + TILE_INSET;
      // The walls run from just under the board plane to just over the tile plane: both
      // planes are rasterised in scanline bands, so an exact z match leaves hairline gaps.
      const zb = -0.02;
      const zt = th + 0.02;
      quad(ctx, cam, [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]], pal.sideNear);
      if (x0 > 0) {
        quad(ctx, cam, [[x0, y0, zb], [x0, y1, zb], [x0, y1, zt], [x0, y0, zt]], pal.sideLat);
      } else if (x1 < 0) {
        quad(ctx, cam, [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]], pal.sideLat);
      }
    }
  }

  warpPlane(ctx, freeze(tileTexture(pal, grid)), cam, th, HALF_BOARD, step, H);

  v.effects?.(ctx, W, H, pal);
}

// ------------------------------------------------------------------- background helpers

function linear(ctx, W, H, stops, x0, y0, x1, y1) {
  const g = ctx.createLinearGradient(x0, y0, x1, y1);
  stops.forEach(([p, c]) => g.addColorStop(p, c));
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);
}

function radialGlow(ctx, W, H, cx, cy, r, inner, outer) {
  const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
  g.addColorStop(0, inner);
  g.addColorStop(1, outer);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);
}

function vignette(ctx, W, H, strength) {
  const g = ctx.createRadialGradient(W / 2, H * 0.55, H * 0.3, W / 2, H * 0.55, W * 0.7);
  g.addColorStop(0, 'rgba(0,0,0,0)');
  g.addColorStop(1, `rgba(0,0,0,${strength})`);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);
}

/** farHaze fades the receding top of the frame into the background colour, which reads as
 *  aerial depth without the cost of a real depth-of-field blur. */
function farHaze(ctx, W, H, rgb, until, strength) {
  const g = ctx.createLinearGradient(0, 0, 0, H * until);
  g.addColorStop(0, `rgba(${rgb},${strength})`);
  g.addColorStop(1, `rgba(${rgb},0)`);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H * until);
}

/** nearFade does the same at the bottom edge, so a board that bleeds out of frame dissolves
 *  instead of ending on a hard crop. */
function nearFade(ctx, W, H, rgb, from, strength) {
  const g = ctx.createLinearGradient(0, H * from, 0, H);
  g.addColorStop(0, `rgba(${rgb},0)`);
  g.addColorStop(1, `rgba(${rgb},${strength})`);
  ctx.fillStyle = g;
  ctx.fillRect(0, H * from, W, H * (1 - from));
}

// ------------------------------------------------------------------------------ palettes

const WARM = {
  frame: '#8a5a30',
  cell: '#e7ece8',
  cellDark: '#d9deda',
  grid: 'rgba(0,0,0,0.10)',
  prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' },
  premText: '#2a2113',
  tile: '#f4e2b8',
  tileText: '#2a2113',
  sideNear: '#c1a068',
  sideLat: '#d5bd8b',
};

const NIGHT = {
  ...WARM,
  frame: '#3d2915',
  cell: '#2a3330',
  cellDark: '#232b29',
  grid: 'rgba(255,255,255,0.07)',
  prem: { TW: '#a8483d', DW: '#8f625e', TL: '#3a6291', DL: '#546f88' },
  premText: '#f2d9a0',
  tile: '#eed9ab',
  tileText: '#241b0e',
  sideNear: '#a3854f',
  sideLat: '#bda372',
};


// ------------------------------------------------------------------------------ variants

/** WORDS_Y is the board-space y of the played cluster's middle row (rows 6..13), the point a
 *  variant aims the camera at when the board is allowed to bleed out of frame. */
const WORDS_Y = HALF_GRID - 10;

const VARIANTS = [
  {
    id: 'night-spot',
    title: 'Ночь — доска в луче света, слова крупно',
    palette: NIGHT,
    cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
    background(ctx, W, H) {
      linear(ctx, W, H, [[0, '#0c1411'], [0.6, '#131e19'], [1, '#070c0a']], 0, 0, 0, H);
      radialGlow(ctx, W, H, W * 0.5, H * 0.55, W * 0.5, 'rgba(255,214,150,0.26)', 'rgba(255,214,150,0)');
    },
    effects(ctx, W, H) {
      farHaze(ctx, W, H, '12,20,17', 0.5, 0.9);
      nearFade(ctx, W, H, '7,12,10', 0.82, 0.75);
      vignette(ctx, W, H, 0.6);
    },
  },
  {
    id: 'day-spot',
    title: 'День — тот же кадр, светлая пара к night-spot',
    palette: WARM,
    cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
    background(ctx, W, H) {
      linear(ctx, W, H, [[0, '#fbf3e7'], [0.6, '#f0e2ce'], [1, '#d8bd98']], 0, 0, 0, H);
      radialGlow(ctx, W, H, W * 0.5, H * 0.5, W * 0.55, 'rgba(255,250,236,0.85)', 'rgba(255,250,236,0)');
    },
    effects(ctx, W, H) {
      farHaze(ctx, W, H, '251,243,231', 0.5, 0.9);
      nearFade(ctx, W, H, '150,113,72', 0.84, 0.35);
      vignette(ctx, W, H, 0.3);
    },
  },
];

// -------------------------------------------------------------------------- VK guides

/**
 * VK_ZONES are the community-cover keep-outs measured off VK's own Figma template
 * (assets/vk/VK_group_cover_template.fig), in 1920×768 cover pixels: `topCrop` is the band
 * some clients shave off the top, and `avatar` is the community picture VK overlays on the
 * bottom-left in the mobile app (its centre sits below the canvas).
 */
const VK_ZONES = { topCrop: 130, avatar: { cx: 283, cy: 818, r: 283 } };

/** drawZones overlays the VK keep-outs, for checking a composition (ZONES=1). */
function drawZones(ctx, W, H) {
  const s = W / OUT_W;
  ctx.save();
  ctx.fillStyle = 'rgba(255,0,90,0.22)';
  ctx.fillRect(0, 0, W, VK_ZONES.topCrop * s);
  ctx.beginPath();
  ctx.arc(VK_ZONES.avatar.cx * s, VK_ZONES.avatar.cy * s, VK_ZONES.avatar.r * s, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,0,90,0.9)';
  ctx.lineWidth = 2 * s;
  ctx.stroke();
  ctx.restore();
}

// ---------------------------------------------------------------------------------- main

/** OUT_W / OUT_H are the VK community cover dimensions; SS is the supersampling factor
 *  (FAST=1 drops it to 1 for quick art-direction passes — the shipped asset uses 2). */
const OUT_W = 1920;
const OUT_H = 768;
const SS = process.env.FAST ? 1 : 2;

function render(v, grid) {
  const big = new Canvas(OUT_W * SS, OUT_H * SS);
  const bctx = big.getContext('2d');
  drawScene(bctx, OUT_W * SS, OUT_H * SS, v, grid, SS);
  if (process.env.ZONES) drawZones(bctx, OUT_W * SS, OUT_H * SS);
  const out = new Canvas(OUT_W, OUT_H);
  const octx = out.getContext('2d');
  octx.imageSmoothingEnabled = true;
  octx.imageSmoothingQuality = 'high';
  octx.drawImage(big, 0, 0, OUT_W, OUT_H);
  return out;
}

const grid = buildBoard();
const want = process.argv.slice(2);
for (const v of VARIANTS) {
  if (want.length && !want.includes(v.id)) continue;
  const started = Date.now();
  const file = join(DIR, `cover-${v.id}.png`);
  render(v, grid).toFileSync(file);
  console.log(`wrote ${file} (${Date.now() - started} ms) — ${v.title}`);
}
